From 37511780b70a2b891f9d9d4322f2c65129081f70 Mon Sep 17 00:00:00 2001 From: mudabbir-ahmad Date: Sat, 21 Feb 2026 13:14:01 +0000 Subject: [PATCH] created a localised login system --- .gitignore | 2 + App.js | 135 ++++- README.md | 291 +++++++++- app.json | 5 +- package.json | 30 +- src/API/NASAPI.js | 203 +++++++ src/AUTH/AuthServiceBase.js | 34 ++ src/AUTH/GoogleAUTH.js | 194 +++++++ src/AUTH/IOSAUTH.js | 194 +++++++ src/AUTH/NASAUTH.js | 222 ++++++++ src/screens/HomeScreen.js | 821 +++++++++++++++++++++++++++ src/screens/LoginScreen.js | 186 ++++++ src/screens/OnboardingScreen.js | 642 +++++++++++++++++++++ src/screens/RegisterScreen.js | 258 +++++++++ src/services/CredentialEncryption.js | 96 ++++ src/services/DatabaseService.js | 428 ++++++++++++++ 16 files changed, 3721 insertions(+), 20 deletions(-) create mode 100644 .gitignore create mode 100644 src/AUTH/AuthServiceBase.js create mode 100644 src/screens/HomeScreen.js create mode 100644 src/screens/OnboardingScreen.js create mode 100644 src/services/CredentialEncryption.js create mode 100644 src/services/DatabaseService.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61f1bf6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +!/Ignore files/ +!/package-lock.json diff --git a/App.js b/App.js index 09f879b..ab515f6 100644 --- a/App.js +++ b/App.js @@ -1,20 +1,127 @@ +import React, { useEffect, useState } from 'react'; import { StatusBar } from 'expo-status-bar'; -import { StyleSheet, Text, View } from 'react-native'; +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { ActivityIndicator, View, Text, TouchableOpacity } from 'react-native'; +import DatabaseService from './src/services/DatabaseService'; +import LoginScreen from './src/screens/LoginScreen'; +import RegisterScreen from './src/screens/RegisterScreen'; +import OnboardingScreen from './src/screens/OnboardingScreen'; +import HomeScreen from './src/screens/HomeScreen'; -export default function App() { +const Stack = createNativeStackNavigator(); + +/** + * App.js - Navigation Controller Only + * + * This component ONLY handles: + * 1. Database initialization + * 2. Navigation stack setup + * 3. Routing to appropriate first screen + * + * NO DATA STORAGE OR DISPLAY HERE + * All screens handle their own state and data + */ + +function AppNavigator() { return ( - - Open up App.js to start working on your app! - - + + {/* Authentication Stack - User not logged in */} + + + + + {/* App Stack - User logged in */} + + ); } -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#fff', - alignItems: 'center', - justifyContent: 'center', - }, -}); +export default function App() { + const [isReady, setIsReady] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + initializeApp(); + }, []); + + /** + * Initialize database - only purpose of App.js useEffect + * All other logic belongs in screen components + */ + const initializeApp = async () => { + try { + setError(null); + await DatabaseService.initDatabase(); + setIsReady(true); + } catch (error) { + console.error('Database initialization failed:', error); + setError(error.message || 'Database initialization failed'); + setIsReady(false); + } + }; + + // Show loading while database initializes + if (!isReady && !error) { + return ( + + + + ); + } + + // Show error if database initialization fails + if (error) { + return ( + + + {error} + + + Retry + + + ); + } + + return ( + <> + + + + + + ); +} diff --git a/README.md b/README.md index 0341805..e25fb80 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,289 @@ -# Media-Aggregation-and-Sorting-System -This is a repo for my Mass project. A project that aims to create a decentralised way of storing media. Decentralised in the sense that media is stored across cloud providers and local storage solutions. +# Media Aggregation and Sorting System + +A React Native/Expo application that aggregates photos and videos from multiple sources (Google Photos, iCloud, and Network-Attached Storage) into a unified photo library. + +## Features + +### Core Functionality +- **User Authentication**: Local account creation and login with SQLite database storage +- **Multi-Service Integration**: + - 🔵 **Google Photos**: OAuth2 integration for Google Photos API + - 🍎 **iCloud Photos**: Apple iCloud account support (placeholder) + - 💾 **NAS Storage**: SFTP connection to Ubuntu servers for photo/video access + +- **Onboarding Flow**: + - Service selection (enable 1-3 services) + - Service configuration with credentials + - Default service selection for uploads + +- **Photo Gallery**: + - Unified photo library view combining all services + - Google Photos-styled grid layout + - Modal image viewer with metadata display + - Support for photos and videos + +### Architecture + +#### Database Schema +- **users**: User account storage (username, email, password) +- **linked_accounts**: External service credentials and configurations +- **media**: Photo/video metadata from all sources +- **sync_logs**: Sync operation history and status + +#### Authentication Services +All authentication services follow the `AuthServiceBase` interface for easy interchangeability: + +1. **GoogleAUTH**: OAuth2 authentication with Google Photos API +2. **IOSAUTH**: Apple iCloud authentication (currently placeholder) +3. **NASAUTH**: SFTP connection to Ubuntu servers via SSH2 + +#### Navigation Flow +``` +Login/Register → Onboarding (Service Setup) → Home (Photo Gallery) +``` + +## Project Structure + +``` +Media-Aggregation-and-Sorting-System/ +├── src/ +│ ├── AUTH/ +│ │ ├── AuthServiceBase.js # Abstract auth service interface +│ │ ├── GoogleAUTH.js # Google Photos authentication +│ │ ├── IOSAUTH.js # iCloud authentication +│ │ └── NASAUTH.js # NAS SFTP authentication +│ ├── screens/ +│ │ ├── LoginScreen.js # Login form +│ │ ├── RegisterScreen.js # Registration form +│ │ ├── OnboardingScreen.js # Service setup wizard +│ │ └── HomeScreen.js # Main photo gallery +│ ├── services/ +│ │ └── DatabaseService.js # SQLite database operations +│ ├── components/ +│ └── API/ +├── App.js # Navigation setup +├── app.json # Expo configuration +├── package.json # Dependencies +└── README.md +``` + +## Installation + +### Prerequisites +- Node.js and npm +- Expo CLI: `npm install -g expo-cli` +- iOS Simulator or Android Emulator (or physical device with Expo Go) + +### Setup Steps + +1. **Clone and Install Dependencies** + ```bash + cd Media-Aggregation-and-Sorting-System + npm install + ``` + +2. **Start the Development Server** + ```bash + npm start + ``` + +3. **Run on Device/Emulator** + - iOS: Press `i` in the terminal + - Android: Press `a` in the terminal + - Physical Device: Scan QR code with Expo Go app + +## Usage + +### Creating an Account +1. Open the app → Select "Register here" +2. Enter username, email, and password (min 6 characters) +3. Confirm password and create account + +### Login +1. Enter registered email and password +2. Click "Login" + +### Onboarding (Service Configuration) +**Step 1: Select Services** +- Toggle which services to enable (1-3 maximum) +- Google Photos, iCloud, and/or NAS + +**Step 2: Configure Services** +- **Google Photos**: Tap to authenticate with Google (OAuth flow) +- **iCloud**: Tap to authenticate with Apple (OAuth flow) +- **NAS**: Enter connection details: + - Server address (IP or hostname) + - SSH port (default: 22) + - Username + - Password + - Photos folder path + - Test connection before proceeding + +**Step 3: Choose Default Service** +- Select which service to use by default for uploads + +### Photo Gallery +- View all photos/videos from connected services +- Service badges at top show connected accounts +- ★ indicates the default service +- Tap any photo to view in enlarged modal +- Modal shows metadata: title, description, source, type, date + +## API Integration (TODO) + +### Google Photos Implementation +- Replace placeholder `authenticate()` with actual Google Sign-In +- Implement OAuth2 token exchange +- Add `/photoslibrary/v1/mediaItems:search` API calls +- Handle pagination and filtering + +### iCloud Implementation +- Integrate Apple Sign-In +- Implement CloudKit API for photo access +- Handle iCloud authentication + +### NAS Implementation +- Replace SSH2 placeholders with actual library implementation +- Implement SFTP file listing and download +- Add file upload functionality +- Handle directory navigation + +## Configuration + +### Environment Variables (Optional) +Create a `.env` file for sensitive configuration: +```env +GOOGLE_CLIENT_ID=your_client_id +GOOGLE_CLIENT_SECRET=your_client_secret +NAS_DEFAULT_PORT=22 +``` + +### Database +- SQLite database is automatically created on first run +- Location: Device's app-specific directory +- Filename: `PhotosApp.db` + +## Authentication Service Pattern + +Each service implements the same interface: + +```javascript +class AuthService extends AuthServiceBase { + async authenticate(credentials) { } + async getAccessToken() { } + async refreshToken() { } + async revokeAccess() { } + async getUserInfo() { } + // Service-specific methods +} +``` + +### Easy Interchangeability +To swap or extend authentication: + +1. Create new class extending `AuthServiceBase` +2. Implement all abstract methods +3. Update the service type in `DatabaseService.linkAccount()` +4. Update `OnboardingScreen` UI if needed + +## Data Flow + +``` +Login/Register + ↓ +Authentication → Save to SQLite + ↓ +Onboarding → Service Selection + ↓ +Service Auth → Store Credentials + ↓ +Home Screen → Fetch from Services + ↓ +Display in Gallery +``` + +## Security Considerations + +⚠️ **Current Implementation Warnings**: +- Passwords stored in plain text in SQLite (development only) +- NAS passwords stored in plain text (development only) +- OAuth tokens not persisted securely + +**For Production**: +- Use secure storage (Keychain/Keystore) +- Encrypt sensitive data in database +- Implement token refresh and expiration +- Use OAuth for all services +- Never store plain-text passwords + +## Dependencies + +### Core +- `react`: 19.1.0 +- `react-native`: 0.81.5 +- `expo`: ~54.0.31 + +### Navigation +- `@react-navigation/native`: ^6.1.17 +- `@react-navigation/native-stack`: ^6.9.26 +- `react-native-screens`: ^3.31.1 +- `react-native-safe-area-context`: ^4.10.5 +- `react-native-gesture-handler`: ^2.16.1 + +### Database & Storage +- `react-native-sqlite-storage`: ^6.0.0 +- `expo-file-system`: ~17.0.1 +- `expo-media-library`: ~15.9.0 + +### Network +- `ssh2`: ^1.15.0 +- `react-native-ssh2`: ^1.0.0 + +### Media +- `expo-image-picker`: ~14.7.1 + +## Troubleshooting + +### Database Initialization Error +``` +Solution: Clear app cache/reinstall +npx expo prebuild --clean +npm start +``` + +### Navigation Issues +Ensure all screens are properly exported and imported in App.js + +### Service Connection Failures +- Check network connectivity +- Verify API credentials +- Check NAS server accessibility (port 22 open) +- Review console logs for detailed error messages + +## Future Enhancements + +- [ ] Implement actual Google Photos API integration +- [ ] Implement actual iCloud Photos integration +- [ ] Complete SFTP functionality for NAS +- [ ] Photo upload to default service +- [ ] Photo search and filtering +- [ ] Album management +- [ ] Automatic sync scheduling +- [ ] Cloud backup +- [ ] Photo sharing features +- [ ] Advanced metadata editing +- [ ] Duplicate detection +- [ ] Smart album creation +- [ ] Facial recognition (optional) + +## Contributing + +This is a personal project. Feel free to fork and extend! + +## License + +MIT License - Feel free to use for personal or commercial projects. + +## Support + +For issues or questions, please check the code comments and inline TODOs for implementation guidance. diff --git a/app.json b/app.json index 556106a..cdb30ec 100644 --- a/app.json +++ b/app.json @@ -24,6 +24,9 @@ }, "web": { "favicon": "./assets/favicon.png" - } + }, + "plugins": [ + "expo-sqlite" + ] } } diff --git a/package.json b/package.json index 9fb791f..59be439 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,37 @@ "start": "expo start", "android": "expo start --android", "ios": "expo start --ios", - "web": "expo start --web" + "web": "expo start --web", + "backend": "node backend/server.js", + "backend-dev": "nodemon backend/server.js" }, "dependencies": { - "expo": "~54.0.31", + "@react-native-community/masked-view": "^0.1.11", + "@react-navigation/native": "^6.1.18", + "@react-navigation/native-stack": "^6.9.26", + "@react-navigation/stack": "^6.4.1", + "axios": "^1.13.5", + "expo": "~54.0.33", + "expo-auth-session": "^7.0.10", + "expo-crypto": "^15.0.8", + "expo-file-system": "~19.0.21", + "expo-image-picker": "~17.0.10", + "expo-linking": "^8.0.11", + "expo-media-library": "~18.2.1", + "expo-secure-store": "^15.0.8", + "expo-sqlite": "~16.0.10", "expo-status-bar": "~3.0.9", + "expo-web-browser": "^15.0.10", "react": "19.1.0", - "react-native": "0.81.5" + "react-native": "0.81.5", + "react-native-gesture-handler": "~2.28.0", + "react-native-reanimated": "~4.1.1", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.16.0", + "ssh2-sftp-client": "^12.0.1" + }, + "devDependencies": { + "@babel/core": "^7.29.0" }, "private": true } diff --git a/src/API/NASAPI.js b/src/API/NASAPI.js index e69de29..ceff92e 100644 --- a/src/API/NASAPI.js +++ b/src/API/NASAPI.js @@ -0,0 +1,203 @@ +/** + * NAS API - Direct Connection Handler + * + * Handles NAS/SFTP connections directly from the React Native app + * Note: Since React Native cannot use SSH2 directly, this provides + * credential management and placeholder methods. For production, + * consider using a WebDAV server on your NAS instead of SFTP. + */ + +class NASAPI { + constructor() { + this.connection = null; + this.credentials = null; + this.isConnected = false; + } + + /** + * Test NAS connection + * Stores credentials for later use + */ + async connect(host, port, username, password, photoFolder = '/Photos') { + try { + // Validate credentials + if (!host || !username || !password) { + throw new Error('Missing required connection details'); + } + + // Store credentials securely + this.credentials = { + host, + port: port || 22, + username, + password, // In production, encrypt this + photoFolder, + connectedAt: new Date().toISOString(), + }; + + this.isConnected = true; + + console.log(`✓ NAS credentials stored for ${host}:${port}`); + + return { + success: true, + message: `NAS configured: ${host}`, + connectionData: { + host, + port, + username, + photoFolder, + }, + }; + } catch (error) { + console.error('NAS connection error:', error); + throw error; + } + } + + /** + * Disconnect from NAS + */ + async disconnect() { + this.connection = null; + this.credentials = null; + this.isConnected = false; + + return { + success: true, + message: 'Disconnected from NAS', + }; + } + + /** + * List files in NAS directory + * Note: Placeholder - In production, use WebDAV or backend server + */ + async listDirectory(path = '/') { + if (!this.isConnected) { + throw new Error('Not connected to NAS'); + } + + console.log(`📁 Listing directory: ${path}`); + + // Placeholder response + return { + path, + files: [], + message: 'NAS file listing requires WebDAV or backend server', + }; + } + + /** + * Get photos from NAS + * Note: Placeholder - In production, use WebDAV or backend server + */ + async getPhotos(folder = null) { + if (!this.isConnected) { + throw new Error('Not connected to NAS'); + } + + const targetFolder = folder || this.credentials?.photoFolder || '/Photos'; + console.log(`📷 Getting photos from: ${targetFolder}`); + + // Placeholder response + return { + mediaItems: [], + folder: targetFolder, + message: 'NAS photo retrieval requires WebDAV or backend server', + }; + } + + /** + * Upload file to NAS + * Note: Placeholder - In production, use WebDAV or backend server + */ + async uploadFile(localPath, remotePath) { + if (!this.isConnected) { + throw new Error('Not connected to NAS'); + } + + console.log(`⬆️ Upload: ${localPath} -> ${remotePath}`); + + return { + success: false, + message: 'NAS file upload requires WebDAV or backend server', + }; + } + + /** + * Download file from NAS + * Note: Placeholder - In production, use WebDAV or backend server + */ + async downloadFile(remotePath, localPath) { + if (!this.isConnected) { + throw new Error('Not connected to NAS'); + } + + console.log(`⬇️ Download: ${remotePath} -> ${localPath}`); + + return { + success: false, + message: 'NAS file download requires WebDAV or backend server', + }; + } + + /** + * Delete file from NAS + */ + async deleteFile(remotePath) { + if (!this.isConnected) { + throw new Error('Not connected to NAS'); + } + + console.log(`🗑️ Delete: ${remotePath}`); + + return { + success: false, + message: 'NAS file deletion requires WebDAV or backend server', + }; + } + + /** + * Create directory on NAS + */ + async createDirectory(remotePath) { + if (!this.isConnected) { + throw new Error('Not connected to NAS'); + } + + console.log(`📁 Create directory: ${remotePath}`); + + return { + success: false, + message: 'NAS directory creation requires WebDAV or backend server', + }; + } + + /** + * Get connection status + */ + getConnectionInfo() { + if (!this.isConnected || !this.credentials) { + return null; + } + + return { + host: this.credentials.host, + port: this.credentials.port, + username: this.credentials.username, + photoFolder: this.credentials.photoFolder, + connectedAt: this.credentials.connectedAt, + isConnected: this.isConnected, + }; + } + + /** + * Check if connected + */ + isConnectedToNAS() { + return this.isConnected; + } +} + +export default NASAPI; diff --git a/src/AUTH/AuthServiceBase.js b/src/AUTH/AuthServiceBase.js new file mode 100644 index 0000000..aea0ff6 --- /dev/null +++ b/src/AUTH/AuthServiceBase.js @@ -0,0 +1,34 @@ +/** + * Abstract base class for authentication services + * This provides a common interface for different authentication providers + */ +export class AuthServiceBase { + constructor() { + if (new.target === AuthServiceBase) { + throw new TypeError('Cannot instantiate abstract class'); + } + } + + async authenticate() { + throw new Error('authenticate() must be implemented'); + } + + async getAccessToken() { + throw new Error('getAccessToken() must be implemented'); + } + + async refreshToken() { + throw new Error('refreshToken() must be implemented'); + } + + async revokeAccess() { + throw new Error('revokeAccess() must be implemented'); + } + + async getUserInfo() { + throw new Error('getUserInfo() must be implemented'); + } +} + +export default AuthServiceBase; + diff --git a/src/AUTH/GoogleAUTH.js b/src/AUTH/GoogleAUTH.js index e69de29..3cc07a5 100644 --- a/src/AUTH/GoogleAUTH.js +++ b/src/AUTH/GoogleAUTH.js @@ -0,0 +1,194 @@ +import { AuthServiceBase } from './AuthServiceBase'; + +/** + * Google Photos Authentication Service + * Handles OAuth2 authentication with Google Photos API + */ +class GoogleAUTH extends AuthServiceBase { + constructor() { + super(); + this.accessToken = null; + this.refreshToken = null; + this.expiresAt = null; + } + + /** + * Authenticate with Google OAuth + * In a real app, you would use Google Sign-In library + */ + async authenticate(authCode) { + try { + // Validate input + if (!authCode) { + throw new Error('Authorization code is required'); + } + + // TODO: Implement actual Google OAuth flow + // This would typically involve: + // 1. Initiating Google Sign-In + // 2. Getting the authorization code + // 3. Exchanging it for access and refresh tokens + console.log('Authenticating with Google...'); + + // Placeholder: In production, exchange authCode for tokens + this.accessToken = 'google_access_token_placeholder'; + this.refreshToken = 'google_refresh_token_placeholder'; + this.expiresAt = Date.now() + 3600000; // 1 hour from now + + return { + success: true, + message: 'Google authentication successful', + accessToken: this.accessToken, + }; + } catch (error) { + console.error('Google authentication error:', error); + throw error; + } + } + + /** + * Get current access token + */ + async getAccessToken() { + if (this.accessToken && this.expiresAt > Date.now()) { + return this.accessToken; + } + // If token is expired, refresh it + if (this.refreshToken) { + return await this.refreshToken(); + } + throw new Error('No valid access token available'); + } + + /** + * Refresh the access token using refresh token + */ + async refreshToken() { + try { + if (!this.refreshToken) { + throw new Error('No refresh token available'); + } + + // TODO: Implement actual token refresh with Google API + console.log('Refreshing Google access token...'); + + // Placeholder: In production, call Google token endpoint + this.accessToken = 'new_google_access_token_placeholder'; + this.expiresAt = Date.now() + 3600000; + + return this.accessToken; + } catch (error) { + console.error('Token refresh error:', error); + throw error; + } + } + + /** + * Revoke access and clear tokens + */ + async revokeAccess() { + try { + // TODO: Implement actual revocation with Google API + console.log('Revoking Google access...'); + + this.accessToken = null; + this.refreshToken = null; + this.expiresAt = null; + + return { + success: true, + message: 'Google access revoked', + }; + } catch (error) { + console.error('Revoke error:', error); + throw error; + } + } + + /** + * Get user info from Google + */ + async getUserInfo() { + try { + const token = await this.getAccessToken(); + + // TODO: Implement actual API call to Google People API + console.log('Fetching user info from Google...'); + + // Placeholder user info + return { + id: 'google_user_id_placeholder', + email: 'user@gmail.com', + name: 'Google User', + photoUrl: 'https://via.placeholder.com/150', + }; + } catch (error) { + console.error('Get user info error:', error); + throw error; + } + } + + /** + * Get media list from Google Photos + */ + async getPhotos(pageSize = 50, pageToken = null) { + try { + const token = await this.getAccessToken(); + + // TODO: Implement actual API call to Google Photos API + console.log('Fetching photos from Google Photos...'); + + // Placeholder response + return { + mediaItems: [], + nextPageToken: null, + }; + } catch (error) { + console.error('Get photos error:', error); + throw error; + } + } + + /** + * Get albums from Google Photos + */ + async getAlbums(pageSize = 50, pageToken = null) { + try { + const token = await this.getAccessToken(); + + // TODO: Implement actual API call to Google Photos API + console.log('Fetching albums from Google Photos...'); + + return { + albums: [], + nextPageToken: null, + }; + } catch (error) { + console.error('Get albums error:', error); + throw error; + } + } + + /** + * Search photos by date range + */ + async searchPhotos(filters = {}) { + try { + const token = await this.getAccessToken(); + + // TODO: Implement actual API call with filters + console.log('Searching photos with filters:', filters); + + return { + mediaItems: [], + nextPageToken: null, + }; + } catch (error) { + console.error('Search photos error:', error); + throw error; + } + } +} + +export default GoogleAUTH; + diff --git a/src/AUTH/IOSAUTH.js b/src/AUTH/IOSAUTH.js index e69de29..0960c50 100644 --- a/src/AUTH/IOSAUTH.js +++ b/src/AUTH/IOSAUTH.js @@ -0,0 +1,194 @@ +import { AuthServiceBase } from './AuthServiceBase'; + +/** + * iOS iCloud Authentication Service + * Handles authentication with Apple iCloud Photos (placeholder implementation) + */ +class IOSAUTH extends AuthServiceBase { + constructor() { + super(); + this.accessToken = null; + this.refreshToken = null; + this.expiresAt = null; + } + + /** + * Authenticate with iCloud + * Placeholder - actual implementation would use Apple's authentication + */ + async authenticate(username, password) { + try { + // Validate input + if (!username || !password) { + throw new Error('Username and password are required'); + } + + // TODO: Implement actual Apple iCloud authentication + // This would require: + // 1. Apple Sign-In framework + // 2. iCloud session management + // 3. Secure credential storage + console.log('Authenticating with iCloud...'); + + // Placeholder: In production, authenticate with Apple servers + this.accessToken = 'icloud_access_token_placeholder'; + this.refreshToken = 'icloud_refresh_token_placeholder'; + this.expiresAt = Date.now() + 3600000; // 1 hour from now + + return { + success: true, + message: 'iCloud authentication successful', + accessToken: this.accessToken, + }; + } catch (error) { + console.error('iCloud authentication error:', error); + throw error; + } + } + + /** + * Get current access token + */ + async getAccessToken() { + if (this.accessToken && this.expiresAt > Date.now()) { + return this.accessToken; + } + // If token is expired, refresh it + if (this.refreshToken) { + return await this.refreshToken(); + } + throw new Error('No valid access token available'); + } + + /** + * Refresh the access token + */ + async refreshToken() { + try { + if (!this.refreshToken) { + throw new Error('No refresh token available'); + } + + // TODO: Implement actual token refresh with iCloud API + console.log('Refreshing iCloud access token...'); + + // Placeholder + this.accessToken = 'new_icloud_access_token_placeholder'; + this.expiresAt = Date.now() + 3600000; + + return this.accessToken; + } catch (error) { + console.error('Token refresh error:', error); + throw error; + } + } + + /** + * Revoke access and clear tokens + */ + async revokeAccess() { + try { + // TODO: Implement actual revocation with Apple iCloud + console.log('Revoking iCloud access...'); + + this.accessToken = null; + this.refreshToken = null; + this.expiresAt = null; + + return { + success: true, + message: 'iCloud access revoked', + }; + } catch (error) { + console.error('Revoke error:', error); + throw error; + } + } + + /** + * Get user info from iCloud + */ + async getUserInfo() { + try { + const token = await this.getAccessToken(); + + // TODO: Implement actual API call to iCloud + console.log('Fetching user info from iCloud...'); + + // Placeholder user info + return { + id: 'icloud_user_id_placeholder', + email: 'user@icloud.com', + name: 'iCloud User', + photoUrl: 'https://via.placeholder.com/150', + }; + } catch (error) { + console.error('Get user info error:', error); + throw error; + } + } + + /** + * Get photos from iCloud Photos + */ + async getPhotos(pageSize = 50, pageToken = null) { + try { + const token = await this.getAccessToken(); + + // TODO: Implement actual API call to iCloud Photos API + console.log('Fetching photos from iCloud Photos...'); + + // Placeholder response + return { + mediaItems: [], + nextPageToken: null, + }; + } catch (error) { + console.error('Get photos error:', error); + throw error; + } + } + + /** + * Get albums from iCloud Photos + */ + async getAlbums(pageSize = 50, pageToken = null) { + try { + const token = await this.getAccessToken(); + + // TODO: Implement actual API call to iCloud Photos API + console.log('Fetching albums from iCloud Photos...'); + + return { + albums: [], + nextPageToken: null, + }; + } catch (error) { + console.error('Get albums error:', error); + throw error; + } + } + + /** + * Search photos in iCloud + */ + async searchPhotos(filters = {}) { + try { + const token = await this.getAccessToken(); + + // TODO: Implement actual search with filters + console.log('Searching photos in iCloud with filters:', filters); + + return { + mediaItems: [], + nextPageToken: null, + }; + } catch (error) { + console.error('Search photos error:', error); + throw error; + } + } +} + +export default IOSAUTH; + diff --git a/src/AUTH/NASAUTH.js b/src/AUTH/NASAUTH.js index e69de29..ada672c 100644 --- a/src/AUTH/NASAUTH.js +++ b/src/AUTH/NASAUTH.js @@ -0,0 +1,222 @@ +import { AuthServiceBase } from './AuthServiceBase'; +import NASAPI from '../API/NASAPI'; + +/** + * NAS SFTP Authentication Service + * + * Uses NASAPI for direct NAS connection management + * No backend server required + */ +class NASAUTH extends AuthServiceBase { + constructor() { + super(); + this.nasAPI = new NASAPI(); + this.host = null; + this.port = 22; + this.username = null; + this.password = null; + this.photoFolder = null; + this.isConnected = false; + } + + /** + * Authenticate with NAS/SFTP server + */ + async authenticate(host, port = 22, username, password, photoFolder = '/Photos') { + try { + // Validate input + if (!host || !username || !password) { + throw new Error('Host, username, and password are required'); + } + + console.log(`Connecting to NAS at ${host}:${port}...`); + + this.host = host; + this.port = port || 22; + this.username = username; + this.password = password; + this.photoFolder = photoFolder; + + // Connect via NASAPI + const result = await this.nasAPI.connect(host, this.port, username, password, photoFolder); + + this.isConnected = true; + + return { + success: true, + message: `NAS configured: ${host}`, + host: this.host, + username: this.username, + }; + } catch (error) { + console.error('NAS authentication error:', error.message); + this.isConnected = false; + throw new Error(`Failed to connect to NAS: ${error.message}`); + } + } + + /** + * Get current access/connection status + */ + async getAccessToken() { + if (!this.isConnected) { + throw new Error('Not connected to NAS'); + } + return { + host: this.host, + username: this.username, + isConnected: this.isConnected, + }; + } + + /** + * Refresh connection (reconnect if needed) + */ + async refreshToken() { + try { + if (this.host && this.username && this.password) { + return await this.authenticate( + this.host, + this.port, + this.username, + this.password, + this.photoFolder + ); + } + throw new Error('No NAS credentials available for reconnection'); + } catch (error) { + console.error('Connection refresh error:', error); + throw error; + } + } + + /** + * Disconnect from NAS + */ + async revokeAccess() { + try { + await this.nasAPI.disconnect(); + + this.isConnected = false; + this.host = null; + this.username = null; + this.password = null; + this.photoFolder = null; + + return { + success: true, + message: 'NAS connection closed', + }; + } catch (error) { + console.error('Disconnect error:', error.message); + this.isConnected = false; + throw error; + } + } + + /** + * Get NAS server information + */ + async getUserInfo() { + try { + if (!this.isConnected) { + throw new Error('Not connected to NAS'); + } + + return { + host: this.host, + username: this.username, + port: this.port, + photoFolder: this.photoFolder, + connectedAt: new Date().toISOString(), + serverType: 'NAS SFTP', + }; + } catch (error) { + console.error('Get user info error:', error); + throw error; + } + } + + /** + * List directories on NAS + */ + async listDirectory(remotePath = '/') { + try { + if (!this.isConnected) { + throw new Error('Not connected to NAS'); + } + + return await this.nasAPI.listDirectory(remotePath); + } catch (error) { + console.error('List directory error:', error); + throw error; + } + } + + /** + * Upload file to NAS + */ + async uploadFile(localPath, remotePath) { + try { + if (!this.isConnected) { + throw new Error('Not connected to NAS'); + } + + return await this.nasAPI.uploadFile(localPath, remotePath); + } catch (error) { + console.error('Upload error:', error); + throw error; + } + } + + /** + * Get photos from designated NAS photo folder + */ + async getPhotos(photoFolder = null) { + try { + if (!this.isConnected) { + throw new Error('Not connected to NAS'); + } + + const folder = photoFolder || this.photoFolder || '/Photos'; + return await this.nasAPI.getPhotos(folder); + } catch (error) { + console.error('Get photos error:', error); + throw error; + } + } + + /** + * Create directory on NAS + */ + async createDirectory(remotePath) { + try { + if (!this.isConnected) { + throw new Error('Not connected to NAS'); + } + + return await this.nasAPI.createDirectory(remotePath); + } catch (error) { + console.error('Create directory error:', error); + throw error; + } + } + + /** + * Delete file from NAS + */ + async deleteFile(remotePath) { + try { + if (!this.isConnected) { + throw new Error('Not connected to NAS'); + } + + return await this.nasAPI.deleteFile(remotePath); + } catch (error) { + console.error('Delete file error:', error); + throw error; + } + } +} + +export default NASAUTH; diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js new file mode 100644 index 0000000..be7c31d --- /dev/null +++ b/src/screens/HomeScreen.js @@ -0,0 +1,821 @@ +import React, { useState, useEffect } from 'react'; +import { + StyleSheet, + View, + Text, + FlatList, + ActivityIndicator, + Modal, + TouchableOpacity, + Dimensions, + Image, + Alert, + ScrollView, + RefreshControl, +} from 'react-native'; +import DatabaseService from '../services/DatabaseService'; +import GoogleAUTH from '../AUTH/GoogleAUTH'; +import IOSAUTH from '../AUTH/IOSAUTH'; +import NASAUTH from '../AUTH/NASAUTH'; + +const { width } = Dimensions.get('window'); +const GRID_COLUMNS = 3; +const imageSize = (width - 50) / GRID_COLUMNS; + +/** + * HomeScreen - Main photo gallery + * + * Responsibilities: + * - Load user data from database + * - Fetch photos from all connected services + * - Display photos in grid layout + * - Handle photo viewing in modal + * - Manage logout + */ + +const HomeScreen = ({ navigation, route }) => { + const userId = route?.params?.userId; + + // User data + const [currentUser, setCurrentUser] = useState(null); + const [linkedAccounts, setLinkedAccounts] = useState([]); + + // Media data + const [allMedia, setAllMedia] = useState([]); + const [filteredMedia, setFilteredMedia] = useState([]); + const [selectedFilter, setSelectedFilter] = useState('all'); + + // UI state + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [selectedMedia, setSelectedMedia] = useState(null); + const [modalVisible, setModalVisible] = useState(false); + const [error, setError] = useState(null); + + // Effect: Load user data on mount + useEffect(() => { + if (!userId) { + console.error('No userId provided to HomeScreen'); + setError('User ID is missing'); + setLoading(false); + return; + } + loadUserData(); + }, [userId]); + + // Effect: Load media when user data is ready + useEffect(() => { + if (currentUser) { + loadAllMedia(); + } + }, [currentUser]); + + // Effect: Filter media when filter changes + useEffect(() => { + filterMedia(); + }, [selectedFilter, allMedia]); + + /** + * Load user and linked accounts from database + */ + const loadUserData = async () => { + try { + const user = await DatabaseService.getUserById(userId); + setCurrentUser(user); + + const accounts = await DatabaseService.getLinkedAccounts(userId); + setLinkedAccounts(accounts); + + setError(null); + } catch (err) { + console.error('Error loading user data:', err); + setError('Failed to load user information'); + Alert.alert('Error', 'Failed to load user information'); + } + }; + + /** + * Load media from all connected services + */ + const loadAllMedia = async () => { + setLoading(true); + try { + // First, try to get media from database + const mediaFromDb = await DatabaseService.getMediaByUser(userId); + + // Then fetch new media from each connected service + const newMedia = []; + + // Fetch from Google Photos if enabled + const googleAccount = linkedAccounts.find( + (acc) => acc.service_type === 'GOOGLE_PHOTOS' + ); + if (googleAccount) { + try { + const googleAuth = new GoogleAUTH(); + console.log('Google Photos would be fetched here'); + } catch (err) { + console.error('Google Photos fetch failed:', err); + } + } + + // Fetch from iCloud if enabled + const iosAccount = linkedAccounts.find( + (acc) => acc.service_type === 'ICLOUD_PHOTOS' + ); + if (iosAccount) { + try { + const iosAuth = new IOSAUTH(); + console.log('iCloud Photos would be fetched here'); + } catch (err) { + console.error('iCloud Photos fetch failed:', err); + } + } + + // Fetch from NAS if enabled + const nasAccount = linkedAccounts.find( + (acc) => acc.service_type === 'NAS' + ); + if (nasAccount) { + try { + const nasAuth = new NASAUTH(); + const nasData = nasAccount.account_data; + + // Connect to NAS via backend API + await nasAuth.authenticate( + nasData.host, + nasData.port, + nasData.username, + nasData.password, + nasData.photoFolder + ); + + // Get photos + const nasPhotos = await nasAuth.getPhotos(nasData.photoFolder); + if (nasPhotos.mediaItems) { + newMedia.push(...nasPhotos.mediaItems); + } + + // Disconnect + await nasAuth.revokeAccess(); + } catch (err) { + console.error('NAS Photos fetch failed:', err); + } + } + + // Combine database media with newly fetched media + const combinedMedia = [...mediaFromDb, ...newMedia]; + + // Remove duplicates + const uniqueMedia = Array.from( + new Map(combinedMedia.map((m) => [m.source_id || m.id || m.path, m])).values() + ); + + setAllMedia(uniqueMedia); + setError(null); + } catch (err) { + console.error('Error loading media:', err); + setError('Failed to load photos'); + Alert.alert('Error', 'Failed to load photos from services'); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + /** + * Filter media based on selected service + */ + const filterMedia = () => { + let filtered = allMedia; + + if (selectedFilter !== 'all') { + filtered = allMedia.filter( + (media) => media.source_service === selectedFilter + ); + } + + // Sort by date (newest first) + filtered.sort((a, b) => { + const dateA = new Date(a.created_date || a.modifyTime || 0); + const dateB = new Date(b.created_date || b.modifyTime || 0); + return dateB - dateA; + }); + + setFilteredMedia(filtered); + }; + + /** + * Handle refresh gesture + */ + const onRefresh = () => { + setRefreshing(true); + loadAllMedia(); + }; + + /** + * Handle logout + */ + const handleLogout = () => { + Alert.alert( + 'Logout', + 'Are you sure you want to logout?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Logout', + style: 'destructive', + onPress: () => { + navigation.replace('Login'); + }, + }, + ] + ); + }; + + /** + * Get service display name + */ + const getServiceName = (serviceType) => { + const serviceMap = { + GOOGLE_PHOTOS: 'Google Photos', + ICLOUD_PHOTOS: 'iCloud', + NAS: 'NAS', + }; + return serviceMap[serviceType] || serviceType; + }; + + /** + * Get service icon + */ + const getServiceIcon = (serviceType) => { + const iconMap = { + GOOGLE_PHOTOS: '🔵', + ICLOUD_PHOTOS: '🍎', + NAS: '💾', + }; + return iconMap[serviceType] || '📱'; + }; + + /** + * Render grid item (photo) + */ + const renderMediaItem = ({ item }) => ( + { + setSelectedMedia(item); + setModalVisible(true); + }} + activeOpacity={0.7} + > + {item.local_path ? ( + + ) : ( + + 📷 + + )} + + {item.media_type === 'video' && ( + + ▶ + + )} + + + {getServiceIcon(item.source_service)} + + + ); + + // Loading state + if (loading) { + return ( + + + Loading your photos... + + ); + } + + // Error state + if (error && filteredMedia.length === 0) { + return ( + + ⚠️ + Unable to Load Photos + {error} + + Try Again + + + ); + } + + // Empty state + if (filteredMedia.length === 0) { + return ( + + + + Photo Library + + Welcome, {currentUser?.username} + + + + Logout + + + + + 📸 + No Photos Yet + + Photos from your connected services will appear here + + + + ); + } + + // Main content + return ( + + + + Photo Library + + Welcome, {currentUser?.username} + + + + Logout + + + + {linkedAccounts.length > 0 && ( + + setSelectedFilter('all')} + > + + All + + + + {linkedAccounts.map((account) => ( + setSelectedFilter(account.service_type)} + > + + {getServiceIcon(account.service_type)} + + + {getServiceName(account.service_type)} + + + ))} + + )} + + item.id?.toString() || item.path || Math.random().toString()} + numColumns={GRID_COLUMNS} + columnWrapperStyle={styles.columnWrapper} + contentContainerStyle={styles.gridContent} + scrollEnabled={true} + refreshControl={ + + } + /> + + setModalVisible(false)} + animationType="fade" + > + setModalVisible(false)} + serviceIcon={getServiceIcon(selectedMedia?.source_service)} + serviceName={getServiceName(selectedMedia?.source_service)} + /> + + + ); +}; + +/** + * MediaDetailModal - Full screen photo viewer + */ +const MediaDetailModal = ({ media, onClose, serviceIcon, serviceName }) => { + if (!media) return null; + + return ( + + + + + + ✕ + + + + {media.local_path ? ( + + ) : ( + + 📷 + + )} + + + + {media.title || 'Untitled Photo'} + + {media.description && ( + {media.description} + )} + + + + + From + + {serviceIcon} + {serviceName} + + + + {media.media_type && ( + + Type + + {media.media_type === 'video' ? '🎥 Video' : '📷 Photo'} + + + )} + + {(media.created_date || media.modifyTime) && ( + + Date + + {new Date( + media.created_date || media.modifyTime + ).toLocaleDateString()} + + + )} + + {media.size && ( + + Size + + {(media.size / 1024 / 1024).toFixed(2)} MB + + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + centerContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 20, + }, + header: { + backgroundColor: '#fff', + paddingHorizontal: 16, + paddingVertical: 16, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + borderBottomWidth: 1, + borderBottomColor: '#f0f0f0', + }, + headerTitle: { + fontSize: 28, + fontWeight: 'bold', + color: '#1a1a1a', + }, + headerSubtitle: { + fontSize: 13, + color: '#999', + marginTop: 2, + }, + logoutButton: { + backgroundColor: '#ff3b30', + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 6, + }, + logoutText: { + color: '#fff', + fontSize: 13, + fontWeight: '600', + }, + filterScroll: { + paddingHorizontal: 12, + paddingVertical: 12, + gap: 8, + }, + filterButton: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: '#fff', + borderRadius: 16, + borderWidth: 1, + borderColor: '#e0e0e0', + gap: 6, + }, + filterButtonActive: { + backgroundColor: '#007AFF', + borderColor: '#007AFF', + }, + filterButtonText: { + fontSize: 13, + fontWeight: '600', + color: '#333', + }, + filterButtonTextActive: { + color: '#fff', + }, + filterIcon: { + fontSize: 14, + }, + columnWrapper: { + gap: 8, + paddingHorizontal: 8, + marginBottom: 8, + }, + gridContent: { + paddingVertical: 8, + paddingBottom: 20, + }, + gridItem: { + width: imageSize, + height: imageSize, + borderRadius: 8, + overflow: 'hidden', + backgroundColor: '#e8e8e8', + position: 'relative', + }, + gridImage: { + width: '100%', + height: '100%', + }, + placeholderImage: { + width: '100%', + height: '100%', + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#e8e8e8', + }, + placeholderIcon: { + fontSize: 32, + }, + videoOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.3)', + justifyContent: 'center', + alignItems: 'center', + }, + playIcon: { + color: '#fff', + fontSize: 24, + fontWeight: 'bold', + }, + serviceBadge: { + position: 'absolute', + bottom: 6, + right: 6, + backgroundColor: 'rgba(0, 0, 0, 0.6)', + borderRadius: 12, + paddingHorizontal: 6, + paddingVertical: 3, + }, + badgeIcon: { + fontSize: 12, + }, + loadingText: { + marginTop: 12, + fontSize: 16, + color: '#666', + }, + errorIcon: { + fontSize: 64, + marginBottom: 16, + }, + errorTitle: { + fontSize: 20, + fontWeight: 'bold', + color: '#333', + marginBottom: 8, + }, + errorText: { + fontSize: 14, + color: '#666', + marginBottom: 24, + textAlign: 'center', + }, + retryButton: { + backgroundColor: '#007AFF', + paddingHorizontal: 24, + paddingVertical: 10, + borderRadius: 8, + }, + retryButtonText: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, + emptyIcon: { + fontSize: 64, + marginBottom: 16, + }, + emptyTitle: { + fontSize: 20, + fontWeight: 'bold', + color: '#333', + marginBottom: 8, + }, + emptyText: { + fontSize: 14, + color: '#999', + textAlign: 'center', + }, + modalContainer: { + flex: 1, + backgroundColor: '#000', + }, + modalBackdrop: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + modalContent: { + flex: 1, + zIndex: 10, + }, + closeButton: { + position: 'absolute', + top: 12, + right: 12, + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 20, + }, + closeButtonText: { + color: '#fff', + fontSize: 24, + fontWeight: '600', + }, + imageContainer: { + flex: 0.6, + backgroundColor: '#000', + }, + imageContent: { + justifyContent: 'center', + alignItems: 'center', + minHeight: '100%', + }, + fullImage: { + width: '100%', + height: '100%', + }, + placeholderLarge: { + width: 200, + height: 200, + backgroundColor: '#333', + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + }, + placeholderLargeIcon: { + fontSize: 64, + }, + infoPanel: { + flex: 0.4, + backgroundColor: '#fff', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + padding: 16, + paddingBottom: 24, + }, + infoTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 6, + }, + infoDescription: { + fontSize: 13, + color: '#666', + marginBottom: 12, + }, + infoDivider: { + height: 1, + backgroundColor: '#f0f0f0', + marginVertical: 12, + }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 10, + }, + infoLabel: { + fontSize: 12, + fontWeight: '600', + color: '#999', + }, + infoValue: { + fontSize: 13, + color: '#333', + fontWeight: '600', + }, + infoServiceBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + infoServiceIcon: { + fontSize: 14, + }, + infoServiceName: { + fontSize: 13, + color: '#333', + fontWeight: '600', + }, +}); + +export default HomeScreen; + diff --git a/src/screens/LoginScreen.js b/src/screens/LoginScreen.js index e69de29..ed92609 100644 --- a/src/screens/LoginScreen.js +++ b/src/screens/LoginScreen.js @@ -0,0 +1,186 @@ +import React, { useState } from 'react'; +import { + StyleSheet, + View, + Text, + TextInput, + TouchableOpacity, + Alert, + ActivityIndicator, + KeyboardAvoidingView, + Platform, +} from 'react-native'; +import DatabaseService from '../services/DatabaseService'; + +const LoginScreen = ({ navigation }) => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + + const handleLogin = async () => { + if (!email.trim() || !password.trim()) { + Alert.alert('Error', 'Please enter both email and password'); + return; + } + + setLoading(true); + try { + await DatabaseService.initDatabase(); + const user = await DatabaseService.loginUser(email, password); + + if (user) { + // Successfully logged in - navigate to home + navigation.replace('Onboarding', { userId: user.id }); + } else { + Alert.alert('Error', 'Invalid email or password'); + } + } catch (error) { + console.error('Login error:', error); + Alert.alert('Error', 'Failed to login: ' + error.message); + } finally { + setLoading(false); + } + }; + + return ( + + + + Photo Library + Login to your account + + + + Email + + + Password + + + + + {loading ? ( + + ) : ( + Login + )} + + + + Don't have an account? + navigation.navigate('Register')} + disabled={loading} + > + Register here + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + content: { + flex: 1, + justifyContent: 'center', + paddingHorizontal: 20, + paddingVertical: 40, + }, + headerContainer: { + marginBottom: 40, + alignItems: 'center', + }, + title: { + fontSize: 32, + fontWeight: 'bold', + color: '#333', + marginBottom: 10, + }, + subtitle: { + fontSize: 16, + color: '#999', + }, + inputContainer: { + marginBottom: 30, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: '#333', + marginBottom: 8, + marginTop: 16, + }, + input: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + paddingHorizontal: 16, + paddingVertical: 12, + fontSize: 16, + backgroundColor: '#fff', + color: '#333', + }, + loginButton: { + backgroundColor: '#007AFF', + borderRadius: 8, + paddingVertical: 14, + paddingHorizontal: 20, + alignItems: 'center', + marginBottom: 20, + }, + buttonDisabled: { + opacity: 0.6, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + registerContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }, + registerText: { + color: '#666', + fontSize: 14, + }, + registerLink: { + color: '#007AFF', + fontSize: 14, + fontWeight: '600', + }, +}); + +export default LoginScreen; + diff --git a/src/screens/OnboardingScreen.js b/src/screens/OnboardingScreen.js new file mode 100644 index 0000000..167ee1f --- /dev/null +++ b/src/screens/OnboardingScreen.js @@ -0,0 +1,642 @@ +import React, { useState } from 'react'; +import { + StyleSheet, + View, + Text, + ScrollView, + TouchableOpacity, + Alert, + ActivityIndicator, + Switch, + TextInput, +} from 'react-native'; +import DatabaseService from '../services/DatabaseService'; +import GoogleAUTH from '../AUTH/GoogleAUTH'; +import IOSAUTH from '../AUTH/IOSAUTH'; +import NASAUTH from '../AUTH/NASAUTH'; + +const OnboardingScreen = ({ navigation, route }) => { + const { userId } = route.params; + const [currentStep, setCurrentStep] = useState(0); + const [loading, setLoading] = useState(false); + + // Service selections + const [googleEnabled, setGoogleEnabled] = useState(false); + const [iosEnabled, setIosEnabled] = useState(false); + const [nasEnabled, setNasEnabled] = useState(false); + + // NAS configuration + const [nasHost, setNasHost] = useState(''); + const [nasPort, setNasPort] = useState('22'); + const [nasUsername, setNasUsername] = useState(''); + const [nasPassword, setNasPassword] = useState(''); + const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos'); + + // Default service selection + const [defaultService, setDefaultService] = useState(null); + + const steps = [ + { + title: 'Select Services', + description: 'Choose which services to enable (you can enable 1-3)', + }, + { + title: 'Configure Services', + description: 'Set up the enabled services', + }, + { + title: 'Choose Default Service', + description: 'Select which service to use for uploads by default', + }, + ]; + + const getEnabledServices = () => { + const services = []; + if (googleEnabled) services.push('Google Photos'); + if (iosEnabled) services.push('iCloud Photos'); + if (nasEnabled) services.push('NAS'); + return services; + }; + + const handleGoogleAuth = async () => { + try { + setLoading(true); + const googleAuth = new GoogleAUTH(); + // In a real app, this would trigger OAuth flow + const result = await googleAuth.authenticate('auth_code'); + + await DatabaseService.linkAccount( + userId, + 'GOOGLE_PHOTOS', + { + accessToken: result.accessToken, + email: 'user@gmail.com', + }, + defaultService === 'GOOGLE_PHOTOS' + ); + + Alert.alert('Success', 'Google Photos connected!'); + } catch (error) { + Alert.alert('Error', 'Failed to connect Google Photos: ' + error.message); + } finally { + setLoading(false); + } + }; + + const handleIosAuth = async () => { + try { + setLoading(true); + const iosAuth = new IOSAUTH(); + const result = await iosAuth.authenticate('icloud_user', 'icloud_password'); + + await DatabaseService.linkAccount( + userId, + 'ICLOUD_PHOTOS', + { + accessToken: result.accessToken, + email: 'user@icloud.com', + }, + defaultService === 'ICLOUD_PHOTOS' + ); + + Alert.alert('Success', 'iCloud Photos connected!'); + } catch (error) { + Alert.alert('Error', 'Failed to connect iCloud Photos: ' + error.message); + } finally { + setLoading(false); + } + }; + + const handleNasAuth = async () => { + if (!nasHost || !nasUsername || !nasPassword) { + Alert.alert('Error', 'Please fill in all NAS connection details'); + return; + } + + try { + setLoading(true); + const nasAuth = new NASAUTH(); + const result = await nasAuth.authenticate( + nasHost, + parseInt(nasPort) || 22, + nasUsername, + nasPassword + ); + + await DatabaseService.linkAccount( + userId, + 'NAS', + { + host: nasHost, + port: nasPort, + username: nasUsername, + photoFolder: nasPhotoFolder, + }, + defaultService === 'NAS' + ); + + Alert.alert('Success', 'NAS connected!'); + } catch (error) { + Alert.alert('Error', 'Failed to connect NAS: ' + error.message); + } finally { + setLoading(false); + } + }; + + const handleNext = async () => { + if (currentStep === 0) { + const enabledCount = getEnabledServices().length; + if (enabledCount === 0) { + Alert.alert('Error', 'Please select at least one service'); + return; + } + if (enabledCount > 3) { + Alert.alert('Error', 'You can enable a maximum of 3 services'); + return; + } + setCurrentStep(1); + } else if (currentStep === 1) { + // Configure services + try { + setLoading(true); + if (googleEnabled) await handleGoogleAuth(); + if (iosEnabled) await handleIosAuth(); + if (nasEnabled) await handleNasAuth(); + setCurrentStep(2); + } catch (error) { + console.error('Configuration error:', error); + } finally { + setLoading(false); + } + } else if (currentStep === 2) { + // Complete onboarding + if (!defaultService) { + Alert.alert('Error', 'Please select a default service'); + return; + } + + try { + setLoading(true); + // Set default service + const accounts = await DatabaseService.getLinkedAccounts(userId); + const defaultAccount = accounts.find( + (acc) => acc.service_type === defaultService + ); + if (defaultAccount) { + await DatabaseService.setDefaultService(userId, defaultAccount.id); + } + + // Navigate to home screen + navigation.replace('Home', { userId }); + } catch (error) { + Alert.alert('Error', 'Failed to complete setup: ' + error.message); + } finally { + setLoading(false); + } + } + }; + + const handleBack = () => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } + }; + + return ( + + + + + Step {currentStep + 1} of {steps.length} + + {steps[currentStep].title} + + {steps[currentStep].description} + + + + {currentStep === 0 && ( + + setGoogleEnabled(!googleEnabled)} + icon="📷" + description="Connect to your Google Photos account" + /> + setIosEnabled(!iosEnabled)} + icon="🍎" + description="Connect to your iCloud Photos account" + /> + setNasEnabled(!nasEnabled)} + icon="💾" + description="Connect to your Ubuntu NAS via SFTP" + /> + + )} + + {currentStep === 1 && ( + + {googleEnabled && ( + + + Tap to authenticate with your Google account + + + )} + + {iosEnabled && ( + + + Tap to authenticate with your Apple account + + + )} + + {nasEnabled && ( + + Server Address + + + Port + + + Username + + + Password + + + Photos Folder Path + + + + {loading ? ( + + ) : ( + Test Connection + )} + + + )} + + )} + + {currentStep === 2 && ( + + + Select your default upload service: + + {getEnabledServices().map((service) => ( + { + const serviceTypeMap = { + 'Google Photos': 'GOOGLE_PHOTOS', + 'iCloud Photos': 'ICLOUD_PHOTOS', + NAS: 'NAS', + }; + setDefaultService(serviceTypeMap[service]); + }} + /> + ))} + + )} + + + + + Back + + + + {loading ? ( + + ) : ( + + {currentStep === steps.length - 1 ? 'Complete' : 'Next'} + + )} + + + + ); +}; + +const ServiceCard = ({ name, enabled, onToggle, icon, description }) => ( + + + {icon} + + {name} + {description} + + + + +); + +const ServiceConfigCard = ({ title, onPress, loading, children }) => ( + + {title} + {onPress && ( + + {loading ? ( + + ) : ( + Authenticate + )} + + )} + {children} + +); + +const ServiceSelectCard = ({ service, selected, onSelect }) => ( + + + + {service} + + +); + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + content: { + flex: 1, + paddingHorizontal: 20, + paddingTop: 20, + }, + header: { + marginBottom: 30, + }, + stepIndicator: { + fontSize: 12, + color: '#007AFF', + fontWeight: '600', + marginBottom: 8, + }, + title: { + fontSize: 28, + fontWeight: 'bold', + color: '#333', + marginBottom: 8, + }, + description: { + fontSize: 16, + color: '#666', + }, + servicesContainer: { + marginBottom: 20, + }, + serviceCard: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + marginBottom: 12, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + borderWidth: 1, + borderColor: '#e0e0e0', + }, + serviceCardContent: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + }, + serviceIcon: { + fontSize: 32, + marginRight: 16, + }, + serviceInfo: { + flex: 1, + }, + serviceName: { + fontSize: 16, + fontWeight: '600', + color: '#333', + marginBottom: 4, + }, + serviceDescription: { + fontSize: 13, + color: '#999', + }, + configContainer: { + marginBottom: 20, + }, + configCard: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + marginBottom: 16, + borderWidth: 1, + borderColor: '#e0e0e0', + }, + configCardTitle: { + fontSize: 16, + fontWeight: '600', + color: '#333', + marginBottom: 12, + }, + configLabel: { + fontSize: 14, + fontWeight: '600', + color: '#333', + marginTop: 12, + marginBottom: 6, + }, + configInput: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 14, + backgroundColor: '#f9f9f9', + color: '#333', + }, + configText: { + fontSize: 14, + color: '#666', + marginTop: 12, + }, + configButton: { + backgroundColor: '#007AFF', + borderRadius: 8, + paddingVertical: 10, + paddingHorizontal: 16, + alignItems: 'center', + }, + testButton: { + backgroundColor: '#34C759', + borderRadius: 8, + paddingVertical: 10, + paddingHorizontal: 16, + alignItems: 'center', + marginTop: 16, + }, + buttonDisabled: { + opacity: 0.6, + }, + buttonText: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, + defaultServiceContainer: { + marginBottom: 20, + }, + defaultServiceTitle: { + fontSize: 16, + fontWeight: '600', + color: '#333', + marginBottom: 12, + }, + selectCard: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + marginBottom: 12, + flexDirection: 'row', + alignItems: 'center', + borderWidth: 2, + borderColor: '#ddd', + }, + selectedCard: { + borderColor: '#007AFF', + backgroundColor: '#f0f7ff', + }, + radioButton: { + width: 20, + height: 20, + borderRadius: 10, + borderWidth: 2, + borderColor: '#ddd', + marginRight: 12, + }, + radioSelected: { + borderColor: '#007AFF', + backgroundColor: '#007AFF', + }, + selectText: { + fontSize: 16, + color: '#333', + }, + selectedText: { + color: '#007AFF', + fontWeight: '600', + }, + footer: { + flexDirection: 'row', + paddingHorizontal: 20, + paddingVertical: 16, + borderTopWidth: 1, + borderTopColor: '#e0e0e0', + gap: 12, + }, + footerButton: { + flex: 1, + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + alignItems: 'center', + backgroundColor: '#fff', + borderWidth: 1, + borderColor: '#ddd', + }, + nextButton: { + backgroundColor: '#007AFF', + borderColor: '#007AFF', + }, + footerButtonText: { + fontSize: 16, + fontWeight: '600', + color: '#333', + }, + hidden: { + opacity: 0, + pointerEvents: 'none', + }, +}); + +export default OnboardingScreen; + diff --git a/src/screens/RegisterScreen.js b/src/screens/RegisterScreen.js index e69de29..2929ec3 100644 --- a/src/screens/RegisterScreen.js +++ b/src/screens/RegisterScreen.js @@ -0,0 +1,258 @@ +import React, { useState } from 'react'; +import { + StyleSheet, + View, + Text, + TextInput, + TouchableOpacity, + Alert, + ActivityIndicator, + KeyboardAvoidingView, + Platform, + ScrollView, +} from 'react-native'; +import DatabaseService from '../services/DatabaseService'; + +const RegisterScreen = ({ navigation }) => { + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + + const validateForm = () => { + if (!username.trim()) { + Alert.alert('Error', 'Please enter a username'); + return false; + } + if (!email.trim()) { + Alert.alert('Error', 'Please enter an email'); + return false; + } + if (!email.includes('@')) { + Alert.alert('Error', 'Please enter a valid email'); + return false; + } + if (!password.trim()) { + Alert.alert('Error', 'Please enter a password'); + return false; + } + if (password.length < 6) { + Alert.alert('Error', 'Password must be at least 6 characters'); + return false; + } + if (password !== confirmPassword) { + Alert.alert('Error', 'Passwords do not match'); + return false; + } + return true; + }; + + const handleRegister = async () => { + if (!validateForm()) { + return; + } + + setLoading(true); + try { + await DatabaseService.initDatabase(); + // Check if email already exists + const exists = await DatabaseService.userExists(email); + if (exists) { + Alert.alert('Error', 'Email already registered'); + setLoading(false); + return; + } + + // Create new user + const userId = await DatabaseService.createUser(username, email, password); + + Alert.alert('Success', 'Account created successfully!', [ + { + text: 'OK', + onPress: () => { + // Navigate to onboarding after successful registration + navigation.replace('Onboarding', { userId }); + }, + }, + ]); + } catch (error) { + console.error('Registration error:', error); + Alert.alert('Error', 'Failed to register: ' + error.message); + } finally { + setLoading(false); + } + }; + + return ( + + + + + Create Account + Join our photo library + + + + Username + + + Email + + + Password + + + Confirm Password + + + + + {loading ? ( + + ) : ( + Create Account + )} + + + + Already have an account? + navigation.navigate('Login')} + disabled={loading} + > + Login here + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + scrollContent: { + flexGrow: 1, + justifyContent: 'center', + }, + content: { + paddingHorizontal: 20, + paddingVertical: 40, + }, + headerContainer: { + marginBottom: 40, + alignItems: 'center', + }, + title: { + fontSize: 32, + fontWeight: 'bold', + color: '#333', + marginBottom: 10, + }, + subtitle: { + fontSize: 16, + color: '#999', + }, + inputContainer: { + marginBottom: 30, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: '#333', + marginBottom: 8, + marginTop: 16, + }, + input: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + paddingHorizontal: 16, + paddingVertical: 12, + fontSize: 16, + backgroundColor: '#fff', + color: '#333', + }, + registerButton: { + backgroundColor: '#34C759', + borderRadius: 8, + paddingVertical: 14, + paddingHorizontal: 20, + alignItems: 'center', + marginBottom: 20, + }, + buttonDisabled: { + opacity: 0.6, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + loginContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }, + loginText: { + color: '#666', + fontSize: 14, + }, + loginLink: { + color: '#007AFF', + fontSize: 14, + fontWeight: '600', + }, +}); + +export default RegisterScreen; + diff --git a/src/services/CredentialEncryption.js b/src/services/CredentialEncryption.js new file mode 100644 index 0000000..3bfbae6 --- /dev/null +++ b/src/services/CredentialEncryption.js @@ -0,0 +1,96 @@ +/** + * Credential Encryption Service + * + * Encrypts sensitive credentials before storing in database + * Uses base64 encoding (React Native compatible) + * For production, use a proper encryption library like react-native-aes-crypto + */ + +class CredentialEncryption { + constructor() { + // Simple key for demo - in production, use proper key management + this.encryptionKey = 'photo-app-secret-key-2026'; + } + + /** + * Base64 encode a string (React Native compatible) + * Uses btoa which is available in React Native + */ + base64Encode(str) { + try { + // btoa is available in React Native + return btoa(unescape(encodeURIComponent(str))); + } catch (error) { + console.error('Base64 encode error:', error); + throw new Error('Failed to encode data'); + } + } + + /** + * Base64 decode a string (React Native compatible) + * Uses atob which is available in React Native + */ + base64Decode(str) { + try { + // atob is available in React Native + return decodeURIComponent(escape(atob(str))); + } catch (error) { + console.error('Base64 decode error:', error); + throw new Error('Failed to decode data'); + } + } + + /** + * Encrypt credentials before storing + * Converts object to JSON string, then base64 encodes it + */ + encrypt(data) { + try { + if (!data) { + throw new Error('No data provided to encrypt'); + } + const jsonString = JSON.stringify(data); + const encoded = this.base64Encode(jsonString); + console.log('✓ Credentials encrypted successfully'); + return encoded; + } catch (error) { + console.error('Encryption error:', error); + throw new Error('Failed to encrypt credentials: ' + error.message); + } + } + + /** + * Decrypt credentials after retrieving + * Base64 decodes, then parses JSON back to object + */ + decrypt(encryptedData) { + try { + if (!encryptedData) { + return null; + } + const jsonString = this.base64Decode(encryptedData); + const data = JSON.parse(jsonString); + console.log('✓ Credentials decrypted successfully'); + return data; + } catch (error) { + console.error('Decryption error:', error); + throw new Error('Failed to decrypt credentials: ' + error.message); + } + } + + /** + * Check if data is already encrypted (base64) + */ + isEncrypted(data) { + if (typeof data !== 'string') return false; + try { + this.base64Decode(data); + return true; + } catch { + return false; + } + } +} + +export default new CredentialEncryption(); + diff --git a/src/services/DatabaseService.js b/src/services/DatabaseService.js new file mode 100644 index 0000000..164075c --- /dev/null +++ b/src/services/DatabaseService.js @@ -0,0 +1,428 @@ +import * as SQLite from 'expo-sqlite'; +import CredentialEncryption from './CredentialEncryption'; + +let db = null; +let initPromise = null; + +const DatabaseService = { + /** + * Initialize the database and create tables if they don't exist + */ + initDatabase: async () => { + if (initPromise) { + return initPromise; + } + initPromise = (async () => { + try { + if (db) { + return true; + } + if (typeof SQLite.openDatabaseAsync !== 'function') { + throw new Error('SQLite openDatabaseAsync API not available'); + } + db = await SQLite.openDatabaseAsync('PhotosApp.db'); + if (!db) { + throw new Error('SQLite openDatabaseAsync returned null'); + } + + await db.execAsync( + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )` + ); + + await db.execAsync( + `CREATE TABLE IF NOT EXISTS linked_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + service_type TEXT NOT NULL, + account_data TEXT NOT NULL, + is_enabled INTEGER DEFAULT 1, + is_default INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) + )` + ); + + await db.execAsync( + `CREATE TABLE IF NOT EXISTS media ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + source_service TEXT NOT NULL, + source_id TEXT, + title TEXT, + description TEXT, + local_path TEXT, + remote_path TEXT, + media_type TEXT, + created_date DATETIME, + uploaded_to_default INTEGER DEFAULT 0, + synced INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) + )` + ); + + await db.execAsync( + `CREATE TABLE IF NOT EXISTS sync_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + service_type TEXT NOT NULL, + status TEXT, + message TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) + )` + ); + + console.log('Database initialized successfully'); + return true; + } catch (error) { + console.error('Database initialization error:', error); + throw error; + } + })(); + return initPromise; + }, + + /** + * Ensure the database is initialized before use + */ + ensureDb: async () => { + await DatabaseService.initDatabase(); + if (!db) { + throw new Error('Database not initialized'); + } + }, + + /** + * Create a new user account + */ + createUser: async (username, email, password) => { + try { + await DatabaseService.ensureDb(); + const result = await db.runAsync( + 'INSERT INTO users (username, email, password) VALUES (?, ?, ?)', + [username, email, password] + ); + return result.lastInsertRowId; + } catch (error) { + console.error('Error creating user:', error); + throw error; + } + }, + + /** + * Get user by email and password + */ + loginUser: async (email, password) => { + try { + await DatabaseService.ensureDb(); + const row = await db.getFirstAsync( + 'SELECT * FROM users WHERE email = ? AND password = ?', + [email, password] + ); + return row || null; + } catch (error) { + console.error('Error logging in user:', error); + throw error; + } + }, + + /** + * Check if user exists by email + */ + userExists: async (email) => { + try { + await DatabaseService.ensureDb(); + const row = await db.getFirstAsync( + 'SELECT id FROM users WHERE email = ?', + [email] + ); + return !!row; + } catch (error) { + console.error('Error checking if user exists:', error); + throw error; + } + }, + + /** + * Get user by ID + */ + getUserById: async (userId) => { + try { + await DatabaseService.ensureDb(); + if (!userId && userId !== 0) { + throw new Error('User ID is required and cannot be null'); + } + const row = await db.getFirstAsync( + 'SELECT * FROM users WHERE id = ?', + [userId] + ); + return row || null; + } catch (error) { + console.error('Error getting user:', error); + throw error; + } + }, + + /** + * Link an external account to user + */ + linkAccount: async (userId, serviceType, accountData, isDefault = false) => { + try { + await DatabaseService.ensureDb(); + + // Validate inputs + if (!userId) { + throw new Error('User ID is required'); + } + if (!serviceType) { + throw new Error('Service type is required'); + } + if (!accountData || typeof accountData !== 'object') { + throw new Error('Account data must be a valid object'); + } + + console.log('📝 Linking account:', { userId, serviceType, dataKeys: Object.keys(accountData) }); + + // Encrypt account data + const encryptedData = CredentialEncryption.encrypt(accountData); + + if (!encryptedData) { + throw new Error('Encryption returned empty data'); + } + + console.log('✓ Account data encrypted, storing in database...'); + + const result = await db.runAsync( + `INSERT INTO linked_accounts (user_id, service_type, account_data, is_default) + VALUES (?, ?, ?, ?)`, + [userId, serviceType, encryptedData, isDefault ? 1 : 0] + ); + + console.log('✓ Account linked successfully, ID:', result.lastInsertRowId); + + return result.lastInsertRowId; + } catch (error) { + console.error('Error linking account:', error); + throw new Error(`Failed to link account: ${error.message}`); + } + }, + + /** + * Get all linked accounts for user + */ + getLinkedAccounts: async (userId) => { + try { + await DatabaseService.ensureDb(); + if (!userId && userId !== 0) { + throw new Error('User ID is required and cannot be null'); + } + const rows = await db.getAllAsync( + 'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1', + [userId] + ); + return rows.map((row) => ({ + ...row, + account_data: CredentialEncryption.decrypt(row.account_data), + })); + } catch (error) { + console.error('Error getting linked accounts:', error); + throw error; + } + }, + + /** + * Get linked account by service type + */ + getLinkedAccountByService: async (userId, serviceType) => { + try { + await DatabaseService.ensureDb(); + const row = await db.getFirstAsync( + 'SELECT * FROM linked_accounts WHERE user_id = ? AND service_type = ? AND is_enabled = 1', + [userId, serviceType] + ); + if (!row) return null; + return { + ...row, + account_data: CredentialEncryption.decrypt(row.account_data), + }; + } catch (error) { + console.error('Error getting linked account:', error); + throw error; + } + }, + + /** + * Set default service for uploads + */ + setDefaultService: async (userId, accountId) => { + try { + await DatabaseService.ensureDb(); + await db.runAsync( + 'UPDATE linked_accounts SET is_default = 0 WHERE user_id = ?', + [userId] + ); + await db.runAsync( + 'UPDATE linked_accounts SET is_default = 1 WHERE id = ? AND user_id = ?', + [accountId, userId] + ); + } catch (error) { + console.error('Error setting default service:', error); + throw error; + } + }, + + /** + * Get default service for user + */ + getDefaultService: async (userId) => { + try { + await DatabaseService.ensureDb(); + const row = await db.getFirstAsync( + 'SELECT * FROM linked_accounts WHERE user_id = ? AND is_default = 1', + [userId] + ); + if (!row) return null; + return { + ...row, + account_data: CredentialEncryption.decrypt(row.account_data), + }; + } catch (error) { + console.error('Error getting default service:', error); + throw error; + } + }, + + /** + * Add media to database + */ + addMedia: async ( + userId, + sourceService, + sourceId, + title, + description, + localPath, + remotePath, + mediaType, + createdDate + ) => { + try { + await DatabaseService.ensureDb(); + if (!userId || !sourceService) { + throw new Error('userId and sourceService are required'); + } + const result = await db.runAsync( + `INSERT INTO media + (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + userId, + sourceService, + sourceId || null, + title || null, + description || null, + localPath || null, + remotePath || null, + mediaType || null, + createdDate || null, + ] + ); + return result.lastInsertRowId; + } catch (error) { + console.error('Error adding media:', error); + throw error; + } + }, + + /** + * Get all media for user + */ + getMediaByUser: async (userId) => { + try { + await DatabaseService.ensureDb(); + if (!userId && userId !== 0) { + throw new Error('User ID is required and cannot be null'); + } + return await db.getAllAsync( + 'SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC', + [userId] + ); + } catch (error) { + console.error('Error getting media:', error); + throw error; + } + }, + + /** + * Get media by service + */ + getMediaByService: async (userId, serviceType) => { + try { + await DatabaseService.ensureDb(); + return await db.getAllAsync( + 'SELECT * FROM media WHERE user_id = ? AND source_service = ? ORDER BY created_date DESC', + [userId, serviceType] + ); + } catch (error) { + console.error('Error getting media by service:', error); + throw error; + } + }, + + /** + * Update media sync status + */ + updateMediaSyncStatus: async (mediaId, synced) => { + try { + await DatabaseService.ensureDb(); + await db.runAsync( + 'UPDATE media SET synced = ? WHERE id = ?', + [synced ? 1 : 0, mediaId] + ); + } catch (error) { + console.error('Error updating media sync status:', error); + throw error; + } + }, + + /** + * Log sync operation + */ + logSync: async (userId, serviceType, status, message) => { + try { + await DatabaseService.ensureDb(); + await db.runAsync( + 'INSERT INTO sync_logs (user_id, service_type, status, message) VALUES (?, ?, ?, ?)', + [userId, serviceType, status, message] + ); + } catch (error) { + console.error('Error logging sync:', error); + throw error; + } + }, + + /** + * Close database connection + */ + closeDatabase: async () => { + try { + if (db?.closeAsync) { + await db.closeAsync(); + } + db = null; + initPromise = null; + } catch (error) { + console.error('Error closing database:', error); + } + }, +}; + +export default DatabaseService; +