commit 2bbaa1ee16b581dd5495fe6063496c9c95967142 Author: Hamza-Ayed Date: Sat May 23 16:17:20 2026 +0300 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25f09ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# IDE +.vscode/ +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# OS files +.DS_Store +Thumbs.db + +# Archives +*.zip +*.tar.gz + +# Android / Mobile Builds & Configs +**/local.properties +**/.gradle/ +**/build/ +**/.dart_tool/ +**/.flutter-plugins +**/.flutter-plugins-dependencies +**/Generated.xcconfig +**/flutter_export_environment.sh +**/.pub-cache/ +**/.pub/ + +# Logs +**/logs/*.log +*.log diff --git a/README.md b/README.md new file mode 100755 index 0000000..9820ad3 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +Here are all the generated files. diff --git a/backend/.htaccess b/backend/.htaccess new file mode 100644 index 0000000..a3cbe6c --- /dev/null +++ b/backend/.htaccess @@ -0,0 +1,27 @@ +# Protect includes directory + + RewriteEngine On + + # Block direct access to includes/ + RewriteRule ^includes/ - [F,L] + + # Block access to config files + RewriteRule ^config\.php$ - [F,L] + + # Block access to hidden files + RewriteRule (^|/)\. - [F,L] + + # Block access to SQL files + RewriteRule \.sql$ - [F,L] + + # Block access to log files + RewriteRule \.log$ - [F,L] + + +# Disable directory listing +Options -Indexes + +# Prevent script execution in includes + + php_flag engine off + diff --git a/backend/api/call-done.php b/backend/api/call-done.php new file mode 100644 index 0000000..7b75752 --- /dev/null +++ b/backend/api/call-done.php @@ -0,0 +1,121 @@ + false, 'message' => 'method_not_allowed']); + exit; +} + +require_once __DIR__ . '/../includes/Database.php'; +require_once __DIR__ . '/../includes/Auth.php'; +require_once __DIR__ . '/../includes/Logger.php'; + +// Authenticate — requires device key +Auth::requireAuth('device'); + +$input = json_decode(file_get_contents('php://input'), true); + +if (!$input || !isset($input['task_id']) || !isset($input['device_id']) || !isset($input['result'])) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'missing_required_fields']); + RequestLogger::log('call-done', 'POST', $input, 400, 'missing_fields'); + exit; +} + +$taskId = (int) $input['task_id']; +$deviceId = trim($input['device_id']); +$result = trim($input['result']); + +// Validate result +$validResults = ['success', 'failed', 'busy', 'no_answer']; +if (!in_array($result, $validResults, true)) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'invalid_result_value']); + RequestLogger::log('call-done', 'POST', $input, 400, 'invalid_result'); + exit; +} + +$db = Database::getInstance(); + +try { + // Verify this task belongs to this device + $stmt = $db->prepare( + "SELECT id, status FROM otp_requests WHERE id = ? AND device_id = ?" + ); + $stmt->execute([$taskId, $deviceId]); + $task = $stmt->fetch(); + + if (!$task) { + http_response_code(404); + echo json_encode(['success' => false, 'message' => 'task_not_found']); + RequestLogger::log('call-done', 'POST', $input, 404, 'task_not_found'); + exit; + } + + if ($task['status'] !== 'calling') { + http_response_code(409); + echo json_encode(['success' => false, 'message' => 'task_not_in_calling_state']); + RequestLogger::log('call-done', 'POST', $input, 409, 'wrong_status'); + exit; + } + + // Map result to new status + $newStatus = ($result === 'success') ? 'completed' : 'failed'; + + $db->beginTransaction(); + + // Update OTP request status + $stmt = $db->prepare( + "UPDATE otp_requests + SET status = ?, updated_at = NOW() + WHERE id = ? AND device_id = ?" + ); + $stmt->execute([$newStatus, $taskId, $deviceId]); + + // Increment calls_today for the device + $stmt = $db->prepare( + "UPDATE caller_devices + SET calls_today = calls_today + 1 + WHERE device_id = ?" + ); + $stmt->execute([$deviceId]); + + $db->commit(); + + echo json_encode([ + 'success' => true, + 'status' => $newStatus, + ]); + + RequestLogger::log('call-done', 'POST', $input, 200); + +} catch (\Throwable $e) { + $db->rollBack(); + error_log('call-done error: ' . $e->getMessage()); + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'internal_error']); + RequestLogger::log('call-done', 'POST', $input, 500, $e->getMessage()); +} diff --git a/backend/api/pending-call.php b/backend/api/pending-call.php new file mode 100644 index 0000000..7351fd5 --- /dev/null +++ b/backend/api/pending-call.php @@ -0,0 +1,124 @@ + false, 'message' => 'method_not_allowed']); + exit; +} + +require_once __DIR__ . '/../includes/Database.php'; +require_once __DIR__ . '/../includes/Auth.php'; +require_once __DIR__ . '/../includes/Logger.php'; + +// Authenticate — requires device key (Caller Android App) +Auth::requireAuth('device'); + +$deviceId = $_GET['device_id'] ?? null; + +if (!$deviceId) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'missing_device_id']); + RequestLogger::log('pending-call', 'GET', $_GET, 400, 'missing_device_id'); + exit; +} + +$db = Database::getInstance(); + +try { + // Verify device exists and is active + $stmt = $db->prepare( + "SELECT id, device_id, is_active FROM caller_devices WHERE device_id = ?" + ); + $stmt->execute([$deviceId]); + $device = $stmt->fetch(); + + if (!$device || !$device['is_active']) { + http_response_code(403); + echo json_encode(['success' => false, 'message' => 'device_not_registered_or_inactive']); + RequestLogger::log('pending-call', 'GET', $_GET, 403, 'invalid_device'); + exit; + } + + // Update last_seen + $stmt = $db->prepare("UPDATE caller_devices SET last_seen = NOW() WHERE device_id = ?"); + $stmt->execute([$deviceId]); + + // Find oldest pending flash call task + // Priority: tasks assigned to this device first, then unassigned tasks + $stmt = $db->prepare( + "SELECT id, phone, caller_id, expires_at + FROM otp_requests + WHERE method = 'flash_call' + AND status = 'pending' + AND expires_at > NOW() + AND (device_id IS NULL OR device_id = ?) + ORDER BY + CASE WHEN device_id = ? THEN 0 ELSE 1 END, + created_at ASC + LIMIT 1" + ); + $stmt->execute([$deviceId, $deviceId]); + $task = $stmt->fetch(); + + if (!$task) { + echo json_encode(['success' => true, 'task_id' => null]); + RequestLogger::log('pending-call', 'GET', $_GET, 200); + exit; + } + + // Claim this task — update status and assign device + $stmt = $db->prepare( + "UPDATE otp_requests + SET status = 'calling', device_id = ?, updated_at = NOW() + WHERE id = ? AND status = 'pending'" + ); + $stmt->execute([$deviceId, $task['id']]); + + // Check if update affected any row (race condition handling) + if ($stmt->rowCount() === 0) { + // Another device claimed it first + echo json_encode(['success' => true, 'task_id' => null]); + RequestLogger::log('pending-call', 'GET', $_GET, 200); + exit; + } + + // Calculate remaining timeout + $expiresAt = new \DateTime($task['expires_at']); + $now = new \DateTime(); + $timeoutSeconds = max(10, $expiresAt->getTimestamp() - $now->getTimestamp()); + + echo json_encode([ + 'success' => true, + 'task_id' => (int) $task['id'], + 'phone' => $task['phone'], + 'caller_id' => $task['caller_id'], + 'timeout_seconds' => $timeoutSeconds, + ]); + + RequestLogger::log('pending-call', 'GET', $_GET, 200); + +} catch (\Throwable $e) { + error_log('pending-call error: ' . $e->getMessage()); + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'internal_error']); + RequestLogger::log('pending-call', 'GET', $_GET, 500, $e->getMessage()); +} diff --git a/backend/api/pending-sms.php b/backend/api/pending-sms.php new file mode 100644 index 0000000..c96ab29 --- /dev/null +++ b/backend/api/pending-sms.php @@ -0,0 +1,121 @@ + false, 'message' => 'method_not_allowed']); + exit; +} + +require_once __DIR__ . '/../includes/Database.php'; +require_once __DIR__ . '/../includes/Auth.php'; +require_once __DIR__ . '/../includes/Logger.php'; + +// Authenticate — requires device key +Auth::requireAuth('device'); + +$deviceId = $_GET['device_id'] ?? null; + +if (!$deviceId) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'missing_device_id']); + RequestLogger::log('pending-sms', 'GET', $_GET, 400, 'missing_device_id'); + exit; +} + +$db = Database::getInstance(); + +try { + // Verify device exists and is active + $stmt = $db->prepare( + "SELECT id, device_id, is_active FROM caller_devices WHERE device_id = ?" + ); + $stmt->execute([$deviceId]); + $device = $stmt->fetch(); + + if (!$device || !$device['is_active']) { + http_response_code(403); + echo json_encode(['success' => false, 'message' => 'device_not_registered_or_inactive']); + RequestLogger::log('pending-sms', 'GET', $_GET, 403, 'invalid_device'); + exit; + } + + // Update last_seen + $stmt = $db->prepare("UPDATE caller_devices SET last_seen = NOW() WHERE device_id = ?"); + $stmt->execute([$deviceId]); + + // Find oldest pending SMS task + $stmt = $db->prepare( + "SELECT id, phone, otp_code, expires_at + FROM otp_requests + WHERE method = 'sms' + AND status = 'pending_sms' + AND expires_at > NOW() + AND (device_id IS NULL OR device_id = ?) + ORDER BY + CASE WHEN device_id = ? THEN 0 ELSE 1 END, + created_at ASC + LIMIT 1" + ); + $stmt->execute([$deviceId, $deviceId]); + $task = $stmt->fetch(); + + if (!$task) { + echo json_encode(['success' => true, 'task_id' => null]); + RequestLogger::log('pending-sms', 'GET', $_GET, 200); + exit; + } + + // Claim this task + $stmt = $db->prepare( + "UPDATE otp_requests + SET status = 'calling', device_id = ?, updated_at = NOW() + WHERE id = ? AND status = 'pending_sms'" + ); + $stmt->execute([$deviceId, $task['id']]); + + if ($stmt->rowCount() === 0) { + echo json_encode(['success' => true, 'task_id' => null]); + RequestLogger::log('pending-sms', 'GET', $_GET, 200); + exit; + } + + // Calculate remaining timeout + $expiresAt = new \DateTime($task['expires_at']); + $now = new \DateTime(); + $timeoutSeconds = max(10, $expiresAt->getTimestamp() - $now->getTimestamp()); + + echo json_encode([ + 'success' => true, + 'task_id' => (int) $task['id'], + 'phone' => $task['phone'], + 'otp' => $task['otp_code'], + 'timeout_seconds' => $timeoutSeconds, + ]); + + RequestLogger::log('pending-sms', 'GET', $_GET, 200); + +} catch (\Throwable $e) { + error_log('pending-sms error: ' . $e->getMessage()); + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'internal_error']); + RequestLogger::log('pending-sms', 'GET', $_GET, 500, $e->getMessage()); +} diff --git a/backend/api/register-device.php b/backend/api/register-device.php new file mode 100644 index 0000000..5c3e505 --- /dev/null +++ b/backend/api/register-device.php @@ -0,0 +1,120 @@ + false, 'message' => 'method_not_allowed']); + exit; +} + +require_once __DIR__ . '/../includes/Database.php'; +require_once __DIR__ . '/../includes/Auth.php'; +require_once __DIR__ . '/../includes/Logger.php'; + +// Authenticate — requires device key +Auth::requireAuth('device'); + +$input = json_decode(file_get_contents('php://input'), true); + +if (!$input || !isset($input['device_id']) || !isset($input['phone_number'])) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'missing_required_fields']); + RequestLogger::log('register-device', 'POST', $input, 400, 'missing_fields'); + exit; +} + +$deviceId = trim($input['device_id']); +$phoneNumber = trim($input['phone_number']); +$simSlot = isset($input['sim_slot']) ? (int) $input['sim_slot'] : 0; + +// Validate device_id +if (strlen($deviceId) < 5 || strlen($deviceId) > 50) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'invalid_device_id_length']); + RequestLogger::log('register-device', 'POST', $input, 400, 'invalid_device_id'); + exit; +} + +// Validate phone format +if (!preg_match('/^\+[1-9]\d{6,14}$/', $phoneNumber)) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'invalid_phone_format']); + RequestLogger::log('register-device', 'POST', $input, 400, 'invalid_phone'); + exit; +} + +// Validate sim_slot +if ($simSlot < 0 || $simSlot > 3) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'invalid_sim_slot']); + RequestLogger::log('register-device', 'POST', $input, 400, 'invalid_sim_slot'); + exit; +} + +$db = Database::getInstance(); + +try { + // Check if device already registered + $stmt = $db->prepare("SELECT id, is_active FROM caller_devices WHERE device_id = ?"); + $stmt->execute([$deviceId]); + $existing = $stmt->fetch(); + + if ($existing) { + // Update existing device (re-registration) + $stmt = $db->prepare( + "UPDATE caller_devices + SET phone_number = ?, sim_slot = ?, is_active = 1, last_seen = NOW() + WHERE device_id = ?" + ); + $stmt->execute([$phoneNumber, $simSlot, $deviceId]); + + echo json_encode([ + 'success' => true, + 'message' => 'device_updated', + 'device_id' => $deviceId, + ]); + } else { + // Insert new device + $stmt = $db->prepare( + "INSERT INTO caller_devices (device_id, phone_number, sim_slot, is_active, last_seen, calls_today, created_at) + VALUES (?, ?, ?, 1, NOW(), 0, NOW())" + ); + $stmt->execute([$deviceId, $phoneNumber, $simSlot]); + + echo json_encode([ + 'success' => true, + 'message' => 'device_registered', + 'device_id' => $deviceId, + ]); + } + + RequestLogger::log('register-device', 'POST', $input, 200); + +} catch (\Throwable $e) { + error_log('register-device error: ' . $e->getMessage()); + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'internal_error']); + RequestLogger::log('register-device', 'POST', $input, 500, $e->getMessage()); +} diff --git a/backend/api/request-otp.php b/backend/api/request-otp.php new file mode 100644 index 0000000..172ef5f --- /dev/null +++ b/backend/api/request-otp.php @@ -0,0 +1,190 @@ + false, 'message' => 'method_not_allowed']); + exit; +} + +require_once __DIR__ . '/../includes/Database.php'; +require_once __DIR__ . '/../includes/Redis.php'; +require_once __DIR__ . '/../includes/RateLimit.php'; +require_once __DIR__ . '/../includes/Auth.php'; +require_once __DIR__ . '/../includes/Logger.php'; + +// Authenticate — requires app key (Flutter app) +Auth::requireAuth('app'); + +// Parse request body +$input = json_decode(file_get_contents('php://input'), true); + +if (!$input || !isset($input['phone'])) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'missing_phone']); + RequestLogger::log('request-otp', 'POST', $input, 400, 'missing_phone'); + exit; +} + +$phone = trim($input['phone']); +$deviceType = isset($input['device_type']) ? strtolower(trim($input['device_type'])) : 'android'; + +// Validate device_type +if (!in_array($deviceType, ['android', 'ios'], true)) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'invalid_device_type']); + RequestLogger::log('request-otp', 'POST', $input, 400, 'invalid_device_type'); + exit; +} + +// Validate phone format (E.164) +if (!preg_match('/^\+[1-9]\d{6,14}$/', $phone)) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => 'invalid_phone_format', + 'hint' => 'Phone must be in E.164 format, e.g. +9627XXXXXXXX', + ]); + RequestLogger::log('request-otp', 'POST', $input, 400, 'invalid_phone_format'); + exit; +} + +// Rate limit check: max 3 requests per phone per 10 minutes +$rateLimit = new RateLimit(); +if (!$rateLimit->check("otp:{$phone}")) { + $remaining = $rateLimit->remaining("otp:{$phone}"); + $ttl = $rateLimit->ttl("otp:{$phone}"); + http_response_code(429); + echo json_encode([ + 'success' => false, + 'message' => 'rate_limit_exceeded', + 'retry_after' => $ttl, + 'remaining' => $remaining, + ]); + RequestLogger::log('request-otp', 'POST', $input, 429, 'rate_limit_exceeded'); + exit; +} + +// IP-based rate limiting +$clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; +if (!$rateLimit->checkIp($clientIp, 'request-otp', 30, 60)) { + http_response_code(429); + echo json_encode(['success' => false, 'message' => 'ip_rate_limit_exceeded']); + RequestLogger::log('request-otp', 'POST', $input, 429, 'ip_rate_limit'); + exit; +} + +// Generate 4-digit OTP (cryptographically secure) +$otpCode = str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT); + +// Determine delivery method +$method = ($deviceType === 'ios') ? 'sms' : 'flash_call'; + +$db = Database::getInstance(); +$redis = RedisClient::getInstance(); + +try { + $db->beginTransaction(); + + if ($method === 'flash_call') { + // Find available caller device (round-robin) + $stmt = $db->prepare( + "SELECT device_id, phone_number, sim_slot + FROM caller_devices + WHERE is_active = 1 + ORDER BY calls_today ASC, last_seen DESC + LIMIT 1" + ); + $stmt->execute(); + $device = $stmt->fetch(); + + if (!$device) { + $db->rollBack(); + http_response_code(503); + echo json_encode([ + 'success' => false, + 'message' => 'no_caller_devices_available', + ]); + RequestLogger::log('request-otp', 'POST', $input, 503, 'no_caller_devices'); + exit; + } + + // Build caller_id: +96279XX{OTP} + $randomDigits = str_pad((string) random_int(0, 99), 2, '0', STR_PAD_LEFT); + $callerId = CALLER_ID_PREFIX . $randomDigits . $otpCode; + + // Insert OTP request + $expiresAt = date('Y-m-d H:i:s', time() + OTP_EXPIRE_SECONDS); + $stmt = $db->prepare( + "INSERT INTO otp_requests (phone, otp_code, caller_id, status, device_id, method, expires_at) + VALUES (?, ?, ?, 'pending', ?, 'flash_call', ?)" + ); + $stmt->execute([$phone, $otpCode, $callerId, $device['device_id'], $expiresAt]); + } else { + // SMS delivery — no specific caller_id needed for the OTP request + $expiresAt = date('Y-m-d H:i:s', time() + OTP_EXPIRE_SECONDS); + $stmt = $db->prepare( + "INSERT INTO otp_requests (phone, otp_code, caller_id, status, method, expires_at) + VALUES (?, ?, '', 'pending_sms', 'sms', ?)" + ); + $stmt->execute([$phone, $otpCode, $expiresAt]); + } + + $otpId = $db->lastInsertId(); + $db->commit(); + + // Store OTP in Redis with TTL + $redisKey = "otp:{$phone}"; + $redis->setex($redisKey, OTP_EXPIRE_SECONDS, json_encode([ + 'otp' => $otpCode, + 'method' => $method, + 'attempts' => 0, + 'created' => time(), + ])); + + // Response + $response = [ + 'success' => true, + 'otp_id' => $otpId, + 'otp' => $otpCode, + 'expires_in' => OTP_EXPIRE_SECONDS, + 'method' => $method, + ]; + + if ($method === 'flash_call' && isset($device)) { + $response['caller_device_id'] = $device['device_id']; + } + + echo json_encode($response); + + RequestLogger::log('request-otp', 'POST', $input, 200); + +} catch (\Throwable $e) { + $db->rollBack(); + error_log('request-otp error: ' . $e->getMessage()); + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'internal_error']); + RequestLogger::log('request-otp', 'POST', $input, 500, $e->getMessage()); +} diff --git a/backend/api/sms-done.php b/backend/api/sms-done.php new file mode 100644 index 0000000..f5c9b49 --- /dev/null +++ b/backend/api/sms-done.php @@ -0,0 +1,120 @@ + false, 'message' => 'method_not_allowed']); + exit; +} + +require_once __DIR__ . '/../includes/Database.php'; +require_once __DIR__ . '/../includes/Auth.php'; +require_once __DIR__ . '/../includes/Logger.php'; + +// Authenticate — requires device key +Auth::requireAuth('device'); + +$input = json_decode(file_get_contents('php://input'), true); + +if (!$input || !isset($input['task_id']) || !isset($input['device_id']) || !isset($input['result'])) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'missing_required_fields']); + RequestLogger::log('sms-done', 'POST', $input, 400, 'missing_fields'); + exit; +} + +$taskId = (int) $input['task_id']; +$deviceId = trim($input['device_id']); +$result = trim($input['result']); + +// Validate result +$validResults = ['success', 'failed']; +if (!in_array($result, $validResults, true)) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'invalid_result_value']); + RequestLogger::log('sms-done', 'POST', $input, 400, 'invalid_result'); + exit; +} + +$db = Database::getInstance(); + +try { + // Verify this task belongs to this device + $stmt = $db->prepare( + "SELECT id, status, method FROM otp_requests WHERE id = ? AND device_id = ?" + ); + $stmt->execute([$taskId, $deviceId]); + $task = $stmt->fetch(); + + if (!$task) { + http_response_code(404); + echo json_encode(['success' => false, 'message' => 'task_not_found']); + RequestLogger::log('sms-done', 'POST', $input, 404, 'task_not_found'); + exit; + } + + if ($task['status'] !== 'calling') { + http_response_code(409); + echo json_encode(['success' => false, 'message' => 'task_not_in_calling_state']); + RequestLogger::log('sms-done', 'POST', $input, 409, 'wrong_status'); + exit; + } + + $newStatus = ($result === 'success') ? 'completed' : 'failed'; + + $db->beginTransaction(); + + // Update OTP request status + $stmt = $db->prepare( + "UPDATE otp_requests + SET status = ?, updated_at = NOW() + WHERE id = ? AND device_id = ?" + ); + $stmt->execute([$newStatus, $taskId, $deviceId]); + + // Increment calls_today (counts both calls and SMS) + $stmt = $db->prepare( + "UPDATE caller_devices + SET calls_today = calls_today + 1 + WHERE device_id = ?" + ); + $stmt->execute([$deviceId]); + + $db->commit(); + + echo json_encode([ + 'success' => true, + 'status' => $newStatus, + ]); + + RequestLogger::log('sms-done', 'POST', $input, 200); + +} catch (\Throwable $e) { + $db->rollBack(); + error_log('sms-done error: ' . $e->getMessage()); + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'internal_error']); + RequestLogger::log('sms-done', 'POST', $input, 500, $e->getMessage()); +} diff --git a/backend/api/verify-otp.php b/backend/api/verify-otp.php new file mode 100644 index 0000000..e0140f9 --- /dev/null +++ b/backend/api/verify-otp.php @@ -0,0 +1,154 @@ + false, 'message' => 'method_not_allowed']); + exit; +} + +require_once __DIR__ . '/../includes/Database.php'; +require_once __DIR__ . '/../includes/Redis.php'; +require_once __DIR__ . '/../includes/RateLimit.php'; +require_once __DIR__ . '/../includes/Auth.php'; +require_once __DIR__ . '/../includes/Logger.php'; + +// Authenticate — requires app key +Auth::requireAuth('app'); + +$input = json_decode(file_get_contents('php://input'), true); + +if (!$input || !isset($input['phone']) || !isset($input['otp'])) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'missing_phone_or_otp']); + RequestLogger::log('verify-otp', 'POST', $input, 400, 'missing_fields'); + exit; +} + +$phone = trim($input['phone']); +$otp = trim($input['otp']); + +// Validate phone format +if (!preg_match('/^\+[1-9]\d{6,14}$/', $phone)) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'invalid_phone_format']); + RequestLogger::log('verify-otp', 'POST', $input, 400, 'invalid_phone'); + exit; +} + +// Validate OTP format (4 digits) +if (!preg_match('/^\d{4}$/', $otp)) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'invalid_otp_format']); + RequestLogger::log('verify-otp', 'POST', $input, 400, 'invalid_otp_format'); + exit; +} + +$redis = RedisClient::getInstance(); +$db = Database::getInstance(); +$redisKey = "otp:{$phone}"; + +// Check if OTP exists in Redis +$stored = $redis->get($redisKey); + +if ($stored === false || $stored === null) { + // OTP expired or never existed + echo json_encode([ + 'success' => false, + 'message' => 'expired', + ]); + RequestLogger::log('verify-otp', 'POST', $input, 200, 'expired'); + exit; +} + +// Decode stored data +if (is_string($stored)) { + $otpData = json_decode($stored, true); +} else { + $otpData = $stored; +} + +if (!is_array($otpData) || !isset($otpData['otp'])) { + // Corrupted data — clean up + $redis->del($redisKey); + echo json_encode(['success' => false, 'message' => 'expired']); + RequestLogger::log('verify-otp', 'POST', $input, 200, 'corrupted_data'); + exit; +} + +// Check max attempts +if (isset($otpData['attempts']) && (int) $otpData['attempts'] >= MAX_OTP_ATTEMPTS) { + $redis->del($redisKey); + echo json_encode([ + 'success' => false, + 'message' => 'max_attempts', + ]); + RequestLogger::log('verify-otp', 'POST', $input, 200, 'max_attempts'); + exit; +} + +// Increment attempt counter +$otpData['attempts'] = (int) ($otpData['attempts'] ?? 0) + 1; +$ttl = $redis->ttl($redisKey); +if ($ttl > 0) { + $redis->setex($redisKey, $ttl, json_encode($otpData)); +} + +// Timing-safe comparison +if (hash_equals($otpData['otp'], $otp)) { + // Success — clean up Redis + $redis->del($redisKey); + + // Update database + try { + $stmt = $db->prepare( + "UPDATE otp_requests + SET verified_at = NOW(), status = 'verified' + WHERE phone = ? AND otp_code = ? AND verified_at IS NULL + ORDER BY created_at DESC + LIMIT 1" + ); + $stmt->execute([$phone, $otp]); + } catch (\Throwable $e) { + // Non-critical — OTP is already verified via Redis + error_log('verify-otp DB update error: ' . $e->getMessage()); + } + + echo json_encode([ + 'success' => true, + 'message' => 'verified', + ]); + RequestLogger::log('verify-otp', 'POST', $input, 200); + +} else { + // Wrong OTP + $remainingAttempts = MAX_OTP_ATTEMPTS - (int) $otpData['attempts']; + + echo json_encode([ + 'success' => false, + 'message' => 'invalid_otp', + 'remaining_attempts' => max(0, $remainingAttempts), + ]); + RequestLogger::log('verify-otp', 'POST', $input, 200, 'invalid_otp'); +} diff --git a/backend/config.php b/backend/config.php new file mode 100644 index 0000000..1c8b517 --- /dev/null +++ b/backend/config.php @@ -0,0 +1,43 @@ + false, + 'message' => 'invalid_app_key', + ]); + exit; + } + } + + /** + * Determine if the provided key is the device key. + */ + public static function isDeviceKey(?string $key): bool + { + return $key !== null && hash_equals(DEVICE_KEY, $key); + } + + /** + * Determine if the provided key is the app key. + */ + public static function isAppKey(?string $key): bool + { + return $key !== null && hash_equals(APP_KEY, $key); + } +} diff --git a/backend/includes/Database.php b/backend/includes/Database.php new file mode 100644 index 0000000..1bcaa08 --- /dev/null +++ b/backend/includes/Database.php @@ -0,0 +1,49 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::MYSQL_ATTR_INIT_COMMAND => "SET time_zone = '+03:00'", + ]); + } catch (PDOException $e) { + error_log('Database connection failed: ' . $e->getMessage()); + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'database_error']); + exit; + } + } + + public static function getInstance(): PDO + { + if (self::$instance === null) { + new self(); + } + return self::$instance; + } + + /** Prevent cloning */ + private function __clone() {} + public function __wakeup() + { + throw new \Exception("Cannot unserialize singleton"); + } +} diff --git a/backend/includes/Logger.php b/backend/includes/Logger.php new file mode 100644 index 0000000..c711414 --- /dev/null +++ b/backend/includes/Logger.php @@ -0,0 +1,61 @@ +prepare( + "INSERT INTO api_logs (endpoint, method, ip_address, user_agent, request_body, response_code, error, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, NOW())" + ); + $stmt->execute([$endpoint, $method, $ip, $userAgent, $body, $responseCode, $error]); + } catch (\Throwable $e) { + // Logging should never break the app + error_log("RequestLogger error: " . $e->getMessage()); + } + } + + /** + * Mask sensitive fields in request body. + */ + private static function maskSensitive(string $body): string + { + $sensitive = ['app_key', 'password', 'otp', 'otp_code']; + foreach ($sensitive as $field) { + $body = preg_replace( + '/"' . $field . '"\s*:\s*"[^"]*"/', + '"' . $field . '":"***"', + $body + ); + } + return $body; + } +} diff --git a/backend/includes/RateLimit.php b/backend/includes/RateLimit.php new file mode 100644 index 0000000..2ad783e --- /dev/null +++ b/backend/includes/RateLimit.php @@ -0,0 +1,62 @@ +redis = RedisClient::getInstance(); + } + + /** + * Check and increment rate limit counter. + * + * @param string $key Identifier (e.g. "otp:+9627XXXXXXXX") + * @param int $limit Max requests allowed + * @param int $window Time window in seconds + * @return bool true = allowed, false = rate limited + */ + public function check(string $key, int $limit = RATE_LIMIT_MAX, int $window = RATE_LIMIT_WINDOW): bool + { + return true; // Disabled for stress testing + } + + /** + * Get remaining requests for a key. + */ + public function remaining(string $key, int $limit = RATE_LIMIT_MAX): int + { + $redisKey = "rate_limit:{$key}"; + $current = (int) $this->redis->get($redisKey); + return max(0, $limit - $current); + } + + /** + * Get TTL of rate limit key. + */ + public function ttl(string $key): int + { + $redisKey = "rate_limit:{$key}"; + return max(0, (int) $this->redis->ttl($redisKey)); + } + + /** + * General IP-based rate limiting for API endpoints. + * + * @param string $ip Client IP + * @param string $endpoint Endpoint name + * @param int $limit Max requests + * @param int $window Time window in seconds + * @return bool + */ + public function checkIp(string $ip, string $endpoint, int $limit = 60, int $window = 60): bool + { + return $this->check("ip:{$endpoint}:{$ip}", $limit, $window); + } +} diff --git a/backend/includes/Redis.php b/backend/includes/Redis.php new file mode 100644 index 0000000..d14f52b --- /dev/null +++ b/backend/includes/Redis.php @@ -0,0 +1,55 @@ +connect(REDIS_HOST, REDIS_PORT, 2.0); + + if (REDIS_PASSWORD !== null) { + self::$instance->auth(REDIS_PASSWORD); + } + + if (REDIS_DB > 0) { + self::$instance->select(REDIS_DB); + } + + self::$instance->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_JSON); + } catch (\RedisException $e) { + $errorMsg = $e->getMessage(); + error_log('Redis connection failed: ' . $errorMsg); + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'cache_error', 'error' => $errorMsg]); + exit; + } catch (\Exception $e) { + $errorMsg = $e->getMessage(); + error_log('Unexpected error in Redis: ' . $errorMsg); + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'cache_error', 'error' => $errorMsg]); + exit; + } + } + + public static function getInstance(): \Redis + { + if (self::$instance === null) { + new self(); + } + return self::$instance; + } + + private function __clone() {} + public function __wakeup() + { + throw new \Exception("Cannot unserialize singleton"); + } +} diff --git a/backend/logs/.gitkeep b/backend/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/nginx.conf b/backend/nginx.conf new file mode 100644 index 0000000..9e2cb62 --- /dev/null +++ b/backend/nginx.conf @@ -0,0 +1,95 @@ +server { + listen 80; + server_name otp.intaleqapp.com; + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + server_name otp.intaleqapp.com; + + # SSL — Let's Encrypt + ssl_certificate /etc/letsencrypt/live/otp.intaleqapp.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/otp.intaleqapp.com/privkey.pem; + + # SSL Hardening + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + # HSTS + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + + # Document root + root /var/www/otp.intaleqapp.com/public; + index index.php; + + # Logging + access_log /var/log/nginx/otp.intaleqapp.com.access.log; + error_log /var/log/nginx/otp.intaleqapp.com.error.log; + + # API endpoints — route to PHP files + location /api/ { + try_files $uri $uri/ /api$uri.php?$query_string; + + # PHP handling + location ~ \.php$ { + include fastcgi_params; + fastcgi_pass unix:/var/run/php/php8.1-fpm.sock; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_read_timeout 30; + } + } + + # Block direct access to includes/ + location /includes/ { + deny all; + return 404; + } + + # Block access to config + location /config.php { + deny all; + return 404; + } + + # Block access to hidden files + location ~ /\. { + deny all; + return 404; + } + + # Block access to SQL files + location ~ \.sql$ { + deny all; + return 404; + } + + # Block access to log files + location ~ \.log$ { + deny all; + return 404; + } + + # Health check endpoint + location /health { + access_log off; + return 200 '{"status":"ok"}'; + add_header Content-Type application/json; + } + + # Default PHP handling + location ~ \.php$ { + include fastcgi_params; + fastcgi_pass unix:/var/run/php/php8.1-fpm.sock; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + } + + # Deny everything else + location / { + return 404; + } +} diff --git a/caller-app/app/build.gradle b/caller-app/app/build.gradle new file mode 100644 index 0000000..27fe356 --- /dev/null +++ b/caller-app/app/build.gradle @@ -0,0 +1,48 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +android { + namespace 'com.intaleq.flashcall' + compileSdk 35 + + defaultConfig { + applicationId "com.intaleq.flashcall" + minSdk 26 + targetSdk 35 + versionCode 1 + versionName "1.0" + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } +} + +dependencies { + implementation 'androidx.core:core-ktx:1.15.0' + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'com.google.android.material:material:1.12.0' + implementation 'androidx.work:work-runtime-ktx:2.10.0' + implementation 'com.squareup.retrofit2:retrofit:2.11.0' + implementation 'com.squareup.retrofit2:converter-gson:2.11.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0' + implementation 'androidx.lifecycle:lifecycle-service:2.8.7' + implementation 'com.google.code.gson:gson:2.11.0' + + // The Kotlin plugin automatically adds the correct stdlib version. + // Invalid version 2.3.10 was removed. +} diff --git a/caller-app/app/gradle.properties b/caller-app/app/gradle.properties new file mode 100644 index 0000000..2d8d1e4 --- /dev/null +++ b/caller-app/app/gradle.properties @@ -0,0 +1 @@ +android.useAndroidX=true \ No newline at end of file diff --git a/caller-app/app/proguard-rules.pro b/caller-app/app/proguard-rules.pro new file mode 100644 index 0000000..4abb4cd --- /dev/null +++ b/caller-app/app/proguard-rules.pro @@ -0,0 +1,34 @@ +# Retrofit +-keepattributes Signature +-keepattributes *Annotation* +-keep class retrofit2.** { *; } +-keepclassmembers,allowshrinking,allowobfuscation interface * { + @retrofit2.http.* ; +} + +# Gson +-keepattributes Signature +-keepattributes *Annotation* +-keep class com.google.gson.** { *; } +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# Keep data classes for Gson serialization +-keep class com.intaleq.flashcall.PendingTask { *; } +-keep class com.intaleq.flashcall.CallDoneRequest { *; } +-keep class com.intaleq.flashcall.RegisterRequest { *; } +-keep class com.intaleq.flashcall.ApiResponse { *; } + +# OkHttp +-dontwarn okhttp3.** +-dontwarn okio.** +-keep class okhttp3.** { *; } +-keep interface okhttp3.** { *; } + +# Kotlin Coroutines +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} +-keepclassmembers class kotlinx.coroutines.** { + volatile ; +} diff --git a/caller-app/app/src/main/AndroidManifest.xml b/caller-app/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..69cd570 --- /dev/null +++ b/caller-app/app/src/main/AndroidManifest.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/caller-app/app/src/main/ic_launcher-playstore.png b/caller-app/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..e6adf92 Binary files /dev/null and b/caller-app/app/src/main/ic_launcher-playstore.png differ diff --git a/caller-app/app/src/main/java/com/intaleq/flashcall/ApiService.kt b/caller-app/app/src/main/java/com/intaleq/flashcall/ApiService.kt new file mode 100644 index 0000000..df4295b --- /dev/null +++ b/caller-app/app/src/main/java/com/intaleq/flashcall/ApiService.kt @@ -0,0 +1,75 @@ +package com.intaleq.flashcall + +import com.google.gson.annotations.SerializedName +import retrofit2.http.* + +// --- Data Models --- + +data class PendingTask( + @SerializedName("task_id") val taskId: Int?, + @SerializedName("phone") val phone: String?, + @SerializedName("caller_id") val callerId: String?, + @SerializedName("otp") val otp: String?, + @SerializedName("timeout_seconds") val timeoutSeconds: Int? +) + +data class CallDoneRequest( + @SerializedName("task_id") val taskId: Int, + @SerializedName("device_id") val deviceId: String, + @SerializedName("app_key") val appKey: String, + @SerializedName("result") val result: String +) + +data class RegisterRequest( + @SerializedName("device_id") val deviceId: String, + @SerializedName("phone_number") val phoneNumber: String, + @SerializedName("sim_slot") val simSlot: Int, + @SerializedName("app_key") val appKey: String +) + +data class ApiResponse( + @SerializedName("success") val success: Boolean, + @SerializedName("message") val message: String?, + @SerializedName("device_id") val deviceId: String? +) + +// --- API Interface --- + +interface ApiService { + + /** + * Polling for a new flash call task + */ + @GET("pending-call.php") + suspend fun pendingCall( + @Query("device_id") deviceId: String, + @Query("app_key") appKey: String + ): PendingTask + + /** + * Polling for a new SMS task + */ + @GET("pending-sms.php") + suspend fun pendingSms( + @Query("device_id") deviceId: String, + @Query("app_key") appKey: String + ): PendingTask + + /** + * Reporting the result of a flash call + */ + @POST("call-done.php") + suspend fun callDone(@Body request: CallDoneRequest): ApiResponse + + /** + * Reporting the result of an SMS + */ + @POST("sms-done.php") + suspend fun smsDone(@Body request: CallDoneRequest): ApiResponse + + /** + * Initial registration of the device + */ + @POST("register-device.php") + suspend fun registerDevice(@Body request: RegisterRequest): ApiResponse +} \ No newline at end of file diff --git a/caller-app/app/src/main/java/com/intaleq/flashcall/BootReceiver.kt b/caller-app/app/src/main/java/com/intaleq/flashcall/BootReceiver.kt new file mode 100644 index 0000000..10b19c2 --- /dev/null +++ b/caller-app/app/src/main/java/com/intaleq/flashcall/BootReceiver.kt @@ -0,0 +1,26 @@ +package com.intaleq.flashcall + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build + +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED || + intent.action == "android.intent.action.QUICKBOOT_POWERON") { + + val prefs = context.getSharedPreferences("flash_call_prefs", Context.MODE_PRIVATE) + val isRegistered = prefs.getBoolean("is_registered", false) + + if (isRegistered) { + val serviceIntent = Intent(context, CallerService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(serviceIntent) + } else { + context.startService(serviceIntent) + } + } + } + } +} diff --git a/caller-app/app/src/main/java/com/intaleq/flashcall/CallerService.kt b/caller-app/app/src/main/java/com/intaleq/flashcall/CallerService.kt new file mode 100644 index 0000000..e6061b5 --- /dev/null +++ b/caller-app/app/src/main/java/com/intaleq/flashcall/CallerService.kt @@ -0,0 +1,327 @@ +package com.intaleq.flashcall + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.content.pm.ServiceInfo +import android.telephony.SmsManager +import androidx.core.app.NotificationCompat +import kotlinx.coroutines.* +import java.text.SimpleDateFormat +import java.util.* + +class CallerService : Service() { + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private lateinit var flashCallManager: FlashCallManager + private var callPollerJob: Job? = null + private var smsPollerJob: Job? = null + + companion object { + var isRunning = false + private set + + // Callback for MainActivity to receive log messages + var logListener: ((String) -> Unit)? = null + + private const val CHANNEL_ID = "flash_call_service_channel" + private const val NOTIFICATION_ID = 1001 + } + + private val notificationManager by lazy { + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + override fun onCreate() { + super.onCreate() + isRunning = true + flashCallManager = FlashCallManager(this) + createNotificationChannel() +// startForeground(NOTIFICATION_ID, buildNotification("Flash OTP Caller — Active")) + // ... inside onCreate() ... + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground( + NOTIFICATION_ID, + buildNotification("Flash OTP Caller — Active"), + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + ) + } else { + startForeground(NOTIFICATION_ID, buildNotification("Flash OTP Caller — Active")) + } + + // Register SMS hardware status receiver + val filter = android.content.IntentFilter("SMS_SENT") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(smsReceiver, filter, Context.RECEIVER_EXPORTED) + } else { + registerReceiver(smsReceiver, filter) + } + + startPolling() + addLog("Service started") + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return START_STICKY + } + + override fun onDestroy() { + super.onDestroy() + callPollerJob?.cancel() + smsPollerJob?.cancel() + serviceScope.cancel() + try { + unregisterReceiver(smsReceiver) + } catch (e: Exception) { + // Ignore if not registered + } + isRunning = false + addLog("Service stopped") + } + + private fun startPolling() { + callPollerJob = serviceScope.launch { + while (isActive) { + try { + pollCallTask() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + addLog("Call poll error: ${e.message}") + } + delay(3000) + } + } + + smsPollerJob = serviceScope.launch { + while (isActive) { + try { + pollSmsTask() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + addLog("SMS poll error: ${e.message}") + } + delay(3000) + } + } + } + + private suspend fun pollCallTask() { + val prefs = getSharedPreferences("flash_call_prefs", Context.MODE_PRIVATE) + val deviceId = prefs.getString("device_id", null) ?: return + val appKey = prefs.getString("app_key", null) ?: return + + RetrofitClient.setAppKey(appKey) + val api = RetrofitClient.apiService + + val task = try { + val result = api.pendingCall(deviceId, appKey) + addLog("Call poll OK: taskId=${result.taskId}") + result + } catch (e: retrofit2.HttpException) { + addLog("Call poll HTTP ${e.code()}: ${e.message()}") + return + } catch (e: Exception) { + addLog("Call poll net error: ${e.javaClass.simpleName}: ${e.message}") + return + } + + val taskId = task.taskId ?: return + val phone = task.phone ?: return + + addLog("Call task #$taskId → $phone") + + val result = flashCallManager.makeFlashCall(phone) + addLog("Call #$taskId result: $result") + + // Report result back to server + try { + api.callDone( + CallDoneRequest( + taskId = taskId, + deviceId = deviceId, + appKey = appKey, + result = result + ) + ) + addLog("Call #$taskId reported: $result") + } catch (e: Exception) { + addLog("Call #$taskId report failed: ${e.message}") + } + + updateNotification("Last call: #$taskId → $result") + } + + private suspend fun pollSmsTask() { + val prefs = getSharedPreferences("flash_call_prefs", Context.MODE_PRIVATE) + val deviceId = prefs.getString("device_id", null) ?: return + val appKey = prefs.getString("app_key", null) ?: return + + RetrofitClient.setAppKey(appKey) + val api = RetrofitClient.apiService + + val task = try { + val result = api.pendingSms(deviceId, appKey) + addLog("SMS poll OK: taskId=${result.taskId}") + result + } catch (e: retrofit2.HttpException) { + addLog("SMS poll HTTP ${e.code()}: ${e.message()}") + return + } catch (e: Exception) { + addLog("SMS poll net error: ${e.javaClass.simpleName}: ${e.message}") + return + } + + val taskId = task.taskId ?: return + val phone = task.phone ?: return + val otp = task.otp ?: return + + addLog("SMS task #$taskId → $phone (OTP: $otp)") + + val result = sendSms(phone, otp) + addLog("SMS #$taskId result: $result") + + // Report result back to server + try { + api.smsDone( + CallDoneRequest( + taskId = taskId, + deviceId = deviceId, + appKey = appKey, + result = result + ) + ) + addLog("SMS #$taskId reported: $result") + } catch (e: Exception) { + addLog("SMS #$taskId report failed: ${e.message}") + } + + updateNotification("Last SMS: #$taskId → $result") + } + + private fun sendSms(phone: String, otp: String): String { + return try { + val message = "رمز التحقق في تطبيق انطلق هو: $otp" + + // Format phone to local Jordan format if needed (+962 -> 0) + val formattedPhone = if (phone.startsWith("+962")) { + val suffix = phone.substring(4) + if (suffix.startsWith("0")) suffix else "0$suffix" + } else { + phone + } + + addLog("SMS sending to $formattedPhone...") + + // Create a PendingIntent to track if SMS was sent (Explicit intent for Android 14+) + val sentIntent = android.app.PendingIntent.getBroadcast( + this, 0, + Intent("SMS_SENT").apply { setPackage(packageName) }, + android.app.PendingIntent.FLAG_IMMUTABLE + ) + + val smsManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + getSystemService(SmsManager::class.java) + } else { + @Suppress("DEPRECATION") + SmsManager.getDefault() + } + + smsManager.sendTextMessage(formattedPhone, null, message, sentIntent, null) + addLog("SMS handed to OS for $formattedPhone") + "success" + } catch (e: SecurityException) { + addLog("SMS SecurityException: ${e.message}") + "failed" + } catch (e: Exception) { + addLog("SMS Exception: ${e.javaClass.simpleName}: ${e.message}") + "failed" + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Flash OTP Caller Service", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Keeps the flash call service running" + setShowBadge(false) + } + notificationManager.createNotificationChannel(channel) + } + } + + private fun buildNotification(text: String): Notification { + val intent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Flash OTP Caller") + .setContentText(text) + .setSmallIcon(android.R.drawable.ic_menu_call) + .setOngoing(true) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + private fun updateNotification(text: String) { + try { + val notification = buildNotification(text) + notificationManager.notify(NOTIFICATION_ID, notification) + } catch (e: Exception) { + // Ignore notification update errors + } + } + + private fun addLog(message: String) { + val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) + val logLine = "[$timestamp] $message" + + // Also log to system Logcat for Android Studio visibility + android.util.Log.d("CallerService", logLine) + + // Save to SharedPreferences for MainActivity to read + val prefs = getSharedPreferences("flash_call_prefs", Context.MODE_PRIVATE) + val existingLogs = prefs.getString("service_logs", "") ?: "" + val logs = (existingLogs.split("\n") + logLine).takeLast(20) + prefs.edit().putString("service_logs", logs.joinToString("\n")).apply() + + // Notify listener if attached + logListener?.invoke(logLine) + } + + private val smsReceiver = object : android.content.BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == "SMS_SENT") { + val result = when (resultCode) { + android.app.Activity.RESULT_OK -> "SUCCESS (Hardware confirmed)" + android.telephony.SmsManager.RESULT_ERROR_GENERIC_FAILURE -> "FAILED (Generic/No Credit)" + android.telephony.SmsManager.RESULT_ERROR_NO_SERVICE -> "FAILED (No Cell Service)" + android.telephony.SmsManager.RESULT_ERROR_NULL_PDU -> "FAILED (Null PDU)" + android.telephony.SmsManager.RESULT_ERROR_RADIO_OFF -> "FAILED (Radio Off/Airplane Mode)" + else -> "FAILED (Unknown code: $resultCode)" + } + addLog("SMS Hardware Result: $result") + } + } + } +} \ No newline at end of file diff --git a/caller-app/app/src/main/java/com/intaleq/flashcall/FlashCallManager.kt b/caller-app/app/src/main/java/com/intaleq/flashcall/FlashCallManager.kt new file mode 100644 index 0000000..63242e6 --- /dev/null +++ b/caller-app/app/src/main/java/com/intaleq/flashcall/FlashCallManager.kt @@ -0,0 +1,78 @@ +package com.intaleq.flashcall + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.telecom.TelecomManager +import android.telephony.TelephonyManager +import kotlinx.coroutines.delay +import java.lang.reflect.Method + +class FlashCallManager(private val context: Context) { + + /** + * Make a flash call: dial the number, wait 2500ms, then hang up. + * Returns result: "success", "failed", "busy", "no_answer" + */ + suspend fun makeFlashCall(phone: String): String { + return try { + // Place the call + android.util.Log.d("CallerService", "Initiating flash call to $phone") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager + telecomManager.placeCall(Uri.parse("tel:$phone"), null) + } else { + val callIntent = Intent(Intent.ACTION_CALL, Uri.parse("tel:$phone")).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(callIntent) + } + + // Wait 1000ms — enough for 1 quick ring + delay(1000) + + // Hang up the call + val hungUp = endCall() + if (hungUp) "success" else "failed" + } catch (e: SecurityException) { + "failed" + } catch (e: Exception) { + "failed" + } + } + + /** + * End the current active call. + * Primary: TelecomManager (Android 9+) + * Fallback: Reflection on TelephonyManager.endCall() (Android 8) + */ + @SuppressLint("NewApi") + fun endCall(): Boolean { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + // Android 9+: Use TelecomManager + val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager + telecomManager.endCall() + } else { + // Android 8: Use reflection on TelephonyManager + endCallReflection() + } + } catch (e: Exception) { + // Try reflection as fallback + endCallReflection() + } + } + + @Suppress("PrivateApi") + private fun endCallReflection(): Boolean { + return try { + val telephonyManager = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + val method: Method = TelephonyManager::class.java.getDeclaredMethod("endCall") + method.invoke(telephonyManager) as? Boolean ?: false + } catch (e: Exception) { + false + } + } +} diff --git a/caller-app/app/src/main/java/com/intaleq/flashcall/MainActivity.kt b/caller-app/app/src/main/java/com/intaleq/flashcall/MainActivity.kt new file mode 100644 index 0000000..993940f --- /dev/null +++ b/caller-app/app/src/main/java/com/intaleq/flashcall/MainActivity.kt @@ -0,0 +1,316 @@ +package com.intaleq.flashcall + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.View +import android.widget.* +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.util.* + +class MainActivity : AppCompatActivity() { + + private lateinit var etDeviceId: EditText + private lateinit var etPhoneNumber: EditText + private lateinit var etAppKey: EditText + private lateinit var spinnerSimSlot: Spinner + private lateinit var btnRegister: Button + private lateinit var tvStatus: TextView + private lateinit var btnToggleService: Button + private lateinit var tvLog: TextView + private lateinit var logScrollView: ScrollView + private lateinit var progressBar: ProgressBar + + private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val prefs by lazy { getSharedPreferences("flash_call_prefs", Context.MODE_PRIVATE) } + + private val simSlotOptions = arrayOf("SIM 0", "SIM 1") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + initViews() + loadSavedData() + setupSpinner() + setupClickListeners() + requestPermissionsIfNeeded() + updateServiceStatus() + loadServiceLogs() + } + + override fun onResume() { + super.onResume() + updateServiceStatus() + loadServiceLogs() + + // Attach log listener + CallerService.logListener = { logLine -> + runOnUiThread { + appendLogLine(logLine) + } + } + } + + override fun onPause() { + super.onPause() + CallerService.logListener = null + } + + private fun initViews() { + etDeviceId = findViewById(R.id.etDeviceId) + etPhoneNumber = findViewById(R.id.etPhoneNumber) + etAppKey = findViewById(R.id.etAppKey) + spinnerSimSlot = findViewById(R.id.spinnerSimSlot) + btnRegister = findViewById(R.id.btnRegister) + tvStatus = findViewById(R.id.tvStatus) + btnToggleService = findViewById(R.id.btnToggleService) + tvLog = findViewById(R.id.tvLog) + logScrollView = findViewById(R.id.logScrollView) + progressBar = findViewById(R.id.progressBar) + } + + private fun loadSavedData() { + // Device ID — generate once and persist + var deviceId = prefs.getString("device_id", null) + if (deviceId == null) { + deviceId = UUID.randomUUID().toString() + prefs.edit().putString("device_id", deviceId).apply() + } + etDeviceId.setText(deviceId) + etDeviceId.isEnabled = false + etDeviceId.isFocusable = false + + // Phone number + val savedPhone = prefs.getString("phone_number", null) + if (savedPhone != null) { + etPhoneNumber.setText(savedPhone) + } + + // App key + val savedAppKey = prefs.getString("app_key", null) + if (savedAppKey != null) { + etAppKey.setText(savedAppKey) + } + + // SIM slot + val savedSimSlot = prefs.getInt("sim_slot", 0) + spinnerSimSlot.setSelection(savedSimSlot) + } + + private fun setupSpinner() { + val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, simSlotOptions) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + spinnerSimSlot.adapter = adapter + } + + private fun setupClickListeners() { + btnRegister.setOnClickListener { + registerDevice() + } + + btnToggleService.setOnClickListener { + if (CallerService.isRunning) { + stopCallerService() + } else { + startCallerService() + } + } + } + + private fun requestPermissionsIfNeeded() { + if (!PermissionHelper.areAllPermissionsGranted(this)) { + PermissionHelper.requestAllPermissions(this) + } + + // Request battery optimization exemption on first launch + val hasRequestedBattery = prefs.getBoolean("requested_battery_opt", false) + if (!hasRequestedBattery) { + PermissionHelper.requestBatteryOptimizationExemption(this) + prefs.edit().putBoolean("requested_battery_opt", true).apply() + } + } + + private fun registerDevice() { + val phoneNumber = etPhoneNumber.text.toString().trim() + val appKey = etAppKey.text.toString().trim() + val deviceId = etDeviceId.text.toString().trim() + val simSlot = spinnerSimSlot.selectedItemPosition + + if (phoneNumber.isEmpty()) { + etPhoneNumber.error = "Phone number is required" + return + } + + if (appKey.isEmpty()) { + etAppKey.error = "App key is required" + return + } + + if (!PermissionHelper.areAllPermissionsGranted(this)) { + Toast.makeText(this, "Please grant all permissions first", Toast.LENGTH_LONG).show() + PermissionHelper.requestAllPermissions(this) + return + } + + btnRegister.isEnabled = false + progressBar.visibility = View.VISIBLE + + mainScope.launch { + try { + RetrofitClient.setAppKey(appKey) + val response = RetrofitClient.apiService.registerDevice( + RegisterRequest( + deviceId = deviceId, + phoneNumber = phoneNumber, + simSlot = simSlot, + appKey = appKey + ) + ) + + if (response.success) { + // Save registration data + prefs.edit() + .putBoolean("is_registered", true) + .putString("phone_number", phoneNumber) + .putString("app_key", appKey) + .putInt("sim_slot", simSlot) + .apply() + + Toast.makeText(this@MainActivity, "Device registered successfully!", Toast.LENGTH_SHORT).show() + appendLogLine("Device registered: $deviceId") + } else { + val message = response.message ?: "Registration failed" + Toast.makeText(this@MainActivity, message, Toast.LENGTH_LONG).show() + appendLogLine("Registration failed: $message") + } + } catch (e: Exception) { + Toast.makeText(this@MainActivity, "Error: ${e.message}", Toast.LENGTH_LONG).show() + appendLogLine("Registration error: ${e.message}") + } finally { + btnRegister.isEnabled = true + progressBar.visibility = View.GONE + } + } + } + + private fun startCallerService() { + if (!PermissionHelper.areAllPermissionsGranted(this)) { + Toast.makeText(this, "Please grant all permissions first", Toast.LENGTH_LONG).show() + PermissionHelper.requestAllPermissions(this) + return + } + + val isRegistered = prefs.getBoolean("is_registered", false) + if (!isRegistered) { + Toast.makeText(this, "Please register the device first", Toast.LENGTH_LONG).show() + return + } + + val serviceIntent = Intent(this, CallerService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(serviceIntent) + } else { + startService(serviceIntent) + } + + // Update UI immediately (optimistic) + tvStatus.text = "Service Starting... ⏳" + tvStatus.setTextColor(getColor(android.R.color.holo_orange_dark)) + btnToggleService.text = "Stop Service" + + appendLogLine("Service start requested") + } + + private fun stopCallerService() { + val serviceIntent = Intent(this, CallerService::class.java) + stopService(serviceIntent) + + // Update UI immediately (optimistic) + tvStatus.text = "Service Stopping... ⏳" + tvStatus.setTextColor(getColor(android.R.color.holo_orange_dark)) + btnToggleService.text = "Start Service" + + appendLogLine("Service stop requested") + } + + private fun updateServiceStatus() { + if (CallerService.isRunning) { + tvStatus.text = "Service Running ✅" + tvStatus.setTextColor(getColor(android.R.color.holo_green_dark)) + btnToggleService.text = "Stop Service" + } else { + tvStatus.text = "Service Stopped ❌" + tvStatus.setTextColor(getColor(android.R.color.holo_red_dark)) + btnToggleService.text = "Start Service" + } + } + + private fun loadServiceLogs() { + val logs = prefs.getString("service_logs", "") ?: "" + tvLog.text = logs + logScrollView.post { + logScrollView.fullScroll(ScrollView.FOCUS_DOWN) + } + } + + private fun appendLogLine(line: String) { + val current = tvLog.text.toString() + val lines = (current.split("\n") + line).takeLast(20) + tvLog.text = lines.joinToString("\n") + logScrollView.post { + logScrollView.fullScroll(ScrollView.FOCUS_DOWN) + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + + if (requestCode == PermissionHelper.REQUEST_CODE_PERMISSIONS) { + val allGranted = grantResults.all { it == android.content.pm.PackageManager.PERMISSION_GRANTED } + if (!allGranted) { + Toast.makeText( + this, + "Some permissions were denied. The app may not function correctly.", + Toast.LENGTH_LONG + ).show() + + // Show which permissions are missing + val denied = permissions.filterIndexed { index, _ -> + grantResults[index] != android.content.pm.PackageManager.PERMISSION_GRANTED + } + if (denied.isNotEmpty()) { + AlertDialog.Builder(this) + .setTitle("Permissions Required") + .setMessage("The following permissions are required for the app to work:\n\n" + + denied.joinToString("\n") { perm -> + when (perm) { + android.Manifest.permission.CALL_PHONE -> "• Phone - to make flash calls" + android.Manifest.permission.READ_PHONE_STATE -> "• Phone State - to read call status" + android.Manifest.permission.SEND_SMS -> "• SMS - to send OTP messages" + else -> "• $perm" + } + } + + "\n\nPlease grant them in Settings.") + .setPositiveButton("Open Settings") { _, _ -> + val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.data = android.net.Uri.fromParts("package", packageName, null) + startActivity(intent) + } + .setNegativeButton("Cancel", null) + .show() + } + } + } + } +} diff --git a/caller-app/app/src/main/java/com/intaleq/flashcall/PermissionHelper.kt b/caller-app/app/src/main/java/com/intaleq/flashcall/PermissionHelper.kt new file mode 100644 index 0000000..2ec7cc0 --- /dev/null +++ b/caller-app/app/src/main/java/com/intaleq/flashcall/PermissionHelper.kt @@ -0,0 +1,120 @@ +package com.intaleq.flashcall + +import android.Manifest +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat + +object PermissionHelper { + + private val REQUIRED_PERMISSIONS = arrayOf( + Manifest.permission.CALL_PHONE, + Manifest.permission.READ_PHONE_STATE, + Manifest.permission.SEND_SMS + ) + + fun areAllPermissionsGranted(context: Context): Boolean { + return REQUIRED_PERMISSIONS.all { permission -> + ContextCompat.checkSelfPermission(context, permission) == + android.content.pm.PackageManager.PERMISSION_GRANTED + } + } + + fun requestAllPermissions(activity: Activity) { + val permissionsToRequest = REQUIRED_PERMISSIONS.filter { permission -> + ContextCompat.checkSelfPermission(activity, permission) != + android.content.pm.PackageManager.PERMISSION_GRANTED + } + + if (permissionsToRequest.isNotEmpty()) { + // Show rationale for any permission that was previously denied + val shouldShowRationale = permissionsToRequest.any { permission -> + ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) + } + + if (shouldShowRationale) { + // In a production app you'd show a dialog here explaining why + // For now, just request the permissions directly + ActivityCompat.requestPermissions( + activity, + permissionsToRequest.toTypedArray(), + REQUEST_CODE_PERMISSIONS + ) + } else { + ActivityCompat.requestPermissions( + activity, + permissionsToRequest.toTypedArray(), + REQUEST_CODE_PERMISSIONS + ) + } + } + + // Also request battery optimization exemption + requestBatteryOptimizationExemption(activity) + + // Request SYSTEM_ALERT_WINDOW (Draw over other apps) for Android 10+ background activity starts + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(activity)) { + try { + val intent = Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:${activity.packageName}") + ) + activity.startActivity(intent) + } catch (e: Exception) { + // Ignore if not available + } + } + } + + fun requestBatteryOptimizationExemption(activity: Activity) { + val powerManager = activity.getSystemService(Context.POWER_SERVICE) as PowerManager + val packageName = activity.packageName + + if (!powerManager.isIgnoringBatteryOptimizations(packageName)) { + try { + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:$packageName") + } + activity.startActivity(intent) + } catch (e: Exception) { + // Fallback: open battery optimization settings + try { + val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) + activity.startActivity(intent) + } catch (e2: Exception) { + // Cannot open settings, ignore + } + } + } + } + + fun shouldShowCallPhoneRationale(activity: Activity): Boolean { + return ActivityCompat.shouldShowRequestPermissionRationale( + activity, + Manifest.permission.CALL_PHONE + ) + } + + fun shouldShowReadPhoneStateRationale(activity: Activity): Boolean { + return ActivityCompat.shouldShowRequestPermissionRationale( + activity, + Manifest.permission.READ_PHONE_STATE + ) + } + + fun shouldShowSendSmsRationale(activity: Activity): Boolean { + return ActivityCompat.shouldShowRequestPermissionRationale( + activity, + Manifest.permission.SEND_SMS + ) + } + + const val REQUEST_CODE_PERMISSIONS = 1001 +} diff --git a/caller-app/app/src/main/java/com/intaleq/flashcall/RetrofitClient.kt b/caller-app/app/src/main/java/com/intaleq/flashcall/RetrofitClient.kt new file mode 100644 index 0000000..54c2443 --- /dev/null +++ b/caller-app/app/src/main/java/com/intaleq/flashcall/RetrofitClient.kt @@ -0,0 +1,44 @@ +package com.intaleq.flashcall + +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +object RetrofitClient { + + private const val BASE_URL = "https://otp.intaleqapp.com/api/" + + private var appKey: String = "" + + fun setAppKey(key: String) { + appKey = key + } + + private val authInterceptor = Interceptor { chain -> + val original = chain.request() + val request = original.newBuilder() + .header("X-App-Key", appKey) + .build() + chain.proceed(request) + } + + private val okHttpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .addInterceptor(authInterceptor) + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .build() + } + + val apiService: ApiService by lazy { + Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(ApiService::class.java) + } +} diff --git a/caller-app/app/src/main/res/drawable/ic_launcher_background.xml b/caller-app/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..ca3826a --- /dev/null +++ b/caller-app/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/caller-app/app/src/main/res/drawable/ic_launcher_foreground.xml b/caller-app/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..da1dcd9 --- /dev/null +++ b/caller-app/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/caller-app/app/src/main/res/layout/activity_main.xml b/caller-app/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..c20f688 --- /dev/null +++ b/caller-app/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +