diff --git a/App.tsx b/App.tsx index b7371bf..e72268e 100644 --- a/App.tsx +++ b/App.tsx @@ -151,7 +151,7 @@ const App = () => { useEffect(() => { SplashScreen.hide(); - }) + }); return ( diff --git a/package.json b/package.json index 0b925a6..b7d4e58 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@babel/preset-typescript": "^7.23.3", "@bam.tech/react-native-image-resizer": "^3.0.11", "@ethersproject/shims": "^5.8.0", + "@notifee/react-native": "^9.1.8", "@react-native-async-storage/async-storage": "^2.1.2", "@react-native-clipboard/clipboard": "^1.16.1", "@react-native-community/blur": "^4.4.0", diff --git a/src/components/PermissionRequest.js b/src/components/PermissionRequest.js new file mode 100644 index 0000000..861805b --- /dev/null +++ b/src/components/PermissionRequest.js @@ -0,0 +1,103 @@ +import React, {useState, useEffect} from 'react'; +import {View, Text, TouchableOpacity, Alert, StyleSheet} from 'react-native'; +import PermissionManager from '../services/PermissionManager'; + +const PermissionRequest = () => { + const [permissions, setPermissions] = useState({ + location: {granted: false, reason: ''}, + camera: {granted: false, reason: ''}, + notifications: {granted: false, reason: ''}, + }); + + useEffect(() => { + checkPermissions(); + }, []); + + const checkPermissions = async () => { + const result = await PermissionManager.checkAllPermissions(); + setPermissions(result); + }; + + const requestPermission = async type => { + let result; + + switch (type) { + case 'location': + result = await PermissionManager.requestLocationPermission(); + break; + case 'camera': + result = await PermissionManager.requestCameraPermission(); + break; + case 'notifications': + result = await PermissionManager.requestNotificationPermission(); + break; + } + + if (result.granted) { + Alert.alert('Success', `${type} permission granted!`); + } else if (result.reason === 'blocked') { + Alert.alert( + 'Permission Required', + `${type} permission is required. Please enable it in Settings.`, + [ + {text: 'Cancel', style: 'cancel'}, + {text: 'Settings', onPress: PermissionManager.openSettings}, + ], + ); + } + + checkPermissions(); // Refresh permissions + }; + + const renderPermissionButton = (type, permission) => ( + requestPermission(type)} + disabled={permission.granted}> + + {permission.granted ? `${type} Granted` : `Request ${type}`} + + + ); + + return <>; +}; + +const styles = StyleSheet.create({ + container: { + padding: 20, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 20, + textAlign: 'center', + }, + permissionButton: { + backgroundColor: '#007AFF', + padding: 15, + borderRadius: 8, + marginBottom: 10, + }, + grantedButton: { + backgroundColor: '#34C759', + }, + buttonText: { + color: 'white', + textAlign: 'center', + fontSize: 16, + fontWeight: '600', + }, + successText: { + color: '#34C759', + textAlign: 'center', + fontSize: 18, + fontWeight: '600', + marginTop: 20, + }, +}); + +export default PermissionRequest; diff --git a/src/components/PermissionTest.js b/src/components/PermissionTest.js new file mode 100644 index 0000000..af253eb --- /dev/null +++ b/src/components/PermissionTest.js @@ -0,0 +1,216 @@ +import React, {useState, useEffect} from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + Alert, + Platform, +} from 'react-native'; +import PermissionManager from '../services/PermissionManager'; + +const PermissionTest = () => { + const [permissions, setPermissions] = useState({ + location: {granted: false, reason: ''}, + camera: {granted: false, reason: ''}, + notifications: {granted: false, reason: ''}, + }); + + useEffect(() => { + checkAllPermissions(); + }, []); + + const checkAllPermissions = async () => { + try { + const result = await PermissionManager.checkAllPermissions(); + setPermissions(result); + console.log('Permission check result:', result); + } catch (error) { + console.error('Error checking permissions:', error); + } + }; + + const initializeNotifications = async () => { + try { + const result = await PermissionManager.initializeNotifications(); + console.log('Notification initialization result:', result); + checkAllPermissions(); // Refresh permissions after initialization + } catch (error) { + console.error('Error initializing notifications:', error); + } + }; + + const requestSpecificPermission = async type => { + try { + let result; + + switch (type) { + case 'location': + result = await PermissionManager.requestLocationPermission(); + break; + case 'camera': + result = await PermissionManager.requestCameraPermission(); + break; + case 'notifications': + result = await PermissionManager.requestNotificationPermission(); + break; + } + + console.log(`${type} permission result:`, result); + + if (result.granted) { + Alert.alert('Success', `${type} permission granted!`); + } else { + Alert.alert('Permission Result', `${type}: ${result.reason}`, [ + {text: 'OK', style: 'default'}, + ...(result.reason === 'blocked' + ? [{text: 'Settings', onPress: PermissionManager.openSettings}] + : []), + ]); + } + + // Refresh permissions + checkAllPermissions(); + } catch (error) { + console.error(`Error requesting ${type} permission:`, error); + Alert.alert('Error', `Failed to request ${type} permission`); + } + }; + + const getPermissionStatus = permission => { + if (permission.granted) return '✅ Granted'; + if (permission.reason === 'blocked') return '🚫 Blocked'; + if (permission.reason === 'denied') return '❌ Denied'; + if (permission.reason === 'unavailable') return '⚠️ Unavailable'; + return `❓ ${permission.reason}`; + }; + + return ( + + Permission Test + Platform: {Platform.OS} + + + Location + + {getPermissionStatus(permissions.location)} + + requestSpecificPermission('location')}> + Request Location + + + + + Camera + + {getPermissionStatus(permissions.camera)} + + requestSpecificPermission('camera')}> + Request Camera + + + + + Notifications + + {getPermissionStatus(permissions.notifications)} + + requestSpecificPermission('notifications')}> + Request Notifications + + + + + Refresh All + + + + Open Settings + + + + Initialize Notifications + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 20, + backgroundColor: '#f5f5f5', + }, + title: { + fontSize: 24, + fontWeight: 'bold', + textAlign: 'center', + marginBottom: 10, + color: '#333', + }, + subtitle: { + fontSize: 16, + textAlign: 'center', + marginBottom: 30, + color: '#666', + }, + permissionSection: { + backgroundColor: 'white', + padding: 20, + borderRadius: 10, + marginBottom: 15, + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 10, + color: '#333', + }, + statusText: { + fontSize: 16, + marginBottom: 15, + color: '#666', + }, + button: { + backgroundColor: '#007AFF', + padding: 12, + borderRadius: 8, + alignItems: 'center', + }, + buttonText: { + color: 'white', + fontSize: 16, + fontWeight: '600', + }, + refreshButton: { + backgroundColor: '#34C759', + marginTop: 10, + }, + settingsButton: { + backgroundColor: '#FF9500', + marginTop: 10, + }, + initButton: { + backgroundColor: '#AF52DE', + marginTop: 10, + }, +}); + +export default PermissionTest; diff --git a/src/components/WebSocketMessages.js b/src/components/WebSocketMessages.js new file mode 100644 index 0000000..d96205c --- /dev/null +++ b/src/components/WebSocketMessages.js @@ -0,0 +1,400 @@ +import React, {useEffect, useState, useRef, useCallback} from 'react'; +import { + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import {theme} from '../services/Common/theme'; +import notifee from '@notifee/react-native'; +import {getLocation} from '../functions/geolocation'; +import webSocketService from '../services/WebSocketService'; +import {RADIUS_IN_KILOMETERS} from '../utils/constants'; + +const WebSocketMessages = ({ + url, + // messageTypes = [], + isConnected, + subscribe, +}) => { + const [messages, setMessages] = useState([]); + const [filteredMessages, setFilteredMessages] = useState([]); + const [selectedType, setSelectedType] = useState('all'); + + // Use refs to track subscription and prevent duplicate subscriptions + const subscriptionRef = useRef(null); + const isSubscribedRef = useRef(false); + + // Subscribe to report messages from Go backend + // IMPORTANT: Even though we removed the cleanup function, React or the parent + // component might still call unsubscribe. We handle this by blocking + // unsubscribe calls for 'reports' type in WebSocketService. + useEffect(() => { + // Only proceed if we have all required functions and are connected + if (!isConnected || !subscribe) { + return; + } + + // Prevent multiple subscriptions + if (isSubscribedRef.current && subscriptionRef.current) { + return; + } + + try { + // Subscribe to the "reports" message type that Go backend sends + const subscriptionId = subscribe('reports', (payload, metadata) => { + const newMessage = { + id: Date.now(), + type: 'reports', + timestamp: new Date(), + data: payload, + metadata, + }; + + // check if the report is within the user's radius and display notification + checkUserLocation(payload).then(isInRange => { + if (isInRange) { + onDisplayNotification(payload); + } + }); + + setMessages(prev => { + const updatedMessages = [newMessage, ...prev.slice(0, 99)]; // Keep last 100 + return updatedMessages; + }); + }); + + // Store subscription info in refs + subscriptionRef.current = subscriptionId; + isSubscribedRef.current = true; + } catch (error) { + console.error('Error creating subscription:', error); + return; + } + + // NO CLEANUP FUNCTION - subscription stays active forever + // This prevents the immediate unsubscribe issue + }, [isConnected, url, subscribe]); // Removed unsubscribe from dependencies + + // NO CLEANUP ON UNMOUNT - subscription stays active forever + // This prevents any unsubscribe calls that could cause issues + + // Filter messages based on selected type + useEffect(() => { + if (selectedType === 'all') { + setFilteredMessages(messages); + } else { + setFilteredMessages(messages.filter(msg => msg.type === selectedType)); + } + }, [messages, selectedType]); + + const clearMessages = () => { + // clearMessageHistory(); // This function is no longer available from useWebSocket + setMessages([]); + setFilteredMessages([]); + }; + + const formatTimestamp = timestamp => { + return new Date(timestamp).toLocaleTimeString(); + }; + + const renderReportMessage = message => { + const {data} = message; + + if (!data || !data.reports) { + return No report data; + } + + return ( + + + 📊 Reports Batch ({data.count} reports) + + + Sequence: {data.fromSeq} - {data.toSeq} + + {data.reports.slice(0, 3).map((reportWithAnalysis, index) => { + return ( + + + ID: {reportWithAnalysis.report.id} + + + 📍 {reportWithAnalysis.report.latitude.toFixed(4)},{' '} + {reportWithAnalysis.report.longitude.toFixed(4)} + + + ⏰{' '} + {new Date(reportWithAnalysis.report.timestamp).toLocaleString()} + + {reportWithAnalysis.analysis && + reportWithAnalysis.analysis.length > 0 && ( + + 🔍 Analysis: {reportWithAnalysis.analysis[0].classification} + (Severity: {reportWithAnalysis.analysis[0].severityLevel}) + + )} + + ); + })} + {data.reports.length > 3 && ( + + ... and {data.reports.length - 3} more reports + + )} + + ); + }; + + const renderMessage = message => { + switch (message.type) { + case 'reports': + return renderReportMessage(message); + default: + return ( + + {JSON.stringify(message.data || message, null, 2)} + + ); + } + }; + + const calculateDistance = (location, reportLocation) => { + const R = 6371; // Radius of the earth in km + const dLat = + (reportLocation.latitude - location.latitude) * (Math.PI / 180); + const dLon = + (reportLocation.longitude - location.longitude) * (Math.PI / 180); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(location.latitude * (Math.PI / 180)) * + Math.cos(reportLocation.latitude * (Math.PI / 180)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + const d = R * c; // Distance in km + return d; + }; + + const checkUserLocation = async payload => { + let isInRange = false; + + try { + const location = await getLocation(); + if (location) { + if (!payload.reports || payload.reports.length === 0) { + return false; + } + + const report = payload.reports[0].report; + + const reportLocation = { + latitude: report.latitude, + longitude: report.longitude, + }; + + // Calculate the distance between the user's location and the report's location + const distance = calculateDistance(location, reportLocation); + + if (distance < RADIUS_IN_KILOMETERS) { + isInRange = true; + } + } else { + isInRange = false; + } + } catch (error) { + console.error('Error getting location:', error); + isInRange = false; + } finally { + return isInRange; + } + }; + + async function onDisplayNotification(payload) { + // Request permissions (required for iOS) + await notifee.requestPermission(); + + // Create a channel (required for Android) + const channelId = await notifee.createChannel({ + id: 'default', + name: 'Default Channel', + }); + + try { + if (!payload.reports || payload.reports.length === 0) { + return false; + } + + const report = payload.reports[0].report; + + let analysisText = ''; + if ( + !payload.reports[0].analysis || + payload.reports[0].analysis.length === 0 + ) { + analysisText = ''; + } else { + analysisText = payload.reports[0].analysis[0].summary; + } + + // Display a notification + await notifee.displayNotification({ + title: 'A new report has been detected', + body: analysisText, + android: { + channelId, + // pressAction is needed if you want the notification to open the app when pressed + pressAction: { + id: 'default', + }, + }, + }); + } catch (e) { + console.error('Error displaying notification:', e); + } + } + + return <>; +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: theme.COLORS.BG || '#FFFFFF', + padding: 16, + }, + disconnectedText: { + textAlign: 'center', + color: theme.COLORS.GRAY || '#9E9E9E', + fontSize: 16, + marginTop: 20, + }, + filterContainer: { + flexDirection: 'row', + justifyContent: 'space-around', + marginBottom: 12, + backgroundColor: theme.COLORS.GRAY || '#F5F5F5', + borderRadius: 8, + padding: 4, + }, + filterButton: { + paddingHorizontal: 15, + paddingVertical: 8, + borderRadius: 6, + }, + filterButtonActive: { + backgroundColor: theme.COLORS.PRIMARY || '#2196F3', + }, + filterButtonText: { + fontSize: 14, + fontWeight: '500', + color: theme.COLORS.TEXT || '#333333', + }, + filterButtonTextActive: { + color: '#FFFFFF', + }, + messagesContainer: { + flex: 1, + }, + messageContainer: { + backgroundColor: theme.COLORS.BG || '#F8F9FA', + borderRadius: 8, + padding: 12, + marginBottom: 10, + borderLeftWidth: 4, + borderLeftColor: theme.COLORS.PRIMARY || '#2196F3', + }, + messageHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + messageType: { + fontSize: 14, + fontWeight: '600', + color: theme.COLORS.PRIMARY || '#2196F3', + textTransform: 'uppercase', + }, + messageTimestamp: { + fontSize: 12, + color: theme.COLORS.GRAY || '#9E9E9E', + }, + messageText: { + fontSize: 14, + color: theme.COLORS.TEXT || '#333333', + lineHeight: 20, + }, + reportContainer: { + backgroundColor: theme.COLORS.BG || '#F8F9FA', + borderRadius: 8, + padding: 12, + marginBottom: 10, + borderLeftWidth: 4, + borderLeftColor: theme.COLORS.PRIMARY || '#2196F3', + }, + reportHeader: { + fontSize: 16, + fontWeight: '600', + color: theme.COLORS.PRIMARY || '#2196F3', + marginBottom: 8, + }, + reportDetails: { + fontSize: 13, + color: theme.COLORS.GRAY || '#9E9E9E', + marginBottom: 12, + }, + singleReport: { + backgroundColor: theme.COLORS.BG || '#E0E0E0', + borderRadius: 6, + padding: 10, + marginBottom: 8, + }, + reportId: { + fontSize: 14, + fontWeight: '500', + color: theme.COLORS.TEXT || '#333333', + marginBottom: 4, + }, + reportLocation: { + fontSize: 13, + color: theme.COLORS.GRAY || '#9E9E9E', + marginBottom: 4, + }, + reportTime: { + fontSize: 12, + color: theme.COLORS.GRAY || '#9E9E9E', + marginBottom: 4, + }, + analysisInfo: { + fontSize: 12, + color: theme.COLORS.GRAY || '#9E9E9E', + }, + moreReports: { + fontSize: 12, + color: theme.COLORS.GRAY || '#9E9E9E', + marginTop: 8, + }, + noMessagesText: { + textAlign: 'center', + color: theme.COLORS.GRAY || '#9E9E9E', + fontSize: 16, + marginTop: 20, + }, + clearButton: { + backgroundColor: theme.COLORS.ERROR || '#F44336', + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 6, + alignSelf: 'center', + marginTop: 10, + }, + clearButtonText: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '600', + }, +}); + +export default WebSocketMessages; diff --git a/src/components/WebSocketStatus.js b/src/components/WebSocketStatus.js new file mode 100644 index 0000000..aa13edf --- /dev/null +++ b/src/components/WebSocketStatus.js @@ -0,0 +1,144 @@ +import React from 'react'; +import {Alert, StyleSheet, Text, TouchableOpacity, View} from 'react-native'; +import {theme} from '../services/Common/theme'; + +const WebSocketStatus = ({ + url, + onConnect, + onDisconnect, + isConnected, + connectionStatus, + isReconnecting, + error, + stats, + connect, + disconnect, +}) => { + const handleConnect = async () => { + console.log('🔌 [WebSocketStatus] handleConnect() called'); + try { + console.log('🔌 [WebSocketStatus] Attempting to connect to:', url); + await connect(); + console.log( + '🔌 [WebSocketStatus] Connection successful, calling onConnect callback', + ); + if (onConnect) onConnect(); + } catch (err) { + console.error('❌ [WebSocketStatus] Connection failed:', err); + Alert.alert('Connection Error', err.message || 'Failed to connect'); + } + }; + + const handleDisconnect = () => { + console.log('🔌 [WebSocketStatus] handleDisconnect() called'); + disconnect(); + console.log( + '🔌 [WebSocketStatus] Disconnected, calling onDisconnect callback', + ); + if (onDisconnect) onDisconnect(); + }; + + const getStatusColor = () => { + switch (connectionStatus) { + case 'connected': + return theme.COLORS.SUCCESS || '#4CAF50'; + case 'connecting': + return theme.COLORS.WARNING || '#FF9800'; + case 'disconnected': + return theme.COLORS.ERROR || '#F44336'; + case 'failed': + return theme.COLORS.ERROR || '#F44336'; + default: + return theme.COLORS.GRAY || '#9E9E9E'; + } + }; + + const getStatusText = () => { + if (isReconnecting) { + return `Reconnecting... (${stats.reconnectAttempts || 0}/${ + stats.maxReconnectAttempts || 5 + })`; + } + + switch (connectionStatus) { + case 'connected': + return 'Connected'; + case 'connecting': + return 'Connecting...'; + case 'disconnected': + return 'Disconnected'; + case 'failed': + return 'Connection Failed'; + default: + return 'Unknown'; + } + }; + + return <>; +}; + +const styles = StyleSheet.create({ + container: { + padding: 16, + backgroundColor: theme.COLORS.BG, + borderRadius: 8, + marginVertical: 8, + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + statusRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + statusIndicator: { + width: 12, + height: 12, + borderRadius: 6, + marginRight: 8, + }, + statusText: { + fontSize: 16, + fontWeight: '600', + color: theme.COLORS.TEXT || '#ffffff', + }, + errorText: { + color: theme.COLORS.ERROR || '#F44336', + fontSize: 14, + marginBottom: 8, + }, + statsContainer: { + marginBottom: 16, + }, + statsText: { + fontSize: 12, + color: theme.COLORS.GRAY || '#9E9E9E', + }, + buttonContainer: { + flexDirection: 'row', + justifyContent: 'center', + }, + button: { + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 6, + minWidth: 100, + alignItems: 'center', + }, + connectButton: { + backgroundColor: theme.COLORS.SUCCESS || '#4CAF50', + }, + disconnectButton: { + backgroundColor: theme.COLORS.ERROR || '#F44336', + }, + buttonText: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '600', + }, +}); + +export default WebSocketStatus; diff --git a/src/hooks/useWebSocket.js b/src/hooks/useWebSocket.js new file mode 100644 index 0000000..b5c30cc --- /dev/null +++ b/src/hooks/useWebSocket.js @@ -0,0 +1,244 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import webSocketService from "../services/WebSocketService"; + +/** + * React hook for using WebSocket service in components + * @param {string} url - WebSocket server URL + * @param {Object} options - Connection options + * @returns {Object} WebSocket state and methods + */ +export const useWebSocket = (url, options = {}) => { + const [connectionStatus, setConnectionStatus] = useState("disconnected"); + const [isConnected, setIsConnected] = useState(false); + const [isReconnecting, setIsReconnecting] = useState(false); + const [lastMessage, setLastMessage] = useState(null); + const [error, setError] = useState(null); + const [stats, setStats] = useState({}); + + const messageHistory = useRef([]); + const subscriptions = useRef(new Map()); + const reconnectAttempts = useRef(0); + + // Default options + const defaultOptions = { + autoConnect: true, + reconnect: true, + // Go backend handles ping/pong, so disable client heartbeat + heartbeat: false, + heartbeatInterval: 30000, + heartbeatTimeout: 5000, + maxReconnectAttempts: 5, + ...options, + }; + + // Connect to WebSocket + const connect = useCallback(async () => { + try { + setError(null); + await webSocketService.connect(url, defaultOptions); + } catch (err) { + setError(err.message || "Failed to connect"); + console.error("WebSocket connection error:", err); + } + }, [url, defaultOptions]); + + // Disconnect from WebSocket + const disconnect = useCallback(() => { + webSocketService.disconnect(); + }, []); + + // Send message + const send = useCallback((message, queueIfDisconnected = true) => { + return webSocketService.send(message, queueIfDisconnected); + }, []); + + // Subscribe to message type + const subscribe = useCallback( + (type, callback, id = null) => { + console.log("🔔 [useWebSocket] subscribe() called:", { + type, + callbackType: typeof callback, + id, + url, + hookRenderCount: Date.now(), // Track when this function is called + }); + + const subscriptionId = webSocketService.subscribe( + type, + (payload, metadata) => { + console.log("🔔 [useWebSocket] Subscription callback triggered:", { + type, + payload, + metadata, + payloadType: typeof payload, + payloadKeys: payload ? Object.keys(payload) : "N/A", + timestamp: Date.now(), + }); + + setLastMessage({ type, payload, metadata, timestamp: Date.now() }); + + // Add to message history + messageHistory.current.push({ + type, + payload, + metadata, + timestamp: Date.now(), + }); + + // Keep only last 100 messages + if (messageHistory.current.length > 100) { + messageHistory.current.shift(); + } + + // Call the callback + if (callback) { + console.log( + "🔔 [useWebSocket] Calling user callback with payload:", + payload + ); + callback(payload, metadata); + } + }, + id + ); + + console.log( + "🔔 [useWebSocket] Subscription created with ID:", + subscriptionId + ); + + // Store subscription reference + subscriptions.current.set(type, subscriptionId); + + return subscriptionId; + }, + [url] + ); + + // Unsubscribe from message type + const unsubscribe = useCallback( + (type, id = null) => { + const subscriptionId = subscriptions.current.get(type); + if (subscriptionId) { + webSocketService.unsubscribe(type, id || subscriptionId); + subscriptions.current.delete(type); + } + }, + [url] + ); + + // Get message history + const getMessageHistory = useCallback((type = null, limit = 50) => { + let messages = messageHistory.current; + + if (type) { + messages = messages.filter((msg) => msg.type === type); + } + + return messages.slice(-limit); + }, []); + + // Clear message history + const clearMessageHistory = useCallback(() => { + messageHistory.current = []; + }, []); + + // Get connection stats + const getStats = useCallback(() => { + return webSocketService.getStats(); + }, []); + + // Update stats periodically - REMOVED to prevent constant re-renders + // useEffect(() => { + // const statsInterval = setInterval(() => { + // setStats(webSocketService.getStats()); + // }, 1000); + + // return () => clearInterval(statsInterval); + // }, []); + + // Set up event listeners + useEffect(() => { + const handleConnected = (data) => { + setIsConnected(true); + setConnectionStatus("connected"); + setIsReconnecting(false); + setError(null); + reconnectAttempts.current = 0; + }; + + const handleDisconnected = (data) => { + setIsConnected(false); + setConnectionStatus("disconnected"); + setIsReconnecting(false); + }; + + const handleReconnecting = () => { + setIsReconnecting(true); + setConnectionStatus("connecting"); + reconnectAttempts.current++; + }; + + const handleError = (errorData) => { + setError(errorData.error || "WebSocket error"); + }; + + const handleReconnectFailed = (data) => { + setIsReconnecting(false); + setConnectionStatus("failed"); + setError(`Reconnection failed after ${data.attempts} attempts`); + }; + + // Subscribe to WebSocket events + webSocketService.on("connected", handleConnected); + webSocketService.on("disconnected", handleDisconnected); + webSocketService.on("reconnecting", handleReconnecting); + webSocketService.on("error", handleError); + webSocketService.on("reconnectFailed", handleReconnectFailed); + + // Auto-connect if enabled + if (defaultOptions.autoConnect && url) { + connect(); + } + + // Cleanup function + return () => { + webSocketService.off("connected", handleConnected); + webSocketService.off("disconnected", handleDisconnected); + webSocketService.off("reconnecting", handleReconnecting); + webSocketService.off("error", handleError); + webSocketService.off("reconnectFailed", handleReconnectFailed); + + // Unsubscribe from all message types + subscriptions.current.forEach((subscriptionId, type) => { + webSocketService.unsubscribe(type, subscriptionId); + }); + subscriptions.current.clear(); + }; + }, [url, defaultOptions.autoConnect, connect]); + + return { + // State + connectionStatus, + isConnected, + isReconnecting, + lastMessage, + error, + stats, + + // Methods + connect, + disconnect, + send, + subscribe, + unsubscribe, + getMessageHistory, + clearMessageHistory, + getStats, + + // Utility + messageHistory: messageHistory.current, + }; +}; + +export default useWebSocket; diff --git a/src/screens/CacheScreen.js b/src/screens/CacheScreen.js index 9eff0e1..19f236e 100644 --- a/src/screens/CacheScreen.js +++ b/src/screens/CacheScreen.js @@ -1,9 +1,21 @@ -import React, { useState } from 'react'; -import { useFocusEffect } from '@react-navigation/native'; -import { Linking, Modal, Pressable, ScrollView, StyleSheet, View, Text, TextInput, ToastAndroid, Alert, Platform } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { fontFamilies } from '../utils/fontFamilies'; -import { theme } from '../services/Common/theme'; +import React, {useState} from 'react'; +import {useFocusEffect} from '@react-navigation/native'; +import { + Linking, + Modal, + Pressable, + ScrollView, + StyleSheet, + View, + Text, + TextInput, + ToastAndroid, + Alert, + Platform, +} from 'react-native'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import {fontFamilies} from '../utils/fontFamilies'; +import {theme} from '../services/Common/theme'; import Ripple from '../components/Ripple'; import WalletSettingsIcon from '../assets/ico_cache_settings.svg'; @@ -12,8 +24,8 @@ import BottomSheetDialog from '../components/BotomSheetDialog'; import CopySmallIcon from '../assets/ico_copy_small.svg'; import EyeSmallIcon from '../assets/ico_eye_small.svg'; import EyeOffIcon from '../assets/ico_eye_off.svg'; -import { BlurView } from '@react-native-community/blur'; -import { Row } from '../components/Row'; +import {BlurView} from '@react-native-community/blur'; +import {Row} from '../components/Row'; import Clipboard from '@react-native-clipboard/clipboard'; import { getPrivacySetting, @@ -24,17 +36,19 @@ import { setPrivacySetting, setUserName, } from '../services/DataManager'; -import { useTranslation } from 'react-i18next'; +import {useTranslation} from 'react-i18next'; +import PermissionRequest from '../components/PermissionRequest'; import { getBlockchainLink, getRewardStats, updateOrCreateUser, updatePrivacyAndTOC, } from '../services/API/APIManager'; -import { useStateValue } from '../services/State/State'; -import { actions } from '../services/State/Reducer'; +import {useStateValue} from '../services/State/State'; +import {actions} from '../services/State/Reducer'; +import WebSocketDemoScreen from './WebSocketDemoScreen'; -const CacheScreen = (props) => { +const CacheScreen = props => { const [avatarOpened, setAvatarOpened] = useState(false); const [privacyOpened, setPrivacyOpened] = useState(false); const [cacheSettingOpened, setCacheSettingOpened] = useState(false); @@ -43,10 +57,10 @@ const CacheScreen = (props) => { const [avatarName, setAvatarName] = useState(''); const [shareDataStatus, setShareDataStatus] = useState(0); - const [{ cacheVault }, dispatch] = useStateValue(); + const [{cacheVault}, dispatch] = useStateValue(); const [blockchainLink, setBlockchainLink] = useState(''); - const { t } = useTranslation(); + const {t} = useTranslation(); const privacy_values = [ { @@ -80,31 +94,39 @@ const CacheScreen = (props) => { React.useCallback(() => { const internalFunc = async () => { await initWallet(); - getUserName().then((resp) => { + getUserName().then(resp => { if (resp) { setAvatarName(resp.userName); } }); - getPrivacySetting().then((resp) => { + getPrivacySetting().then(resp => { if (resp) { setShareDataStatus(resp); } }); const wa = await getWalletAddress(); setWalletAddress(await getWalletAddress(wa)); - getRewardStats(wa).then((resp) => { + getRewardStats(wa).then(resp => { if (resp && resp.ok) { let cacheResult = { reports: resp.stats.kitns_daily + resp.stats.kitns_disbursed || 0, - referrals: resp.stats.kitns_ref_daily + resp.stats.kitns_ref_disbursed || 0, + referrals: + resp.stats.kitns_ref_daily + resp.stats.kitns_ref_disbursed || + 0, dailyReports: resp.stats.kitns_daily || 0, dailyReferrals: resp.stats.kitns_ref_daily || 0, disbursedReports: resp.stats.kitns_disbursed || 0, disbursedReferrals: resp.stats.kitns_ref_disbursed || 0, - disbursedTotal: resp.stats.kitns_disbursed + resp.stats.kitns_ref_disbursed || 0, - dailyTotal: resp.stats.kitns_daily + resp.stats.kitns_ref_daily || 0, - total: resp.stats.kitns_daily + resp.stats.kitns_ref_daily + - resp.stats.kitns_disbursed + resp.stats.kitns_ref_disbursed || 0, + disbursedTotal: + resp.stats.kitns_disbursed + resp.stats.kitns_ref_disbursed || + 0, + dailyTotal: + resp.stats.kitns_daily + resp.stats.kitns_ref_daily || 0, + total: + resp.stats.kitns_daily + + resp.stats.kitns_ref_daily + + resp.stats.kitns_disbursed + + resp.stats.kitns_ref_disbursed || 0, }; dispatch({ type: actions.SET_CACHE_VAULT, @@ -113,32 +135,30 @@ const CacheScreen = (props) => { setCacheVault(cacheResult); } }); - getBlockchainLink(wa).then((resp) => { + getBlockchainLink(wa).then(resp => { if (resp && resp.ok) { setBlockchainLink(resp.blockchainLink); } }); - } + }; internalFunc(); }, []), ); const AvatarSheet = ({ isVisible = false, - onClose = () => { }, - onUpdateUser = async () => { }, + onClose = () => {}, + onUpdateUser = async () => {}, }) => { - const [localAvatarName, setLocalAvatarName] = useState(avatarName === walletAddress ? '' : avatarName); + const [localAvatarName, setLocalAvatarName] = useState( + avatarName === walletAddress ? '' : avatarName, + ); return ( - + {t('cachescreen.editavatar')} { onChangeText={setLocalAvatarName} /> { const updateUserResult = await onUpdateUser(localAvatarName); if (updateUserResult) { @@ -160,7 +180,7 @@ const CacheScreen = (props) => { {t('cachescreen.submit')} { onClose(); }}> @@ -169,23 +189,23 @@ const CacheScreen = (props) => { ); - } + }; const PrivacySheet = ({ isVisible = false, dataSharingOption = 0, - onClose = () => { }, + onClose = () => {}, }) => { const [privacySelected, setPrivacySelected] = useState(dataSharingOption); - const selectPrivacy = (privacy_index) => { + const selectPrivacy = privacy_index => { setPrivacySelected(privacy_index); }; const setPrivacy = async () => { await updatePrivacyAndTOC( walletAddress, - privacySelected === 0 ? 'share_data_live' : 'not_share_data_live' + privacySelected === 0 ? 'share_data_live' : 'not_share_data_live', ); setPrivacySetting(privacySelected); setShareDataStatus(privacySelected); @@ -193,13 +213,11 @@ const CacheScreen = (props) => { }; return ( - + - {t('cachescreen.privacysettings')} + + {t('cachescreen.privacysettings')} + {privacy_values.map((element, index) => ( { width: '80%', }}> selectPrivacy(index)}> - + {element.value} {element.sub_value} @@ -228,7 +246,7 @@ const CacheScreen = (props) => { ))} {t('cachescreen.Confirm')} @@ -237,7 +255,7 @@ const CacheScreen = (props) => { ); }; - const CacheSettingSheet = ({ isVisible = false, onClose = () => { } }) => { + const CacheSettingSheet = ({isVisible = false, onClose = () => {}}) => { const [isShowPrivateKey, setIsShowPrivateKey] = useState(true); const [isShowMnemonics, setIsShowMnemonics] = useState(true); @@ -249,7 +267,7 @@ const CacheScreen = (props) => { setIsShowPrivateKey(!isShowPrivateKey); }; - const onCopyWalletAddress = (address) => { + const onCopyWalletAddress = address => { Clipboard.setString(address); if (Platform.OS === 'ios') { Alert.alert('Copied to clipboard'); @@ -273,7 +291,7 @@ const CacheScreen = (props) => { {t('cachescreen.youraddress')} - {walletAddress} + {walletAddress} onCopyWalletAddress(walletAddress)}> @@ -287,12 +305,12 @@ const CacheScreen = (props) => { paddingHorizontal: 8, }}> - + {t('cachescreen.privatekey')} - + {walletInfo.privateKey} {isShowPrivateKey && ( @@ -311,7 +329,7 @@ const CacheScreen = (props) => { - + {isShowPrivateKey ? t('cachescreen.taptoreveal') : t('cachescreen.taptohide')} @@ -323,14 +341,14 @@ const CacheScreen = (props) => { + style={{...styles.blue30Card, marginTop: 24, paddingHorizontal: 8}}> {t('cachescreen.keepyourkitnsafe')} - + - + {t('cachescreen.mnemonicphrase')} @@ -358,7 +376,7 @@ const CacheScreen = (props) => { - + {isShowMnemonics ? t('cachescreen.taptoreveal') : t('cachescreen.taptohide')} @@ -370,7 +388,7 @@ const CacheScreen = (props) => { {t('cachescreen.back')} @@ -385,7 +403,6 @@ const CacheScreen = (props) => { onClose={onClose} title={t('cachescreen.cachesettings')} headerIcon={}> - ); @@ -399,38 +416,47 @@ const CacheScreen = (props) => { {t('cachescreen.total')} - - {cacheVault.total || 0} {'KITN'} + + {cacheVault.total || 0}{' '} + {'KITN'} {t('cachescreen.fromReports')} - {cacheVault.reports || 0} {'KITN'} + {cacheVault.reports || 0}{' '} + {'KITN'} {t('cachescreen.fromReferrals')} - {cacheVault.referrals || 0} {'KITN'} + {cacheVault.referrals || 0}{' '} + {'KITN'} {t('cachescreen.todaysReports')} - {cacheVault.dailyReports} {'KITN'} + {cacheVault.dailyReports}{' '} + {'KITN'} - {t('cachescreen.todaysReferrals')} - {cacheVault.dailyReferrals} {'KITN'} + {t('cachescreen.todaysReferrals')} + + + {cacheVault.dailyReferrals}{' '} + {'KITN'} - {t('cachescreen.todaysLitterbox')} + + {t('cachescreen.todaysLitterbox')} + {`${cacheVault.dailyTotal || 0} `} @@ -439,13 +465,16 @@ const CacheScreen = (props) => { - {t('cachescreen.blockchainLink')} + + {t('cachescreen.blockchainLink')} + - + Linking.openURL(blockchainLink)} - >{blockchainLink} + style={{...styles.txt12bold, color: theme.COLORS.GREEN_LINK}} + onPress={() => Linking.openURL(blockchainLink)}> + {blockchainLink} + {/** Avatar */} @@ -453,14 +482,18 @@ const CacheScreen = (props) => { {t('cachescreen.avatar')} - + - {avatarName === walletAddress ? t('cachescreen.avatarnotset') : avatarName} + {avatarName === walletAddress + ? t('cachescreen.avatarnotset') + : avatarName} - {avatarName === walletAddress ? t('cachescreen.create') : t('cachescreen.edit')} + {avatarName === walletAddress + ? t('cachescreen.create') + : t('cachescreen.edit')} @@ -471,20 +504,18 @@ const CacheScreen = (props) => { {t('cachescreen.privacy')} - + {shareDataStatus == 0 ? t('cachescreen.Mapreportswithavatar') - : t('cachescreen.Sharereportsanonymously') - } + : t('cachescreen.Sharereportsanonymously')} {shareDataStatus == 0 ? t('cachescreen.Traceable') - : t('cachescreen.Nodatacollectedwhileflagging') - } + : t('cachescreen.Nodatacollectedwhileflagging')} @@ -492,7 +523,7 @@ const CacheScreen = (props) => { - + {t('cachescreen.cachesettings')} @@ -503,6 +534,8 @@ const CacheScreen = (props) => { + + { onClose={() => { setAvatarOpened(false); }} - onUpdateUser={async (userName) => { + onUpdateUser={async userName => { if (userName.length == 0) { Alert.alert( t('onboarding.Error'), t('cachescreen.errAvatarEmpty'), - [{ text: t('onboarding.Ok'), type: 'cancel' }], + [{text: t('onboarding.Ok'), type: 'cancel'}], ); return false; } @@ -525,11 +558,11 @@ const CacheScreen = (props) => { Alert.alert( t('onboarding.Error'), t('onboarding.ErrSameUsernameExists'), - [{ text: t('onboarding.Ok'), type: 'cancel' }], + [{text: t('onboarding.Ok'), type: 'cancel'}], ); return false; } - setUserName({ userName: userName }); + setUserName({userName: userName}); return true; } else { return false; @@ -742,7 +775,7 @@ const styles = StyleSheet.create({ padding: 10, color: 'white', fontSize: 20, - fontWeight: 'bold' + fontWeight: 'bold', }, }); diff --git a/src/screens/CameraScreen.js b/src/screens/CameraScreen.js index b90e135..3e10aba 100644 --- a/src/screens/CameraScreen.js +++ b/src/screens/CameraScreen.js @@ -133,8 +133,33 @@ const CameraScreen = (props) => { const flashScale = useState(0); const flashScaleStatic = useState(1); const tapScale = useState(0); - const { t } = useTranslation(); - const { hasPermission, requestPermission } = useCameraPermission(); + const {t} = useTranslation(); + const {hasPermission, requestPermission} = useCameraPermission(); + + // Enhanced camera permission handler + const handleCameraPermission = async () => { + try { + if (!hasPermission) { + const result = await requestPermission(); + if (!result) { + Alert.alert( + 'Camera Permission Required', + 'This app needs camera access to take photos. Please grant camera permission in Settings.', + [ + {text: 'Cancel', style: 'cancel'}, + {text: 'Settings', onPress: () => Linking.openSettings()}, + ], + ); + } + } + } catch (error) { + console.error('Error handling camera permission:', error); + Alert.alert( + 'Camera Permission Error', + 'There was an error requesting camera permission. Please try again or check your device settings.', + ); + } + }; const isFocused = useIsFocused(); @@ -247,7 +272,8 @@ const CameraScreen = (props) => { }, [phototaken, flashAnimatedValue]); useEffect(() => { - requestPermission(); + // Request camera permission when component mounts + handleCameraPermission(); }, []); const checkPhotoLibraryPermission = async () => { @@ -611,6 +637,26 @@ const CameraScreen = (props) => { animatedProps={animatedProps} /> )} + + {/* Camera Permission Status */} + {!hasPermission && ( + + + Camera Permission Required + + + This app needs camera access to take photos. Please grant + camera permission. + + + + Grant Permission + + + + )} {isInAnnotationMode && photoData && ( { )} - {!phototaken && !isInAnnotationMode && ( + {!phototaken && !isInAnnotationMode && hasPermission && ( <> { + const [serverUrl, setServerUrl] = useState( + process.env.APP_MODE === 'dev' + ? 'wss://devlive.cleanapp.io/api/v3/reports/listen' + : 'wss://live.cleanapp.io/api/v3/reports/listen', + ); + // Centralized WebSocket connection + const { + connectionStatus, + isConnected, + isReconnecting, + error, + stats, + connect, + disconnect, + subscribe: originalSubscribe, + unsubscribe: originalUnsubscribe, + } = useWebSocket(serverUrl, { + autoConnect: false, + reconnect: true, + heartbeat: false, // Go backend handles ping/pong + }); + + useEffect(() => { + connect(); + }, []); + + // Stabilize function references to prevent unnecessary re-renders + const subscribe = useCallback(originalSubscribe, [originalSubscribe]); + const unsubscribe = useCallback(originalUnsubscribe, [originalUnsubscribe]); + + const handleConnect = () => { + console.log('🖥️ [WebSocketDemoScreen] handleConnect() called'); + console.log('🖥️ [WebSocketDemoScreen] Server URL:', serverUrl); + connect(); + }; + + const handleDisconnect = () => { + console.log('🖥️ [WebSocketDemoScreen] handleDisconnect() called'); + console.log('🖥️ [WebSocketDemoScreen] Server URL:', serverUrl); + disconnect(); + }; + + return ( + + + {/* WebSocket Status */} + + + {/* WebSocket Messages */} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: theme.COLORS.BG || '#F5F5F5', + }, + scrollView: { + flex: 1, + }, + header: { + padding: 20, + backgroundColor: theme.COLORS.PRIMARY || '#2196F3', + alignItems: 'center', + }, + title: { + fontSize: 24, + fontWeight: 'bold', + color: '#FFFFFF', + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + color: '#FFFFFF', + textAlign: 'center', + opacity: 0.9, + }, + section: { + margin: 16, + padding: 16, + backgroundColor: '#FFFFFF', + borderRadius: 8, + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + color: theme.COLORS.TEXT || '#333333', + marginBottom: 16, + }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + }, + label: { + fontSize: 16, + fontWeight: '500', + color: theme.COLORS.TEXT || '#333333', + marginRight: 12, + minWidth: 80, + }, + urlInput: { + flex: 1, + borderWidth: 1, + borderColor: theme.COLORS.BORDER || '#E0E0E0', + borderRadius: 6, + padding: 12, + fontSize: 14, + color: theme.COLORS.TEXT || '#333333', + }, + resetButton: { + alignSelf: 'flex-end', + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: theme.COLORS.GRAY || '#9E9E9E', + borderRadius: 4, + }, + resetButtonText: { + color: '#FFFFFF', + fontSize: 12, + fontWeight: '500', + }, + messageTypesContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + marginBottom: 16, + }, + messageTypeItem: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.COLORS.PRIMARY || '#E3F2FD', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + marginRight: 8, + marginBottom: 8, + }, + messageTypeText: { + fontSize: 12, + color: theme.COLORS.PRIMARY || '#1976D2', + fontWeight: '500', + marginRight: 6, + }, + removeTypeButton: { + width: 18, + height: 18, + borderRadius: 9, + backgroundColor: theme.COLORS.ERROR || '#F44336', + alignItems: 'center', + justifyContent: 'center', + }, + removeTypeButtonText: { + color: '#FFFFFF', + fontSize: 12, + fontWeight: 'bold', + lineHeight: 18, + }, + addTypeContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + addTypeInput: { + flex: 1, + borderWidth: 1, + borderColor: theme.COLORS.BORDER || '#E0E0E0', + borderRadius: 6, + padding: 12, + marginRight: 8, + fontSize: 14, + color: theme.COLORS.TEXT || '#333333', + }, + addTypeButton: { + backgroundColor: theme.COLORS.SUCCESS || '#4CAF50', + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 6, + }, + addTypeButtonText: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '600', + }, + instructionItem: { + flexDirection: 'row', + marginBottom: 12, + alignItems: 'flex-start', + }, + instructionNumber: { + fontSize: 16, + fontWeight: 'bold', + color: theme.COLORS.PRIMARY || '#2196F3', + marginRight: 12, + minWidth: 20, + }, + instructionText: { + flex: 1, + fontSize: 14, + color: theme.COLORS.TEXT || '#333333', + lineHeight: 20, + }, + featureItem: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + featureIcon: { + fontSize: 20, + marginRight: 12, + }, + featureText: { + flex: 1, + fontSize: 14, + color: theme.COLORS.TEXT || '#333333', + lineHeight: 20, + }, +}); + +export default WebSocketDemoScreen; diff --git a/src/services/PermissionManager.js b/src/services/PermissionManager.js new file mode 100644 index 0000000..6d345d4 --- /dev/null +++ b/src/services/PermissionManager.js @@ -0,0 +1,257 @@ +import {Platform} from 'react-native'; +import { + check, + request, + PERMISSIONS, + RESULTS, + openSettings, +} from 'react-native-permissions'; +import notifee, { + AuthorizationStatus, + AndroidImportance, +} from '@notifee/react-native'; + +class PermissionManager { + // Location Permissions + static async requestLocationPermission() { + try { + let permission; + + if (Platform.OS === 'ios') { + permission = PERMISSIONS.IOS.LOCATION_WHEN_IN_USE; + } else { + permission = PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION; + } + + const result = await check(permission); + + if (result === RESULTS.UNAVAILABLE) { + return {granted: false, reason: 'unavailable'}; + } + + if (result === RESULTS.DENIED) { + const requestResult = await request(permission); + return { + granted: requestResult === RESULTS.GRANTED, + reason: requestResult, + }; + } + + if (result === RESULTS.BLOCKED) { + return {granted: false, reason: 'blocked'}; + } + + return {granted: result === RESULTS.GRANTED, reason: result}; + } catch (error) { + return {granted: false, reason: 'error', error}; + } + } + + // Camera Permissions + static async requestCameraPermission() { + try { + let permission; + + if (Platform.OS === 'ios') { + permission = PERMISSIONS.IOS.CAMERA; + } else { + permission = PERMISSIONS.ANDROID.CAMERA; + } + + const result = await check(permission); + + if (result === RESULTS.UNAVAILABLE) { + return {granted: false, reason: 'unavailable'}; + } + + if (result === RESULTS.DENIED) { + const requestResult = await request(permission); + return { + granted: requestResult === RESULTS.GRANTED, + reason: requestResult, + }; + } + + if (result === RESULTS.BLOCKED) { + return {granted: false, reason: 'blocked'}; + } + + return {granted: result === RESULTS.GRANTED, reason: result}; + } catch (error) { + console.error('Camera permission error:', error); + return {granted: false, reason: 'error', error}; + } + } + + // Check camera permission without requesting + static async checkCameraPermission() { + try { + let permission; + + if (Platform.OS === 'ios') { + permission = PERMISSIONS.IOS.CAMERA; + } else { + permission = PERMISSIONS.ANDROID.CAMERA; + } + + const result = await check(permission); + return {granted: result === RESULTS.GRANTED, reason: result}; + } catch (error) { + console.error('Check camera permission error:', error); + return {granted: false, reason: 'error', error}; + } + } + + // Notification Permissions using notifee + static async requestNotificationPermission() { + try { + // Request permission using notifee + const settings = await notifee.requestPermission(); + + // Log the authorization status + if (settings.authorizationStatus === AuthorizationStatus.DENIED) { + console.log('User denied notification permissions request'); + return {granted: false, reason: 'denied'}; + } else if ( + settings.authorizationStatus === AuthorizationStatus.AUTHORIZED + ) { + console.log('User granted notification permissions request'); + return {granted: true, reason: 'granted'}; + } else if ( + settings.authorizationStatus === AuthorizationStatus.PROVISIONAL + ) { + console.log( + 'User provisionally granted notification permissions request', + ); + return {granted: true, reason: 'provisional'}; + } else if ( + settings.authorizationStatus === AuthorizationStatus.NOT_DETERMINED + ) { + console.log('Notification permission not determined'); + return {granted: false, reason: 'not_determined'}; + } + + // Fallback: check system permission status + let permission; + if (Platform.OS === 'ios') { + permission = PERMISSIONS.IOS.NOTIFICATIONS; + } else { + // Android 13+ (API level 33+) + if (Platform.Version >= 33) { + permission = PERMISSIONS.ANDROID.POST_NOTIFICATIONS; + } else { + return {granted: true, reason: 'not_required'}; + } + } + + const result = await check(permission); + return {granted: result === RESULTS.GRANTED, reason: result}; + } catch (error) { + console.error('Notification permission error:', error); + return {granted: false, reason: 'error', error}; + } + } + + // Check notification permission status without requesting + static async checkNotificationPermission() { + try { + // Check current notification settings + const settings = await notifee.getNotificationSettings(); + + if (settings.authorizationStatus === AuthorizationStatus.AUTHORIZED) { + return {granted: true, reason: 'authorized'}; + } else if ( + settings.authorizationStatus === AuthorizationStatus.PROVISIONAL + ) { + return {granted: true, reason: 'provisional'}; + } else if (settings.authorizationStatus === AuthorizationStatus.DENIED) { + return {granted: false, reason: 'denied'}; + } else if ( + settings.authorizationStatus === AuthorizationStatus.NOT_DETERMINED + ) { + return {granted: false, reason: 'not_determined'}; + } + + return {granted: false, reason: 'unknown'}; + } catch (error) { + console.error('Check notification permission error:', error); + return {granted: false, reason: 'error', error}; + } + } + + // Check all permissions at once + static async checkAllPermissions() { + const [location, camera, notifications] = await Promise.all([ + this.requestLocationPermission(), + this.requestCameraPermission(), + this.checkNotificationPermission(), // Use check instead of request to avoid prompting + ]); + + return { + location, + camera, + notifications, + allGranted: location.granted && camera.granted && notifications.granted, + }; + } + + // Request all permissions at once (use this when you want to prompt user) + static async requestAllPermissions() { + const [location, camera, notifications] = await Promise.all([ + this.requestLocationPermission(), + this.requestCameraPermission(), + this.requestNotificationPermission(), + ]); + + return { + location, + camera, + notifications, + allGranted: location.granted && camera.granted && notifications.granted, + }; + } + + // Open app settings + static openSettings() { + openSettings(); + } + + // Create notification channel for Android (required for notifications to work) + static async createNotificationChannel() { + if (Platform.OS === 'android') { + try { + const channelId = await notifee.createChannel({ + id: 'default', + name: 'Default Channel', + sound: 'default', + importance: AndroidImportance.HIGH, + }); + console.log('Notification channel created:', channelId); + return channelId; + } catch (error) { + console.error('Error creating notification channel:', error); + return null; + } + } + return null; + } + + // Initialize notification system + static async initializeNotifications() { + try { + // Create notification channel for Android + await this.createNotificationChannel(); + + // Check current notification settings + const settings = await this.checkNotificationPermission(); + console.log('Notification settings:', settings); + + return settings; + } catch (error) { + console.error('Error initializing notifications:', error); + return {granted: false, reason: 'error', error}; + } + } +} + +export default PermissionManager; diff --git a/src/services/WebSocketService.js b/src/services/WebSocketService.js new file mode 100644 index 0000000..41846e1 --- /dev/null +++ b/src/services/WebSocketService.js @@ -0,0 +1,501 @@ +// Custom EventEmitter implementation for React Native +class EventEmitter { + constructor() { + this.events = {}; + } + + on(event, listener) { + if (!this.events[event]) { + this.events[event] = []; + } + this.events[event].push(listener); + return this; + } + + off(event, listener) { + if (!this.events[event]) return this; + + if (listener) { + this.events[event] = this.events[event].filter(l => l !== listener); + } else { + delete this.events[event]; + } + return this; + } + + emit(event, ...args) { + if (!this.events[event]) return false; + + this.events[event].forEach(listener => { + try { + listener(...args); + } catch (error) { + console.error(`Error in event listener for ${event}:`, error); + } + }); + + return true; + } + + removeAllListeners(event) { + if (event) { + delete this.events[event]; + } else { + this.events = {}; + } + return this; + } +} + +class WebSocketService extends EventEmitter { + constructor() { + super(); + this.ws = null; + this.url = null; + this.isConnected = false; + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 5; + this.reconnectDelay = 1000; // Start with 1 second + this.maxReconnectDelay = 30000; // Max 30 seconds + this.heartbeatInterval = null; + this.heartbeatTimeout = null; + this.messageQueue = []; + this.subscriptions = new Map(); + this.isReconnecting = false; + } + + /** + * Connect to WebSocket server + * @param {string} url - WebSocket server URL + * @param {Object} options - Connection options + */ + connect(url, options = {}) { + if (this.isConnected || this.isReconnecting) { + return Promise.resolve(); + } + + this.url = url; + const { + protocols = [], + headers = {}, + reconnect = true, + // Go backend handles ping/pong, so disable client heartbeat + heartbeat = false, + heartbeatInterval = 30000, + heartbeatTimeout = 5000, + } = options; + + return new Promise((resolve, reject) => { + try { + this.ws = new WebSocket(url, protocols); + + // Set up event handlers + this.ws.onopen = () => { + this.isConnected = true; + this.isReconnecting = false; + this.reconnectAttempts = 0; + this.reconnectDelay = 1000; + + // Go backend handles ping/pong, so don't start client heartbeat + // if (heartbeat) { + // this.startHeartbeat(heartbeatInterval, heartbeatTimeout); + // } + + // Process queued messages + this.processMessageQueue(); + + // Emit connection event + this.emit('connected', {url, timestamp: Date.now()}); + + resolve(); + }; + + this.ws.onmessage = event => { + try { + const data = JSON.parse(event.data); + + // Handle double-encoded JSON from Go backend + if (data.data && typeof data.data === 'string') { + try { + const innerData = JSON.parse(data.data); + this.handleMessage(innerData); + } catch (innerError) { + // Fall back to original data + this.handleMessage(data); + } + } else { + this.handleMessage(data); + } + } catch (error) { + this.emit('error', { + error: 'Invalid message format', + data: event.data, + parseError: error.message, + }); + } + }; + + this.ws.onclose = event => { + this.isConnected = false; + this.stopHeartbeat(); + + // Emit disconnection event + this.emit('disconnected', { + code: event.code, + reason: event.reason, + timestamp: Date.now(), + }); + + // Attempt reconnection if enabled + if (reconnect && !this.isReconnecting) { + this.scheduleReconnect(); + } + }; + + this.ws.onerror = error => { + this.emit('error', {error: error.message || 'WebSocket error'}); + reject(error); + }; + } catch (error) { + reject(error); + } + }); + } + + /** + * Disconnect from WebSocket server + */ + disconnect() { + if (this.ws) { + this.stopHeartbeat(); + this.ws.close(1000, 'Client disconnecting'); + this.ws = null; + this.isConnected = false; + this.isReconnecting = false; + this.messageQueue = []; + // Don't clear subscriptions on disconnect - preserve them for reconnection + } + } + + /** + * Send message to WebSocket server + * @param {Object|string} message - Message to send + * @param {boolean} queueIfDisconnected - Queue message if disconnected + */ + send(message, queueIfDisconnected = true) { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + if (queueIfDisconnected) { + this.messageQueue.push(message); + + return false; + } + throw new Error('WebSocket not connected'); + } + + try { + const messageStr = + typeof message === 'string' ? message : JSON.stringify(message); + this.ws.send(messageStr); + this.emit('messageSent', {message, timestamp: Date.now()}); + return true; + } catch (error) { + console.error('Error sending WebSocket message:', error); + this.emit('error', { + error: 'Failed to send message', + originalError: error, + }); + return false; + } + } + + /** + * Subscribe to a specific message type + * @param {string} type - Message type to subscribe to + * @param {Function} callback - Callback function + * @param {string} id - Optional subscription ID + */ + subscribe(type, callback, id = null) { + const subscriptionId = id || `${type}_${Date.now()}_${Math.random()}`; + + if (!this.subscriptions.has(type)) { + this.subscriptions.set(type, new Map()); + } + + // Check if this exact callback is already subscribed + const typeSubscriptions = this.subscriptions.get(type); + for (const [existingId, existingCallback] of typeSubscriptions) { + if (existingCallback === callback) { + return existingId; + } + } + + typeSubscriptions.set(subscriptionId, callback); + + // Emit subscription event + this.emit('subscribed', {type, id: subscriptionId}); + + return subscriptionId; + } + + /** + * Unsubscribe from a specific message type + * @param {string} type - Message type to unsubscribe from + * @param {string} id - Subscription ID (if null, unsubscribes all for type) + */ + unsubscribe(type, id = null) { + // SPECIAL CASE: Never unsubscribe from 'reports' type + // This prevents React or parent components from accidentally removing + // the reports subscription that we want to keep active forever + if (type === 'reports') { + return true; // Pretend unsubscribe was successful + } + + if (!this.subscriptions.has(type)) { + return false; + } + + if (id === null) { + // Unsubscribe all for this type + const count = this.subscriptions.get(type).size; + this.subscriptions.delete(type); + this.emit('unsubscribed', {type, id: null}); + return true; + } else { + // Unsubscribe specific subscription + const typeSubscriptions = this.subscriptions.get(type); + if (typeSubscriptions && typeSubscriptions.has(id)) { + typeSubscriptions.delete(id); + + // Remove type if no more subscriptions + if (typeSubscriptions.size === 0) { + this.subscriptions.delete(type); + } + + this.emit('unsubscribed', {type, id}); + return true; + } + } + + return false; + } + + /** + * Get connection status + */ + getConnectionStatus() { + if (!this.ws) { + return 'disconnected'; + } + + switch (this.ws.readyState) { + case WebSocket.CONNECTING: + return 'connecting'; + case WebSocket.OPEN: + return 'connected'; + case WebSocket.CLOSING: + return 'closing'; + case WebSocket.CLOSED: + return 'closed'; + default: + return 'unknown'; + } + } + + /** + * Get connection statistics + */ + getStats() { + return { + isConnected: this.isConnected, + isReconnecting: this.isReconnecting, + reconnectAttempts: this.reconnectAttempts, + messageQueueSize: this.messageQueue.length, + subscriptionCount: Array.from(this.subscriptions.values()).reduce( + (total, typeSubs) => total + typeSubs.size, + 0, + ), + url: this.url, + connectionStatus: this.getConnectionStatus(), + }; + } + + /** + * Check if a subscription exists for a given type and callback + * @param {string} type - Message type + * @param {Function} callback - Callback function + * @returns {string|null} - Subscription ID if exists, null otherwise + */ + hasSubscription(type, callback) { + if (!this.subscriptions.has(type)) { + return null; + } + + const typeSubscriptions = this.subscriptions.get(type); + for (const [id, existingCallback] of typeSubscriptions) { + if (existingCallback === callback) { + return id; + } + } + return null; + } + + /** + * Get all active subscriptions + */ + getSubscriptions() { + const result = {}; + for (const [type, typeSubs] of this.subscriptions) { + result[type] = Array.from(typeSubs.keys()); + } + return result; + } + + // Private methods + + handleMessage(data) { + // Additional validation for the data structure + if (!data || typeof data !== 'object') { + console.error('Invalid data received in handleMessage:', data); + return; + } + + // Extract message properties + const {type, payload, id, timestamp, data: messageData} = data; + + // Emit raw message event with Go backend compatible structure + this.emit('message', { + type, + payload: messageData, + id, + timestamp, + raw: data, + }); + + // Handle subscriptions + if (type && this.subscriptions.has(type)) { + const typeSubscriptions = this.subscriptions.get(type); + + typeSubscriptions.forEach((callback, subscriptionId) => { + try { + // Use messageData (Go's 'data' field) as the actual payload for callbacks + const actualPayload = messageData || payload; + callback(actualPayload, {type, id, timestamp}); + } catch (error) { + console.error('Error in subscription callback:', { + error: error.message, + subscriptionId, + type, + }); + this.emit('error', { + error: 'Subscription callback error', + type, + originalError: error, + }); + } + }); + } + + // Handle specific message types from Go backend + switch (type) { + case 'pong': + // Go backend sends pong responses to our pings + this.handlePong(); + break; + case 'reports': + // Go backend uses 'data' field, so use messageData as the primary source + const reportData = messageData || payload; + + // Go backend broadcasts report data + this.emit('reports', reportData); + break; + case 'error': + this.emit('serverError', payload); + break; + default: + // Emit typed event with Go backend compatible data + const defaultData = messageData || payload; + this.emit(type, defaultData); + } + } + + scheduleReconnect() { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + this.emit('reconnectFailed', { + attempts: this.reconnectAttempts, + maxAttempts: this.maxReconnectAttempts, + }); + return; + } + + this.isReconnecting = true; + this.reconnectAttempts++; + + const delay = Math.min( + this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), + this.maxReconnectDelay, + ); + + setTimeout(() => { + if (this.isReconnecting) { + this.connect(this.url, {reconnect: true}) + .then(() => { + // Emit event to notify components that subscriptions are restored + this.emit('reconnected', { + timestamp: Date.now(), + subscriptionCount: Array.from(this.subscriptions.values()).reduce( + (total, typeSubs) => total + typeSubs.size, + 0, + ), + }); + }) + .catch(error => { + console.error('Reconnection failed:', error); + }); + } + }, delay); + } + + startHeartbeat(interval, timeout) { + this.heartbeatInterval = setInterval(() => { + if (this.isConnected) { + this.send({type: 'ping', timestamp: Date.now()}); + + // Set timeout for pong response + this.heartbeatTimeout = setTimeout(() => { + this.ws.close(1000, 'Heartbeat timeout'); + }, timeout); + } + }, interval); + } + + stopHeartbeat() { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + + if (this.heartbeatTimeout) { + clearTimeout(this.heartbeatTimeout); + this.heartbeatTimeout = null; + } + } + + handlePong() { + if (this.heartbeatTimeout) { + clearTimeout(this.heartbeatTimeout); + this.heartbeatTimeout = null; + } + } + + processMessageQueue() { + while (this.messageQueue.length > 0) { + const message = this.messageQueue.shift(); + this.send(message, false); + } + } +} + +// Create singleton instance +const webSocketService = new WebSocketService(); + +export default webSocketService; diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000..37d2f2a --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1 @@ +export const RADIUS_IN_KILOMETERS = 1; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index f6176e6..88ad8ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1008,7 +1008,20 @@ "@babel/parser" "^7.26.9" "@babel/types" "^7.26.9" -"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3", "@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9", "@babel/traverse@^7.26.5", "@babel/traverse@^7.26.8", "@babel/traverse@^7.26.9": +"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3": + version "7.26.9" + resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz" + integrity sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg== + dependencies: + "@babel/code-frame" "^7.26.2" + "@babel/generator" "^7.26.9" + "@babel/parser" "^7.26.9" + "@babel/template" "^7.26.9" + "@babel/types" "^7.26.9" + debug "^4.3.1" + globals "^11.1.0" + +"@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9", "@babel/traverse@^7.26.5", "@babel/traverse@^7.26.8", "@babel/traverse@^7.26.9": version "7.26.9" resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz" integrity sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg== @@ -1475,6 +1488,11 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@notifee/react-native@^9.1.8": + version "9.1.8" + resolved "https://registry.yarnpkg.com/@notifee/react-native/-/react-native-9.1.8.tgz#3d55cb3fbcc21f9e35931e366afdf64b294da891" + integrity sha512-Az/dueoPerJsbbjRxu8a558wKY+gONUrfoy3Hs++5OqbeMsR0dYe6P+4oN6twrLFyzAhEA1tEoZRvQTFDRmvQg== + "@react-native-async-storage/async-storage@^2.1.2": version "2.2.0" resolved "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz"