From 2bbaa1ee16b581dd5495fe6063496c9c95967142 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sat, 23 May 2026 16:17:20 +0300 Subject: [PATCH] first commit --- .gitignore | 32 + README.md | 1 + backend/.htaccess | 27 + backend/api/call-done.php | 121 +++ backend/api/pending-call.php | 124 +++ backend/api/pending-sms.php | 121 +++ backend/api/register-device.php | 120 +++ backend/api/request-otp.php | 190 +++++ backend/api/sms-done.php | 120 +++ backend/api/verify-otp.php | 154 ++++ backend/config.php | 43 + backend/database.sql | 110 +++ backend/includes/Auth.php | 96 +++ backend/includes/Database.php | 49 ++ backend/includes/Logger.php | 61 ++ backend/includes/RateLimit.php | 62 ++ backend/includes/Redis.php | 55 ++ backend/logs/.gitkeep | 0 backend/nginx.conf | 95 +++ caller-app/app/build.gradle | 48 ++ caller-app/app/gradle.properties | 1 + caller-app/app/proguard-rules.pro | 34 + caller-app/app/src/main/AndroidManifest.xml | 55 ++ .../app/src/main/ic_launcher-playstore.png | Bin 0 -> 40466 bytes .../java/com/intaleq/flashcall/ApiService.kt | 75 ++ .../com/intaleq/flashcall/BootReceiver.kt | 26 + .../com/intaleq/flashcall/CallerService.kt | 327 ++++++++ .../com/intaleq/flashcall/FlashCallManager.kt | 78 ++ .../com/intaleq/flashcall/MainActivity.kt | 316 ++++++++ .../com/intaleq/flashcall/PermissionHelper.kt | 120 +++ .../com/intaleq/flashcall/RetrofitClient.kt | 44 + .../res/drawable/ic_launcher_background.xml | 74 ++ .../res/drawable/ic_launcher_foreground.xml | 31 + .../app/src/main/res/layout/activity_main.xml | 220 +++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 2574 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 4354 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 2078 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 2938 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 3822 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 6314 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 5184 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 9184 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 7260 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 12254 bytes caller-app/app/src/main/res/values/colors.xml | 18 + .../app/src/main/res/values/strings.xml | 23 + caller-app/build.gradle | 15 + caller-app/gradle.properties | 10 + .../gradle/wrapper/gradle-wrapper.properties | 5 + caller-app/settings.gradle | 18 + deploy.sh | 36 + docs/PERMISSIONS.md | 289 +++++++ docs/SETUP.md | 497 ++++++++++++ docs/SMART_OTP_GATEWAY.md | 258 ++++++ receiver_app_new/.gitignore | 45 ++ receiver_app_new/.metadata | 45 ++ receiver_app_new/README.md | 17 + receiver_app_new/analysis_options.yaml | 28 + receiver_app_new/android/.gitignore | 14 + receiver_app_new/android/app/build.gradle.kts | 49 ++ .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 45 ++ .../com/intaleq/receiver/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + receiver_app_new/android/build.gradle.kts | 24 + receiver_app_new/android/gradle.properties | 2 + .../gradle/wrapper/gradle-wrapper.properties | 5 + receiver_app_new/android/settings.gradle.kts | 26 + receiver_app_new/ios/.gitignore | 34 + .../ios/Flutter/AppFrameworkInfo.plist | 24 + receiver_app_new/ios/Flutter/Debug.xcconfig | 2 + receiver_app_new/ios/Flutter/Release.xcconfig | 2 + receiver_app_new/ios/Podfile | 43 + receiver_app_new/ios/Podfile.lock | 29 + .../ios/Runner.xcodeproj/project.pbxproj | 753 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 101 +++ .../contents.xcworkspacedata | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + receiver_app_new/ios/Runner/AppDelegate.swift | 16 + .../AppIcon.appiconset/Contents.json | 122 +++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 450 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 462 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 704 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 586 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 1674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 1226 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 1418 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + .../Runner/Base.lproj/LaunchScreen.storyboard | 37 + .../ios/Runner/Base.lproj/Main.storyboard | 26 + receiver_app_new/ios/Runner/Info.plist | 70 ++ .../ios/Runner/Runner-Bridging-Header.h | 1 + .../ios/Runner/SceneDelegate.swift | 6 + .../ios/RunnerTests/RunnerTests.swift | 12 + receiver_app_new/lib/main.dart | 24 + .../lib/screens/otp_wait_screen.dart | 689 ++++++++++++++++ .../lib/screens/phone_input_screen.dart | 452 +++++++++++ .../lib/screens/success_screen.dart | 171 ++++ .../lib/services/api_service.dart | 104 +++ .../lib/services/otp_controller.dart | 100 +++ receiver_app_new/linux/.gitignore | 1 + receiver_app_new/linux/CMakeLists.txt | 128 +++ receiver_app_new/linux/flutter/CMakeLists.txt | 88 ++ .../flutter/generated_plugin_registrant.cc | 11 + .../flutter/generated_plugin_registrant.h | 15 + .../linux/flutter/generated_plugins.cmake | 23 + receiver_app_new/linux/runner/CMakeLists.txt | 26 + receiver_app_new/linux/runner/main.cc | 6 + .../linux/runner/my_application.cc | 148 ++++ .../linux/runner/my_application.h | 21 + receiver_app_new/macos/.gitignore | 7 + .../macos/Flutter/Flutter-Debug.xcconfig | 2 + .../macos/Flutter/Flutter-Release.xcconfig | 2 + .../Flutter/GeneratedPluginRegistrant.swift | 12 + receiver_app_new/macos/Podfile | 42 + .../macos/Runner.xcodeproj/project.pbxproj | 705 ++++++++++++++++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 99 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../macos/Runner/AppDelegate.swift | 13 + .../AppIcon.appiconset/Contents.json | 68 ++ .../AppIcon.appiconset/app_icon_1024.png | Bin 0 -> 102994 bytes .../AppIcon.appiconset/app_icon_128.png | Bin 0 -> 5680 bytes .../AppIcon.appiconset/app_icon_16.png | Bin 0 -> 520 bytes .../AppIcon.appiconset/app_icon_256.png | Bin 0 -> 14142 bytes .../AppIcon.appiconset/app_icon_32.png | Bin 0 -> 1066 bytes .../AppIcon.appiconset/app_icon_512.png | Bin 0 -> 36406 bytes .../AppIcon.appiconset/app_icon_64.png | Bin 0 -> 2218 bytes .../macos/Runner/Base.lproj/MainMenu.xib | 343 ++++++++ .../macos/Runner/Configs/AppInfo.xcconfig | 14 + .../macos/Runner/Configs/Debug.xcconfig | 2 + .../macos/Runner/Configs/Release.xcconfig | 2 + .../macos/Runner/Configs/Warnings.xcconfig | 13 + .../macos/Runner/DebugProfile.entitlements | 12 + receiver_app_new/macos/Runner/Info.plist | 32 + .../macos/Runner/MainFlutterWindow.swift | 15 + .../macos/Runner/Release.entitlements | 8 + .../macos/RunnerTests/RunnerTests.swift | 12 + receiver_app_new/pubspec.lock | 426 ++++++++++ receiver_app_new/pubspec.yaml | 90 +++ receiver_app_new/pubspec.yaml.old | 23 + receiver_app_new/test/widget_test.dart | 31 + receiver_app_new/web/favicon.png | Bin 0 -> 917 bytes receiver_app_new/web/icons/Icon-192.png | Bin 0 -> 5292 bytes receiver_app_new/web/icons/Icon-512.png | Bin 0 -> 8252 bytes .../web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes .../web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes receiver_app_new/web/index.html | 46 ++ receiver_app_new/web/manifest.json | 35 + receiver_app_new/windows/.gitignore | 17 + receiver_app_new/windows/CMakeLists.txt | 108 +++ .../windows/flutter/CMakeLists.txt | 109 +++ .../flutter/generated_plugin_registrant.cc | 14 + .../flutter/generated_plugin_registrant.h | 15 + .../windows/flutter/generated_plugins.cmake | 24 + .../windows/runner/CMakeLists.txt | 40 + receiver_app_new/windows/runner/Runner.rc | 121 +++ .../windows/runner/flutter_window.cpp | 71 ++ .../windows/runner/flutter_window.h | 33 + receiver_app_new/windows/runner/main.cpp | 43 + receiver_app_new/windows/runner/resource.h | 16 + .../windows/runner/resources/app_icon.ico | Bin 0 -> 33772 bytes .../windows/runner/runner.exe.manifest | 14 + receiver_app_new/windows/runner/utils.cpp | 65 ++ receiver_app_new/windows/runner/utils.h | 19 + .../windows/runner/win32_window.cpp | 288 +++++++ .../windows/runner/win32_window.h | 102 +++ 195 files changed, 11126 insertions(+) create mode 100644 .gitignore create mode 100755 README.md create mode 100644 backend/.htaccess create mode 100644 backend/api/call-done.php create mode 100644 backend/api/pending-call.php create mode 100644 backend/api/pending-sms.php create mode 100644 backend/api/register-device.php create mode 100644 backend/api/request-otp.php create mode 100644 backend/api/sms-done.php create mode 100644 backend/api/verify-otp.php create mode 100644 backend/config.php create mode 100644 backend/database.sql create mode 100644 backend/includes/Auth.php create mode 100644 backend/includes/Database.php create mode 100644 backend/includes/Logger.php create mode 100644 backend/includes/RateLimit.php create mode 100644 backend/includes/Redis.php create mode 100644 backend/logs/.gitkeep create mode 100644 backend/nginx.conf create mode 100644 caller-app/app/build.gradle create mode 100644 caller-app/app/gradle.properties create mode 100644 caller-app/app/proguard-rules.pro create mode 100644 caller-app/app/src/main/AndroidManifest.xml create mode 100644 caller-app/app/src/main/ic_launcher-playstore.png create mode 100644 caller-app/app/src/main/java/com/intaleq/flashcall/ApiService.kt create mode 100644 caller-app/app/src/main/java/com/intaleq/flashcall/BootReceiver.kt create mode 100644 caller-app/app/src/main/java/com/intaleq/flashcall/CallerService.kt create mode 100644 caller-app/app/src/main/java/com/intaleq/flashcall/FlashCallManager.kt create mode 100644 caller-app/app/src/main/java/com/intaleq/flashcall/MainActivity.kt create mode 100644 caller-app/app/src/main/java/com/intaleq/flashcall/PermissionHelper.kt create mode 100644 caller-app/app/src/main/java/com/intaleq/flashcall/RetrofitClient.kt create mode 100644 caller-app/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 caller-app/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 caller-app/app/src/main/res/layout/activity_main.xml create mode 100644 caller-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 caller-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 caller-app/app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 caller-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 caller-app/app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 caller-app/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 caller-app/app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 caller-app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 caller-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 caller-app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 caller-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 caller-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 caller-app/app/src/main/res/values/colors.xml create mode 100644 caller-app/app/src/main/res/values/strings.xml create mode 100644 caller-app/build.gradle create mode 100644 caller-app/gradle.properties create mode 100644 caller-app/gradle/wrapper/gradle-wrapper.properties create mode 100644 caller-app/settings.gradle create mode 100755 deploy.sh create mode 100644 docs/PERMISSIONS.md create mode 100644 docs/SETUP.md create mode 100644 docs/SMART_OTP_GATEWAY.md create mode 100644 receiver_app_new/.gitignore create mode 100644 receiver_app_new/.metadata create mode 100644 receiver_app_new/README.md create mode 100644 receiver_app_new/analysis_options.yaml create mode 100644 receiver_app_new/android/.gitignore create mode 100644 receiver_app_new/android/app/build.gradle.kts create mode 100644 receiver_app_new/android/app/src/debug/AndroidManifest.xml create mode 100644 receiver_app_new/android/app/src/main/AndroidManifest.xml create mode 100644 receiver_app_new/android/app/src/main/kotlin/com/intaleq/receiver/MainActivity.kt create mode 100644 receiver_app_new/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 receiver_app_new/android/app/src/main/res/drawable/launch_background.xml create mode 100644 receiver_app_new/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 receiver_app_new/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 receiver_app_new/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 receiver_app_new/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 receiver_app_new/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 receiver_app_new/android/app/src/main/res/values-night/styles.xml create mode 100644 receiver_app_new/android/app/src/main/res/values/styles.xml create mode 100644 receiver_app_new/android/app/src/profile/AndroidManifest.xml create mode 100644 receiver_app_new/android/build.gradle.kts create mode 100644 receiver_app_new/android/gradle.properties create mode 100644 receiver_app_new/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 receiver_app_new/android/settings.gradle.kts create mode 100644 receiver_app_new/ios/.gitignore create mode 100644 receiver_app_new/ios/Flutter/AppFrameworkInfo.plist create mode 100644 receiver_app_new/ios/Flutter/Debug.xcconfig create mode 100644 receiver_app_new/ios/Flutter/Release.xcconfig create mode 100644 receiver_app_new/ios/Podfile create mode 100644 receiver_app_new/ios/Podfile.lock create mode 100644 receiver_app_new/ios/Runner.xcodeproj/project.pbxproj create mode 100644 receiver_app_new/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 receiver_app_new/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 receiver_app_new/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 receiver_app_new/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 receiver_app_new/ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 receiver_app_new/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 receiver_app_new/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 receiver_app_new/ios/Runner/AppDelegate.swift create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 receiver_app_new/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 receiver_app_new/ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 receiver_app_new/ios/Runner/Base.lproj/Main.storyboard create mode 100644 receiver_app_new/ios/Runner/Info.plist create mode 100644 receiver_app_new/ios/Runner/Runner-Bridging-Header.h create mode 100644 receiver_app_new/ios/Runner/SceneDelegate.swift create mode 100644 receiver_app_new/ios/RunnerTests/RunnerTests.swift create mode 100644 receiver_app_new/lib/main.dart create mode 100644 receiver_app_new/lib/screens/otp_wait_screen.dart create mode 100644 receiver_app_new/lib/screens/phone_input_screen.dart create mode 100644 receiver_app_new/lib/screens/success_screen.dart create mode 100644 receiver_app_new/lib/services/api_service.dart create mode 100644 receiver_app_new/lib/services/otp_controller.dart create mode 100644 receiver_app_new/linux/.gitignore create mode 100644 receiver_app_new/linux/CMakeLists.txt create mode 100644 receiver_app_new/linux/flutter/CMakeLists.txt create mode 100644 receiver_app_new/linux/flutter/generated_plugin_registrant.cc create mode 100644 receiver_app_new/linux/flutter/generated_plugin_registrant.h create mode 100644 receiver_app_new/linux/flutter/generated_plugins.cmake create mode 100644 receiver_app_new/linux/runner/CMakeLists.txt create mode 100644 receiver_app_new/linux/runner/main.cc create mode 100644 receiver_app_new/linux/runner/my_application.cc create mode 100644 receiver_app_new/linux/runner/my_application.h create mode 100644 receiver_app_new/macos/.gitignore create mode 100644 receiver_app_new/macos/Flutter/Flutter-Debug.xcconfig create mode 100644 receiver_app_new/macos/Flutter/Flutter-Release.xcconfig create mode 100644 receiver_app_new/macos/Flutter/GeneratedPluginRegistrant.swift create mode 100644 receiver_app_new/macos/Podfile create mode 100644 receiver_app_new/macos/Runner.xcodeproj/project.pbxproj create mode 100644 receiver_app_new/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 receiver_app_new/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 receiver_app_new/macos/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 receiver_app_new/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 receiver_app_new/macos/Runner/AppDelegate.swift create mode 100644 receiver_app_new/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 receiver_app_new/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png create mode 100644 receiver_app_new/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png create mode 100644 receiver_app_new/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png create mode 100644 receiver_app_new/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png create mode 100644 receiver_app_new/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png create mode 100644 receiver_app_new/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png create mode 100644 receiver_app_new/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png create mode 100644 receiver_app_new/macos/Runner/Base.lproj/MainMenu.xib create mode 100644 receiver_app_new/macos/Runner/Configs/AppInfo.xcconfig create mode 100644 receiver_app_new/macos/Runner/Configs/Debug.xcconfig create mode 100644 receiver_app_new/macos/Runner/Configs/Release.xcconfig create mode 100644 receiver_app_new/macos/Runner/Configs/Warnings.xcconfig create mode 100644 receiver_app_new/macos/Runner/DebugProfile.entitlements create mode 100644 receiver_app_new/macos/Runner/Info.plist create mode 100644 receiver_app_new/macos/Runner/MainFlutterWindow.swift create mode 100644 receiver_app_new/macos/Runner/Release.entitlements create mode 100644 receiver_app_new/macos/RunnerTests/RunnerTests.swift create mode 100644 receiver_app_new/pubspec.lock create mode 100644 receiver_app_new/pubspec.yaml create mode 100644 receiver_app_new/pubspec.yaml.old create mode 100644 receiver_app_new/test/widget_test.dart create mode 100644 receiver_app_new/web/favicon.png create mode 100644 receiver_app_new/web/icons/Icon-192.png create mode 100644 receiver_app_new/web/icons/Icon-512.png create mode 100644 receiver_app_new/web/icons/Icon-maskable-192.png create mode 100644 receiver_app_new/web/icons/Icon-maskable-512.png create mode 100644 receiver_app_new/web/index.html create mode 100644 receiver_app_new/web/manifest.json create mode 100644 receiver_app_new/windows/.gitignore create mode 100644 receiver_app_new/windows/CMakeLists.txt create mode 100644 receiver_app_new/windows/flutter/CMakeLists.txt create mode 100644 receiver_app_new/windows/flutter/generated_plugin_registrant.cc create mode 100644 receiver_app_new/windows/flutter/generated_plugin_registrant.h create mode 100644 receiver_app_new/windows/flutter/generated_plugins.cmake create mode 100644 receiver_app_new/windows/runner/CMakeLists.txt create mode 100644 receiver_app_new/windows/runner/Runner.rc create mode 100644 receiver_app_new/windows/runner/flutter_window.cpp create mode 100644 receiver_app_new/windows/runner/flutter_window.h create mode 100644 receiver_app_new/windows/runner/main.cpp create mode 100644 receiver_app_new/windows/runner/resource.h create mode 100644 receiver_app_new/windows/runner/resources/app_icon.ico create mode 100644 receiver_app_new/windows/runner/runner.exe.manifest create mode 100644 receiver_app_new/windows/runner/utils.cpp create mode 100644 receiver_app_new/windows/runner/utils.h create mode 100644 receiver_app_new/windows/runner/win32_window.cpp create mode 100644 receiver_app_new/windows/runner/win32_window.h 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 0000000000000000000000000000000000000000..e6adf927d517432eb7cd7bf45f201c499cc416c9 GIT binary patch literal 40466 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 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +