first commit
48
caller-app/app/build.gradle
Normal file
@@ -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.
|
||||
}
|
||||
1
caller-app/app/gradle.properties
Normal file
@@ -0,0 +1 @@
|
||||
android.useAndroidX=true
|
||||
34
caller-app/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# Retrofit
|
||||
-keepattributes Signature
|
||||
-keepattributes *Annotation*
|
||||
-keep class retrofit2.** { *; }
|
||||
-keepclassmembers,allowshrinking,allowobfuscation interface * {
|
||||
@retrofit2.http.* <methods>;
|
||||
}
|
||||
|
||||
# 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 <fields>;
|
||||
}
|
||||
55
caller-app/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="Flash OTP Caller"
|
||||
android:theme="@style/Theme.AppCompat.Light.DarkActionBar"
|
||||
android:usesCleartextTraffic="false">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".CallerService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Automation of flash call verification for OTP purposes" />
|
||||
</service>
|
||||
|
||||
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
BIN
caller-app/app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<out String>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
android:height="108dp"
|
||||
android:width="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,31 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startY="49.59793"
|
||||
android:startX="42.9492"
|
||||
android:endY="92.4963"
|
||||
android:endX="85.84757"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000"/>
|
||||
</vector>
|
||||
220
caller-app/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,220 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:background="#FAFAFA">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<!-- Device Setup Section -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:elevation="4dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardBackgroundColor="#FFFFFF">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Device Setup"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#333333"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="Device ID">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etDeviceId"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:enabled="false"
|
||||
android:focusable="false"
|
||||
android:textSize="12sp"
|
||||
android:inputType="none" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="Phone Number">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etPhoneNumber"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="phone"
|
||||
android:textSize="14sp" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="Server App Key">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etAppKey"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:textSize="14sp" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="SIM Slot:"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginEnd="12dp" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinnerSimSlot"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:spinnerMode="dropdown" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnRegister"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:text="Register Device"
|
||||
android:textAllCaps="false"
|
||||
android:textSize="15sp"
|
||||
android:backgroundTint="#4CAF50"
|
||||
android:textColor="#FFFFFF" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="8dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Service Control Section -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:elevation="4dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardBackgroundColor="#FFFFFF">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Service Control"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#333333"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Service Stopped ❌"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#F44336"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnToggleService"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:text="Start Service"
|
||||
android:textAllCaps="false"
|
||||
android:textSize="15sp"
|
||||
android:backgroundTint="#4CAF50"
|
||||
android:textColor="#FFFFFF" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Live Log Section -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:elevation="4dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardBackgroundColor="#FFFFFF">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Live Log"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#333333"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/logScrollView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="200dp"
|
||||
android:background="#F5F5F5"
|
||||
android:padding="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvLog"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#555555"
|
||||
android:fontFamily="monospace"
|
||||
android:lineSpacingExtra="4dp" />
|
||||
|
||||
</ScrollView>
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
BIN
caller-app/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
caller-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
caller-app/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
caller-app/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
caller-app/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
caller-app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
caller-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
caller-app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
caller-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
18
caller-app/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="primary">#4CAF50</color>
|
||||
<color name="primary_dark">#388E3C</color>
|
||||
<color name="primary_light">#C8E6C9</color>
|
||||
<color name="accent">#4CAF50</color>
|
||||
<color name="text_primary">#333333</color>
|
||||
<color name="text_secondary">#666666</color>
|
||||
<color name="text_hint">#999999</color>
|
||||
<color name="background">#FAFAFA</color>
|
||||
<color name="card_background">#FFFFFF</color>
|
||||
<color name="success_green">#4CAF50</color>
|
||||
<color name="error_red">#F44336</color>
|
||||
<color name="log_background">#F5F5F5</color>
|
||||
<color name="log_text">#555555</color>
|
||||
<color name="white">#FFFFFF</color>
|
||||
<color name="black">#000000</color>
|
||||
</resources>
|
||||
23
caller-app/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Flash OTP Caller</string>
|
||||
<string name="device_setup">Device Setup</string>
|
||||
<string name="service_control">Service Control</string>
|
||||
<string name="device_id_hint">Device ID</string>
|
||||
<string name="phone_number_hint">Phone Number</string>
|
||||
<string name="app_key_hint">Server App Key</string>
|
||||
<string name="sim_slot_label">SIM Slot</string>
|
||||
<string name="register_button">Register Device</string>
|
||||
<string name="start_service">Start Service</string>
|
||||
<string name="stop_service">Stop Service</string>
|
||||
<string name="service_running">Service Running ✅</string>
|
||||
<string name="service_stopped">Service Stopped ❌</string>
|
||||
<string name="live_log">Live Log</string>
|
||||
<string name="permission_required">Permissions Required</string>
|
||||
<string name="permission_rationale">This app requires Phone, SMS, and Phone State permissions to make flash calls and send OTP messages.</string>
|
||||
<string name="registration_success">Device registered successfully!</string>
|
||||
<string name="registration_failed">Registration failed</string>
|
||||
<string name="permissions_denied">Some permissions were denied. The app may not function correctly.</string>
|
||||
<string name="register_first">Please register the device first</string>
|
||||
<string name="grant_permissions_first">Please grant all permissions first</string>
|
||||
</resources>
|
||||
15
caller-app/build.gradle
Normal file
@@ -0,0 +1,15 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.9.22'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.9.1'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('clean', Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
10
caller-app/gradle.properties
Normal file
@@ -0,0 +1,10 @@
|
||||
org.gradle.jvmargs=-Xmx4096m -XX:MaxPermSize=512m
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
|
||||
# AndroidX Support
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
|
||||
# Kotlin
|
||||
kotlin.code.style=official
|
||||
5
caller-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
18
caller-app/settings.gradle
Normal file
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "FlashOTPCaller"
|
||||
include ':app'
|
||||