created a localised login system

This commit is contained in:
bobbert committed 2026-02-21 13:14:01 +00:00
1 parent 59ac1fc926
commit 37511780b7
16 files changed
+3721 -20

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
!/Ignore files/
!/package-lock.json
+121 -14
View File
@@ -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 (
<View style={styles.container}>
<Text>Open up App.js to start working on your app!</Text>
<StatusBar style="auto" />
</View>
<Stack.Navigator
screenOptions={{
headerShown: false,
cardStyle: { backgroundColor: '#f5f5f5' },
}}
>
{/* Authentication Stack - User not logged in */}
<Stack.Screen
name="Login"
component={LoginScreen}
options={{
animationEnabled: false,
}}
/>
<Stack.Screen
name="Register"
component={RegisterScreen}
options={{
animationEnabled: true,
}}
/>
<Stack.Screen
name="Onboarding"
component={OnboardingScreen}
options={{
animationEnabled: true,
}}
/>
{/* App Stack - User logged in */}
<Stack.Screen
name="Home"
component={HomeScreen}
options={{
animationEnabled: true,
}}
/>
</Stack.Navigator>
);
}
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 (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
// Show error if database initialization fails
if (error) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20 }}>
<Text style={{ fontSize: 16, color: '#333', marginBottom: 12, textAlign: 'center' }}>
{error}
</Text>
<TouchableOpacity
onPress={initializeApp}
style={{ backgroundColor: '#007AFF', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 6 }}
>
<Text style={{ color: '#fff', fontWeight: '600' }}>Retry</Text>
</TouchableOpacity>
</View>
);
}
return (
<>
<NavigationContainer>
<AppNavigator />
</NavigationContainer>
<StatusBar barStyle="dark-content" backgroundColor="#f5f5f5" />
</>
);
}
+289 -2
View File
@@ -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.
+4 -1
View File
@@ -24,6 +24,9 @@
},
"web": {
"favicon": "./assets/favicon.png"
}
},
"plugins": [
"expo-sqlite"
]
}
}
+27 -3
View File
@@ -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
}
+203
View File
@@ -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;
+34
View File
@@ -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;
+194
View File
@@ -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;
+194
View File
@@ -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;
+222
View File
@@ -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;
+821
View File
@@ -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 }) => (
<TouchableOpacity
style={styles.gridItem}
onPress={() => {
setSelectedMedia(item);
setModalVisible(true);
}}
activeOpacity={0.7}
>
{item.local_path ? (
<Image
source={{ uri: item.local_path }}
style={styles.gridImage}
resizeMode="cover"
/>
) : (
<View style={styles.placeholderImage}>
<Text style={styles.placeholderIcon}>📷</Text>
</View>
)}
{item.media_type === 'video' && (
<View style={styles.videoOverlay}>
<Text style={styles.playIcon}>▶</Text>
</View>
)}
<View style={styles.serviceBadge}>
<Text style={styles.badgeIcon}>{getServiceIcon(item.source_service)}</Text>
</View>
</TouchableOpacity>
);
// Loading state
if (loading) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={styles.loadingText}>Loading your photos...</Text>
</View>
);
}
// Error state
if (error && filteredMedia.length === 0) {
return (
<View style={styles.centerContainer}>
<Text style={styles.errorIcon}>⚠️</Text>
<Text style={styles.errorTitle}>Unable to Load Photos</Text>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={loadAllMedia}>
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
</View>
);
}
// Empty state
if (filteredMedia.length === 0) {
return (
<View style={styles.container}>
<View style={styles.header}>
<View>
<Text style={styles.headerTitle}>Photo Library</Text>
<Text style={styles.headerSubtitle}>
Welcome, {currentUser?.username}
</Text>
</View>
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
<Text style={styles.logoutText}>Logout</Text>
</TouchableOpacity>
</View>
<View style={styles.centerContainer}>
<Text style={styles.emptyIcon}>📸</Text>
<Text style={styles.emptyTitle}>No Photos Yet</Text>
<Text style={styles.emptyText}>
Photos from your connected services will appear here
</Text>
</View>
</View>
);
}
// Main content
return (
<View style={styles.container}>
<View style={styles.header}>
<View>
<Text style={styles.headerTitle}>Photo Library</Text>
<Text style={styles.headerSubtitle}>
Welcome, {currentUser?.username}
</Text>
</View>
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
<Text style={styles.logoutText}>Logout</Text>
</TouchableOpacity>
</View>
{linkedAccounts.length > 0 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filterScroll}
>
<TouchableOpacity
style={[
styles.filterButton,
selectedFilter === 'all' && styles.filterButtonActive,
]}
onPress={() => setSelectedFilter('all')}
>
<Text
style={[
styles.filterButtonText,
selectedFilter === 'all' && styles.filterButtonTextActive,
]}
>
All
</Text>
</TouchableOpacity>
{linkedAccounts.map((account) => (
<TouchableOpacity
key={account.id}
style={[
styles.filterButton,
selectedFilter === account.service_type &&
styles.filterButtonActive,
]}
onPress={() => setSelectedFilter(account.service_type)}
>
<Text style={styles.filterIcon}>
{getServiceIcon(account.service_type)}
</Text>
<Text
style={[
styles.filterButtonText,
selectedFilter === account.service_type &&
styles.filterButtonTextActive,
]}
>
{getServiceName(account.service_type)}
</Text>
</TouchableOpacity>
))}
</ScrollView>
)}
<FlatList
data={filteredMedia}
renderItem={renderMediaItem}
keyExtractor={(item) => item.id?.toString() || item.path || Math.random().toString()}
numColumns={GRID_COLUMNS}
columnWrapperStyle={styles.columnWrapper}
contentContainerStyle={styles.gridContent}
scrollEnabled={true}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
/>
<Modal
visible={modalVisible}
transparent={true}
onRequestClose={() => setModalVisible(false)}
animationType="fade"
>
<MediaDetailModal
media={selectedMedia}
onClose={() => setModalVisible(false)}
serviceIcon={getServiceIcon(selectedMedia?.source_service)}
serviceName={getServiceName(selectedMedia?.source_service)}
/>
</Modal>
</View>
);
};
/**
* MediaDetailModal - Full screen photo viewer
*/
const MediaDetailModal = ({ media, onClose, serviceIcon, serviceName }) => {
if (!media) return null;
return (
<View style={styles.modalContainer}>
<TouchableOpacity
style={styles.modalBackdrop}
onPress={onClose}
activeOpacity={1}
/>
<View style={styles.modalContent}>
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Text style={styles.closeButtonText}>✕</Text>
</TouchableOpacity>
<ScrollView
style={styles.imageContainer}
contentContainerStyle={styles.imageContent}
scrollEnabled={true}
showsVerticalScrollIndicator={false}
>
{media.local_path ? (
<Image
source={{ uri: media.local_path }}
style={styles.fullImage}
resizeMode="contain"
/>
) : (
<View style={styles.placeholderLarge}>
<Text style={styles.placeholderLargeIcon}>📷</Text>
</View>
)}
</ScrollView>
<View style={styles.infoPanel}>
<Text style={styles.infoTitle}>{media.title || 'Untitled Photo'}</Text>
{media.description && (
<Text style={styles.infoDescription}>{media.description}</Text>
)}
<View style={styles.infoDivider} />
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>From</Text>
<View style={styles.infoServiceBadge}>
<Text style={styles.infoServiceIcon}>{serviceIcon}</Text>
<Text style={styles.infoServiceName}>{serviceName}</Text>
</View>
</View>
{media.media_type && (
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Type</Text>
<Text style={styles.infoValue}>
{media.media_type === 'video' ? '🎥 Video' : '📷 Photo'}
</Text>
</View>
)}
{(media.created_date || media.modifyTime) && (
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Date</Text>
<Text style={styles.infoValue}>
{new Date(
media.created_date || media.modifyTime
).toLocaleDateString()}
</Text>
</View>
)}
{media.size && (
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Size</Text>
<Text style={styles.infoValue}>
{(media.size / 1024 / 1024).toFixed(2)} MB
</Text>
</View>
)}
</View>
</View>
</View>
);
};
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;
+186
View File
@@ -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 (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
>
<View style={styles.content}>
<View style={styles.headerContainer}>
<Text style={styles.title}>Photo Library</Text>
<Text style={styles.subtitle}>Login to your account</Text>
</View>
<View style={styles.inputContainer}>
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
placeholder="Enter your email"
placeholderTextColor="#999"
value={email}
onChangeText={setEmail}
editable={!loading}
keyboardType="email-address"
autoCapitalize="none"
/>
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
placeholder="Enter your password"
placeholderTextColor="#999"
value={password}
onChangeText={setPassword}
editable={!loading}
secureTextEntry={true}
autoCapitalize="none"
/>
</View>
<TouchableOpacity
style={[styles.loginButton, loading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Login</Text>
)}
</TouchableOpacity>
<View style={styles.registerContainer}>
<Text style={styles.registerText}>Don't have an account? </Text>
<TouchableOpacity
onPress={() => navigation.navigate('Register')}
disabled={loading}
>
<Text style={styles.registerLink}>Register here</Text>
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
);
};
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;
+642
View File
@@ -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 (
<View style={styles.container}>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.header}>
<Text style={styles.stepIndicator}>
Step {currentStep + 1} of {steps.length}
</Text>
<Text style={styles.title}>{steps[currentStep].title}</Text>
<Text style={styles.description}>
{steps[currentStep].description}
</Text>
</View>
{currentStep === 0 && (
<View style={styles.servicesContainer}>
<ServiceCard
name="Google Photos"
enabled={googleEnabled}
onToggle={() => setGoogleEnabled(!googleEnabled)}
icon="📷"
description="Connect to your Google Photos account"
/>
<ServiceCard
name="iCloud Photos"
enabled={iosEnabled}
onToggle={() => setIosEnabled(!iosEnabled)}
icon="🍎"
description="Connect to your iCloud Photos account"
/>
<ServiceCard
name="Network Storage (NAS)"
enabled={nasEnabled}
onToggle={() => setNasEnabled(!nasEnabled)}
icon="💾"
description="Connect to your Ubuntu NAS via SFTP"
/>
</View>
)}
{currentStep === 1 && (
<View style={styles.configContainer}>
{googleEnabled && (
<ServiceConfigCard
title="Google Photos"
onPress={handleGoogleAuth}
loading={loading}
>
<Text style={styles.configText}>
Tap to authenticate with your Google account
</Text>
</ServiceConfigCard>
)}
{iosEnabled && (
<ServiceConfigCard
title="iCloud Photos"
onPress={handleIosAuth}
loading={loading}
>
<Text style={styles.configText}>
Tap to authenticate with your Apple account
</Text>
</ServiceConfigCard>
)}
{nasEnabled && (
<ServiceConfigCard title="Network Storage (NAS)" loading={loading}>
<Text style={styles.configLabel}>Server Address</Text>
<TextInput
style={styles.configInput}
placeholder="192.168.1.100 or nas.example.com"
value={nasHost}
onChangeText={setNasHost}
editable={!loading}
placeholderTextColor="#999"
/>
<Text style={styles.configLabel}>Port</Text>
<TextInput
style={styles.configInput}
placeholder="22"
value={nasPort}
onChangeText={setNasPort}
editable={!loading}
keyboardType="numeric"
placeholderTextColor="#999"
/>
<Text style={styles.configLabel}>Username</Text>
<TextInput
style={styles.configInput}
placeholder="SSH username"
value={nasUsername}
onChangeText={setNasUsername}
editable={!loading}
autoCapitalize="none"
placeholderTextColor="#999"
/>
<Text style={styles.configLabel}>Password</Text>
<TextInput
style={styles.configInput}
placeholder="SSH password"
value={nasPassword}
onChangeText={setNasPassword}
editable={!loading}
secureTextEntry
autoCapitalize="none"
placeholderTextColor="#999"
/>
<Text style={styles.configLabel}>Photos Folder Path</Text>
<TextInput
style={styles.configInput}
placeholder="/home/user/photos"
value={nasPhotoFolder}
onChangeText={setNasPhotoFolder}
editable={!loading}
placeholderTextColor="#999"
/>
<TouchableOpacity
style={[styles.testButton, loading && styles.buttonDisabled]}
onPress={handleNasAuth}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Test Connection</Text>
)}
</TouchableOpacity>
</ServiceConfigCard>
)}
</View>
)}
{currentStep === 2 && (
<View style={styles.defaultServiceContainer}>
<Text style={styles.defaultServiceTitle}>
Select your default upload service:
</Text>
{getEnabledServices().map((service) => (
<ServiceSelectCard
key={service}
service={service}
selected={defaultService === service}
onSelect={() => {
const serviceTypeMap = {
'Google Photos': 'GOOGLE_PHOTOS',
'iCloud Photos': 'ICLOUD_PHOTOS',
NAS: 'NAS',
};
setDefaultService(serviceTypeMap[service]);
}}
/>
))}
</View>
)}
</ScrollView>
<View style={styles.footer}>
<TouchableOpacity
style={[styles.footerButton, currentStep === 0 && styles.hidden]}
onPress={handleBack}
disabled={loading}
>
<Text style={styles.footerButtonText}>Back</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.footerButton, styles.nextButton, loading && styles.buttonDisabled]}
onPress={handleNext}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.footerButtonText}>
{currentStep === steps.length - 1 ? 'Complete' : 'Next'}
</Text>
)}
</TouchableOpacity>
</View>
</View>
);
};
const ServiceCard = ({ name, enabled, onToggle, icon, description }) => (
<View style={styles.serviceCard}>
<View style={styles.serviceCardContent}>
<Text style={styles.serviceIcon}>{icon}</Text>
<View style={styles.serviceInfo}>
<Text style={styles.serviceName}>{name}</Text>
<Text style={styles.serviceDescription}>{description}</Text>
</View>
</View>
<Switch value={enabled} onValueChange={onToggle} />
</View>
);
const ServiceConfigCard = ({ title, onPress, loading, children }) => (
<View style={styles.configCard}>
<Text style={styles.configCardTitle}>{title}</Text>
{onPress && (
<TouchableOpacity
style={[styles.configButton, loading && styles.buttonDisabled]}
onPress={onPress}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Authenticate</Text>
)}
</TouchableOpacity>
)}
{children}
</View>
);
const ServiceSelectCard = ({ service, selected, onSelect }) => (
<TouchableOpacity
style={[styles.selectCard, selected && styles.selectedCard]}
onPress={onSelect}
>
<View style={[styles.radioButton, selected && styles.radioSelected]} />
<Text style={[styles.selectText, selected && styles.selectedText]}>
{service}
</Text>
</TouchableOpacity>
);
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;
+258
View File
@@ -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 (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.content}>
<View style={styles.headerContainer}>
<Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Join our photo library</Text>
</View>
<View style={styles.inputContainer}>
<Text style={styles.label}>Username</Text>
<TextInput
style={styles.input}
placeholder="Choose a username"
placeholderTextColor="#999"
value={username}
onChangeText={setUsername}
editable={!loading}
autoCapitalize="none"
/>
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
placeholder="Enter your email"
placeholderTextColor="#999"
value={email}
onChangeText={setEmail}
editable={!loading}
keyboardType="email-address"
autoCapitalize="none"
/>
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
placeholder="Create a password (min 6 characters)"
placeholderTextColor="#999"
value={password}
onChangeText={setPassword}
editable={!loading}
secureTextEntry={true}
autoCapitalize="none"
/>
<Text style={styles.label}>Confirm Password</Text>
<TextInput
style={styles.input}
placeholder="Confirm your password"
placeholderTextColor="#999"
value={confirmPassword}
onChangeText={setConfirmPassword}
editable={!loading}
secureTextEntry={true}
autoCapitalize="none"
/>
</View>
<TouchableOpacity
style={[styles.registerButton, loading && styles.buttonDisabled]}
onPress={handleRegister}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Create Account</Text>
)}
</TouchableOpacity>
<View style={styles.loginContainer}>
<Text style={styles.loginText}>Already have an account? </Text>
<TouchableOpacity
onPress={() => navigation.navigate('Login')}
disabled={loading}
>
<Text style={styles.loginLink}>Login here</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
};
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;
+96
View File
@@ -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();
+428
View File
@@ -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;