diff --git a/.gradle_home/wrapper/dists/gradle-8.13-bin/5xuhj0ry160q40clulazy9h7d/gradle-8.13-bin.zip.lck b/.gradle_home/wrapper/dists/gradle-8.13-bin/5xuhj0ry160q40clulazy9h7d/gradle-8.13-bin.zip.lck new file mode 100644 index 0000000..e69de29 diff --git a/.gradle_home/wrapper/dists/gradle-8.13-bin/5xuhj0ry160q40clulazy9h7d/gradle-8.13-bin.zip.part b/.gradle_home/wrapper/dists/gradle-8.13-bin/5xuhj0ry160q40clulazy9h7d/gradle-8.13-bin.zip.part new file mode 100644 index 0000000..e69de29 diff --git a/android_bot/.gradle/8.13/checksums/checksums.lock b/android_bot/.gradle/8.13/checksums/checksums.lock index 54a0ddf..da21be0 100644 Binary files a/android_bot/.gradle/8.13/checksums/checksums.lock and b/android_bot/.gradle/8.13/checksums/checksums.lock differ diff --git a/android_bot/.gradle/8.13/checksums/md5-checksums.bin b/android_bot/.gradle/8.13/checksums/md5-checksums.bin index 9d46967..4463403 100644 Binary files a/android_bot/.gradle/8.13/checksums/md5-checksums.bin and b/android_bot/.gradle/8.13/checksums/md5-checksums.bin differ diff --git a/android_bot/.gradle/8.13/checksums/sha1-checksums.bin b/android_bot/.gradle/8.13/checksums/sha1-checksums.bin index f261c43..874c193 100644 Binary files a/android_bot/.gradle/8.13/checksums/sha1-checksums.bin and b/android_bot/.gradle/8.13/checksums/sha1-checksums.bin differ diff --git a/android_bot/.gradle/8.13/executionHistory/executionHistory.bin b/android_bot/.gradle/8.13/executionHistory/executionHistory.bin new file mode 100644 index 0000000..040e87c Binary files /dev/null and b/android_bot/.gradle/8.13/executionHistory/executionHistory.bin differ diff --git a/android_bot/.gradle/8.13/executionHistory/executionHistory.lock b/android_bot/.gradle/8.13/executionHistory/executionHistory.lock index 8f4ba1c..7d8f046 100644 Binary files a/android_bot/.gradle/8.13/executionHistory/executionHistory.lock and b/android_bot/.gradle/8.13/executionHistory/executionHistory.lock differ diff --git a/android_bot/.gradle/8.13/fileHashes/fileHashes.bin b/android_bot/.gradle/8.13/fileHashes/fileHashes.bin index 3ee70d3..7d8fe4a 100644 Binary files a/android_bot/.gradle/8.13/fileHashes/fileHashes.bin and b/android_bot/.gradle/8.13/fileHashes/fileHashes.bin differ diff --git a/android_bot/.gradle/8.13/fileHashes/fileHashes.lock b/android_bot/.gradle/8.13/fileHashes/fileHashes.lock index f9f46f1..8d4d4a3 100644 Binary files a/android_bot/.gradle/8.13/fileHashes/fileHashes.lock and b/android_bot/.gradle/8.13/fileHashes/fileHashes.lock differ diff --git a/android_bot/.gradle/8.13/fileHashes/resourceHashesCache.bin b/android_bot/.gradle/8.13/fileHashes/resourceHashesCache.bin index 19d5076..669b055 100644 Binary files a/android_bot/.gradle/8.13/fileHashes/resourceHashesCache.bin and b/android_bot/.gradle/8.13/fileHashes/resourceHashesCache.bin differ diff --git a/android_bot/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/android_bot/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 97b1e96..8c1fd74 100644 Binary files a/android_bot/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/android_bot/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/android_bot/.gradle/buildOutputCleanup/outputFiles.bin b/android_bot/.gradle/buildOutputCleanup/outputFiles.bin new file mode 100644 index 0000000..2d8ab2c Binary files /dev/null and b/android_bot/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/android_bot/.gradle/file-system.probe b/android_bot/.gradle/file-system.probe new file mode 100644 index 0000000..31d1f58 Binary files /dev/null and b/android_bot/.gradle/file-system.probe differ diff --git a/android_bot/app/src/main/AndroidManifest.xml b/android_bot/app/src/main/AndroidManifest.xml index 3d14b48..e511a4e 100644 --- a/android_bot/app/src/main/AndroidManifest.xml +++ b/android_bot/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + + android:theme="@style/Theme.Android_bot" + android:usesCleartextTraffic="true"> + + + + + + + \ No newline at end of file diff --git a/android_bot/app/src/main/java/com/siro/android_bot/MainActivity.kt b/android_bot/app/src/main/java/com/siro/android_bot/MainActivity.kt index 41860b5..6a468c8 100644 --- a/android_bot/app/src/main/java/com/siro/android_bot/MainActivity.kt +++ b/android_bot/app/src/main/java/com/siro/android_bot/MainActivity.kt @@ -1,27 +1,46 @@ package com.siro.android_bot +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import android.os.Bundle +import android.provider.Settings +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.siro.android_bot.ui.theme.Android_botTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + val androidId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID) ?: "UNKNOWN" + enableEdgeToEdge() setContent { Android_botTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", + BotSetupScreen( + deviceId = androidId, modifier = Modifier.padding(innerPadding) ) } @@ -31,17 +50,55 @@ class MainActivity : ComponentActivity() { } @Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) +fun BotSetupScreen(deviceId: String, modifier: Modifier = Modifier) { + val context = LocalContext.current + + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Siro Scraper Bot 🤖", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(32.dp)) + Text( + text = "Your Device ID (Android ID):", + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = deviceId, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Black, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(32.dp)) + Button(onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Device ID", deviceId) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Copied to clipboard!", Toast.LENGTH_SHORT).show() + }) { + Text("Copy ID") + } + Spacer(modifier = Modifier.height(48.dp)) + Text( + text = "Make sure to add this ID to your Siro Server.\nAlso, remember to enable this app in Settings > Accessibility.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 32.dp) + ) + } } @Preview(showBackground = true) @Composable fun GreetingPreview() { Android_botTheme { - Greeting("Android") + BotSetupScreen("a1b2c3d4e5f6g7h8") } } \ No newline at end of file diff --git a/android_bot/app/src/main/java/com/siro/android_bot/network/WorkerClient.kt b/android_bot/app/src/main/java/com/siro/android_bot/network/WorkerClient.kt new file mode 100644 index 0000000..4f01755 --- /dev/null +++ b/android_bot/app/src/main/java/com/siro/android_bot/network/WorkerClient.kt @@ -0,0 +1,121 @@ +package com.siro.android_bot.network + +import android.content.Context +import android.provider.Settings +import android.util.Log +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +class WorkerClient(private val context: Context) { + + private val TAG = "WorkerClient" + private val SECRET_KEY = "SIRO_BOT_SUPER_SECRET_123" + + // Read the real hardware Android ID + val deviceId: String by lazy { + Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: "UNKNOWN_DEVICE" + } + + // Change this to your actual server domain + private val BASE_URL = "https://api.intaleq.xyz/bot_android/standalone_worker.php" + // For local testing use: "http://10.0.2.2:8000/standalone_worker.php" + + private fun generateSignature(deviceId: String, ts: Long): String { + val message = "$deviceId$ts" + val algorithm = "HmacSHA256" + val mac = Mac.getInstance(algorithm) + val secretKeySpec = SecretKeySpec(SECRET_KEY.toByteArray(), algorithm) + mac.init(secretKeySpec) + val hashBytes = mac.doFinal(message.toByteArray()) + return hashBytes.joinToString("") { "%02x".format(it) } + } + + fun fetchTask(): JSONObject? { + try { + val ts = System.currentTimeMillis() / 1000 + val sig = generateSignature(deviceId, ts) + + val urlString = "$BASE_URL?device_id=$deviceId&ts=$ts&sig=$sig" + val url = URL(urlString) + + Log.d(TAG, "Fetching task from: $urlString") + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.connectTimeout = 10000 + connection.readTimeout = 10000 + + val responseCode = connection.responseCode + if (responseCode == HttpURLConnection.HTTP_OK) { + val reader = BufferedReader(InputStreamReader(connection.inputStream)) + val response = reader.readText() + reader.close() + return JSONObject(response) + } else { + Log.e(TAG, "Fetch Error: HTTP $responseCode") + } + } catch (e: Exception) { + Log.e(TAG, "Exception during fetchTask: ${e.message}") + } + return null + } + + fun submitPrice(taskId: String, appName: String, startLat: Double, startLng: Double, endLat: Double, endLng: Double, distanceKm: Double, price: Double): Boolean { + try { + val ts = System.currentTimeMillis() / 1000 + val sig = generateSignature(deviceId, ts) + + val payload = JSONObject().apply { + put("device_id", deviceId) + put("ts", ts) + put("sig", sig) + put("task_id", taskId) + put("type", "price_check") + put("status", "success") + + val resultData = JSONObject().apply { + put("app", appName) + put("distance_km", distanceKm) + put("price", price) + put("start_lat", startLat) + put("start_lng", startLng) + put("end_lat", endLat) + put("end_lng", endLng) + } + put("result_data", resultData) + } + + val url = URL(BASE_URL) + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8") + connection.doOutput = true + connection.connectTimeout = 10000 + connection.readTimeout = 10000 + + val writer = OutputStreamWriter(connection.outputStream) + writer.write(payload.toString()) + writer.flush() + writer.close() + + val responseCode = connection.responseCode + return if (responseCode == HttpURLConnection.HTTP_OK) { + val reader = BufferedReader(InputStreamReader(connection.inputStream)) + Log.d(TAG, "Submit Success: ${reader.readText()}") + reader.close() + true + } else { + Log.e(TAG, "Submit Error: HTTP $responseCode") + false + } + } catch (e: Exception) { + Log.e(TAG, "Exception during submitPrice: ${e.message}") + return false + } + } +} diff --git a/android_bot/app/src/main/java/com/siro/android_bot/service/AppLauncher.kt b/android_bot/app/src/main/java/com/siro/android_bot/service/AppLauncher.kt new file mode 100644 index 0000000..08c71f6 --- /dev/null +++ b/android_bot/app/src/main/java/com/siro/android_bot/service/AppLauncher.kt @@ -0,0 +1,39 @@ +package com.siro.android_bot.service + +import android.content.Context +import android.content.Intent +import android.util.Log + +object AppLauncher { + private const val TAG = "AppLauncher" + + // Maps the server app name to the actual Android package name + // Important: We will verify these exact package names tomorrow once you install them. + private val APP_PACKAGES = mapOf( + "yallago" to "ae.com.yalla.go.dubai.client", + "ae.com.yalla.go.dubai.client" to "ae.com.yalla.go.dubai.client", + "zaken" to "com.zakinn.app", + "com.zakinn.app" to "com.zakinn.app", + "tufaddal" to "com.bis.taxi", + "com.bis.taxi" to "com.bis.taxi" + ) + + fun launchApp(context: Context, appName: String): Boolean { + val packageName = APP_PACKAGES[appName.lowercase()] + if (packageName == null) { + Log.e(TAG, "Unknown app name from server: $appName") + return false + } + + val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName) + if (launchIntent != null) { + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + context.startActivity(launchIntent) + Log.i(TAG, "Successfully launched $appName ($packageName)") + return true + } else { + Log.e(TAG, "App $appName ($packageName) is not installed on this device!") + return false + } + } +} diff --git a/android_bot/app/src/main/java/com/siro/android_bot/service/ScraperAccessibilityService.kt b/android_bot/app/src/main/java/com/siro/android_bot/service/ScraperAccessibilityService.kt new file mode 100644 index 0000000..1d7ae8b --- /dev/null +++ b/android_bot/app/src/main/java/com/siro/android_bot/service/ScraperAccessibilityService.kt @@ -0,0 +1,397 @@ +package com.siro.android_bot.service + +import android.accessibilityservice.AccessibilityService +import android.util.Log +import android.view.accessibility.AccessibilityEvent +import com.siro.android_bot.network.WorkerClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.json.JSONObject + +enum class BotState { + IDLE, + LAUNCHING_APP, + SEARCHING_START, + SEARCHING_END, + READING_PRICE, + SUBMITTING +} + +class ScraperAccessibilityService : AccessibilityService() { + + private val TAG = "ScraperService" + private var pollingJob: Job? = null + private val serviceScope = CoroutineScope(Dispatchers.IO) + + private lateinit var workerClient: WorkerClient + + // State Machine Memory + private var currentState = BotState.IDLE + private var currentTask: JSONObject? = null + private var currentAppName: String = "" + + override fun onServiceConnected() { + super.onServiceConnected() + Log.i(TAG, "Accessibility Service Connected!") + + workerClient = WorkerClient(this) + Log.i(TAG, "Device Android ID: ${workerClient.deviceId}") + + startPolling() + } + + private fun startPolling() { + pollingJob = serviceScope.launch { + while (isActive) { + if (currentState == BotState.IDLE) { + Log.d(TAG, "Polling for tasks as ${workerClient.deviceId}...") + val result = workerClient.fetchTask() + + if (result != null && result.optBoolean("has_task", false)) { + val task = result.getJSONObject("task") + handleTask(task) + } else { + Log.d(TAG, "No tasks available.") + } + } + // Poll every 15 seconds + delay(15000) + } + } + } + + private fun handleTask(task: JSONObject) { + currentTask = task + currentAppName = task.optString("app") + val taskId = task.optString("task_id") + + Log.i(TAG, "Received Task: $taskId for App: $currentAppName") + + currentState = BotState.LAUNCHING_APP + + // Launch the App + val success = AppLauncher.launchApp(this, currentAppName) + if (success) { + // We wait for the AccessibilityEvent to tell us the app is opened, + // but we can preemptively change state to SEARCHING_START + currentState = BotState.SEARCHING_START + Log.i(TAG, "State -> SEARCHING_START") + } else { + // Failed to launch (app not installed) + Log.e(TAG, "Failed to launch app. Returning to IDLE.") + currentState = BotState.IDLE + currentTask = null + } + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + if (event == null) return + + val packageName = event.packageName?.toString() ?: return + val rootNode = rootInActiveWindow ?: return + + when (packageName) { + "ae.com.yalla.go.dubai.client" -> handleYallaGoAutomation(rootNode) + "com.zakinn.app" -> handleZakinnAutomation(rootNode) + "com.bis.taxi" -> handleTfadalAutomation(rootNode) + } + } + + private fun handleYallaGoAutomation(rootNode: android.view.accessibility.AccessibilityNodeInfo) { + val task = currentTask ?: return + + when (currentState) { + BotState.SEARCHING_START -> { + // If on main screen, find the "Where to" or dropoff TextView by text to tap + val dropoffNode = findNodeByText(rootNode, "Where to") ?: findNodeByText(rootNode, "أين تريد الذهاب") + if (dropoffNode != null) { + dropoffNode.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK) + Log.i(TAG, "YallaGo: Clicked dropoff entry node.") + currentState = BotState.SEARCHING_END + return + } + + // If already on the search screen, type pickup location + val startLoc = task.optString("start_location", "Main Square") + val pickupEdit = findEditableNode(rootNode) + if (pickupEdit != null) { + val arguments = android.os.Bundle().apply { + putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, startLoc) + } + pickupEdit.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, arguments) + Log.i(TAG, "YallaGo: Set pickup to: $startLoc") + currentState = BotState.SEARCHING_END + } + } + BotState.SEARCHING_END -> { + // Find destination field on search screen or select-on-map screen + val endLoc = task.optString("end_location", "Airport") + val destEdit = findEditableNode(rootNode) + if (destEdit != null) { + val arguments = android.os.Bundle().apply { + putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, endLoc) + } + destEdit.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, arguments) + Log.i(TAG, "YallaGo: Set destination to: $endLoc") + + // Look for the primary action button to confirm (e.g., "Confirm", "Book", "Done") + val confirmBtn = findNodeByText(rootNode, "Confirm") ?: findNodeByText(rootNode, "تأكيد") + if (confirmBtn != null) { + confirmBtn.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK) + Log.i(TAG, "YallaGo: Clicked confirm button.") + currentState = BotState.READING_PRICE + } + } + } + BotState.READING_PRICE -> { + // Search the screen for currency value + searchPriceByCurrency(rootNode) + } + else -> {} + } + } + + private fun handleZakinnAutomation(rootNode: android.view.accessibility.AccessibilityNodeInfo) { + val task = currentTask ?: return + + when (currentState) { + BotState.SEARCHING_START -> { + val inputNodes = rootNode.findAccessibilityNodeInfosByViewId("com.zakinn.app:id/ui-input-taxi-startText") + if (inputNodes.isNotEmpty()) { + val startLoc = task.optString("start_location", "Main Square") + val arguments = android.os.Bundle().apply { + putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, startLoc) + } + inputNodes[0].performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, arguments) + Log.i(TAG, "Zakinn: Entered start location: $startLoc") + currentState = BotState.SEARCHING_END + } else { + traverseAndType(rootNode, "locTypeStart", task.optString("start_location")) + } + } + BotState.SEARCHING_END -> { + val confirmButton = rootNode.findAccessibilityNodeInfosByViewId("com.zakinn.app:id/ui-btn-checkout-order-confirm") + if (confirmButton.isNotEmpty()) { + confirmButton[0].performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK) + Log.i(TAG, "Zakinn: Confirmed checkout.") + currentState = BotState.READING_PRICE + } else { + val fallbackConfirm = findNodeByText(rootNode, "Confirm") ?: findNodeByText(rootNode, "تأكيد") + if (fallbackConfirm != null) { + fallbackConfirm.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK) + Log.i(TAG, "Zakinn: Confirmed checkout via fallback text.") + currentState = BotState.READING_PRICE + } + } + } + BotState.READING_PRICE -> { + val priceValueNodes = rootNode.findAccessibilityNodeInfosByViewId("com.zakinn.app:id/service-price-value") + if (priceValueNodes.isNotEmpty()) { + val price = priceValueNodes[0].text?.toString() ?: "" + Log.i(TAG, "Zakinn: Read price: $price") + submitPriceToServer(price) + } else { + searchPriceByCurrency(rootNode) + } + } + else -> {} + } + } + + private fun handleTfadalAutomation(rootNode: android.view.accessibility.AccessibilityNodeInfo) { + val task = currentTask ?: return + + when (currentState) { + BotState.SEARCHING_START -> { + // In Flutter, look for inputs matching localized source strings + val sourceNode = findNodeByText(rootNode, "Find Your Source") + ?: findNodeByText(rootNode, "Source") + ?: findNodeByText(rootNode, "نقطة الانطلاق") + ?: findNodeByText(rootNode, "الانطلاق") + ?: findNodeByText(rootNode, "ابحث عن مصدرك") + ?: findNodeByText(rootNode, "Kalkış") + ?: findNodeByText(rootNode, "Kaynağınızı bulun") + + if (sourceNode != null) { + val startLoc = task.optString("start_location", "Main Square") + val arguments = android.os.Bundle().apply { + putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, startLoc) + } + sourceNode.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, arguments) + Log.i(TAG, "Tfadal: Set source location: $startLoc") + currentState = BotState.SEARCHING_END + } + } + BotState.SEARCHING_END -> { + val destNode = findNodeByText(rootNode, "Find Your Destination") + ?: findNodeByText(rootNode, "Destination") + ?: findNodeByText(rootNode, "الوجهة") + ?: findNodeByText(rootNode, "ابحث عن وجهتك") + ?: findNodeByText(rootNode, "Varış") + ?: findNodeByText(rootNode, "Varış noktanızı bulun") + + if (destNode != null) { + val endLoc = task.optString("end_location", "Airport") + val arguments = android.os.Bundle().apply { + putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, endLoc) + } + destNode.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, arguments) + Log.i(TAG, "Tfadal: Set destination location: $endLoc") + + val requestButton = findNodeByText(rootNode, "Add a trip") + ?: findNodeByText(rootNode, "Normal Trip") + ?: findNodeByText(rootNode, "طلب الرحلة") + ?: findNodeByText(rootNode, "اطلب رحلة") + ?: findNodeByText(rootNode, "Normal Yolculuk") + ?: findNodeByText(rootNode, "Yolculuk ekle") + ?: findNodeByText(rootNode, "Continue") + ?: findNodeByText(rootNode, "متابعة") + ?: findNodeByText(rootNode, "Devam") + + if (requestButton != null) { + requestButton.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK) + Log.i(TAG, "Tfadal: Clicked request trip button.") + currentState = BotState.READING_PRICE + } + } + } + BotState.READING_PRICE -> { + // Read expected price using localized strings + val priceNode = findNodeByText(rootNode, "Expected Price") + ?: findNodeByText(rootNode, "Total Price") + ?: findNodeByText(rootNode, "السعر المتوقع") + ?: findNodeByText(rootNode, "السعر الإجمالي") + + if (priceNode != null) { + val priceText = priceNode.text?.toString() ?: "" + Log.i(TAG, "Tfadal: Found price header node: $priceText") + searchPriceByCurrency(rootNode) + } else { + searchPriceByCurrency(rootNode) + } + } + else -> {} + } + } + + private fun findNodeByText(node: android.view.accessibility.AccessibilityNodeInfo?, query: String): android.view.accessibility.AccessibilityNodeInfo? { + if (node == null) return null + val text = node.text?.toString() ?: "" + val desc = node.contentDescription?.toString() ?: "" + if (text.contains(query, ignoreCase = true) || desc.contains(query, ignoreCase = true)) { + return node + } + for (i in 0 until node.childCount) { + val res = findNodeByText(node.getChild(i), query) + if (res != null) return res + } + return null + } + + private fun findEditableNode(node: android.view.accessibility.AccessibilityNodeInfo?): android.view.accessibility.AccessibilityNodeInfo? { + if (node == null) return null + if (node.className == "android.widget.EditText" || node.isEditable) { + return node + } + for (i in 0 until node.childCount) { + val res = findEditableNode(node.getChild(i)) + if (res != null) return res + } + return null + } + + private fun traverseAndType(node: android.view.accessibility.AccessibilityNodeInfo?, hintText: String, value: String) { + if (node == null) return + if (node.className == "android.widget.EditText" || node.isEditable) { + val arguments = android.os.Bundle().apply { + putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, value) + } + node.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, arguments) + Log.i(TAG, "Typed '$value' into editable node.") + return + } + for (i in 0 until node.childCount) { + traverseAndType(node.getChild(i), hintText, value) + } + } + + private fun searchPriceByCurrency(node: android.view.accessibility.AccessibilityNodeInfo?) { + if (node == null) return + val text = node.text?.toString() ?: "" + if (text.contains("ل.س") || text.contains("SYP") || text.contains("AED") || text.contains("SP") || text.contains("SP.")) { + Log.i(TAG, "Found price pattern dynamically: $text") + submitPriceToServer(text) + return + } + for (i in 0 until node.childCount) { + searchPriceByCurrency(node.getChild(i)) + } + } + + private fun submitPriceToServer(rawPrice: String) { + val task = currentTask ?: return + val taskId = task.optString("task_id") + + // Extract nested payload object where coordinates reside + val payload = task.optJSONObject("payload") + val startLat = payload?.optDouble("start_lat", 0.0) ?: 0.0 + val startLng = payload?.optDouble("start_lng", 0.0) ?: 0.0 + val endLat = payload?.optDouble("end_lat", 0.0) ?: 0.0 + val endLng = payload?.optDouble("end_lng", 0.0) ?: 0.0 + + // Calculate distance + val distanceKm = calculateDistanceInKm(startLat, startLng, endLat, endLng) + + // Extract numeric digits from price + val numericPrice = rawPrice.replace(Regex("[^0-9.]"), "").toDoubleOrNull() ?: 0.0 + + serviceScope.launch { + Log.i(TAG, "Submitting price $numericPrice for task $taskId...") + val success = workerClient.submitPrice( + taskId = taskId, + appName = currentAppName, + startLat = startLat, + startLng = startLng, + endLat = endLat, + endLng = endLng, + distanceKm = distanceKm, + price = numericPrice + ) + + if (success) { + Log.i(TAG, "Successfully submitted price to server.") + } else { + Log.e(TAG, "Failed to submit price.") + } + + // Go back to IDLE + currentState = BotState.IDLE + currentTask = null + } + } + + private fun calculateDistanceInKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val r = 6371.0 // Earth radius in km + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2) + val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + return r * c + } + + override fun onInterrupt() { + Log.w(TAG, "Accessibility Service Interrupted") + } + + override fun onDestroy() { + super.onDestroy() + pollingJob?.cancel() + Log.i(TAG, "Accessibility Service Destroyed") + } +} diff --git a/android_bot/app/src/main/res/values/strings.xml b/android_bot/app/src/main/res/values/strings.xml index 0b781ab..bc4152e 100644 --- a/android_bot/app/src/main/res/values/strings.xml +++ b/android_bot/app/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ android_bot + Siro Scraper Accessibility Service helps automate competitor price checks. \ No newline at end of file diff --git a/android_bot/app/src/main/res/xml/accessibility_service_config.xml b/android_bot/app/src/main/res/xml/accessibility_service_config.xml new file mode 100644 index 0000000..cf6c11d --- /dev/null +++ b/android_bot/app/src/main/res/xml/accessibility_service_config.xml @@ -0,0 +1,9 @@ + + diff --git a/backend/bot/standalone_worker.php b/backend/bot/standalone_worker.php new file mode 100644 index 0000000..fd03db7 --- /dev/null +++ b/backend/bot/standalone_worker.php @@ -0,0 +1,753 @@ + 900) { + return false; + } + // Generate the expected signature + $expected_sig = hash_hmac('sha256', $device_id . $ts, $secret_key); + // Secure comparison + return hash_equals($expected_sig, $sig); +} + +function jsonError($message, $code = 400) { + http_response_code($code); + echo json_encode(['status' => 'failure', 'message' => $message]); + exit; +} + +// ---------------------------------------------------------------------------- +// API Request Routing +// ---------------------------------------------------------------------------- +$method = $_SERVER['REQUEST_METHOD']; +$contentType = $_SERVER['CONTENT_TYPE'] ?? ''; + +// Check if request is API call from the Bot +$isApiCall = false; +$device_id = null; +$ts = null; +$sig = null; + +if ($method === 'GET' && isset($_GET['device_id'], $_GET['ts'], $_GET['sig'])) { + $isApiCall = true; + $device_id = $_GET['device_id']; + $ts = intval($_GET['ts']); + $sig = $_GET['sig']; +} elseif ($method === 'POST') { + // Check if JSON body has device credentials + $rawInput = file_get_contents('php://input'); + $input = json_decode($rawInput, true); + if ($input && isset($input['device_id'], $input['ts'], $input['sig'])) { + $isApiCall = true; + $device_id = $input['device_id']; + $ts = intval($input['ts']); + $sig = $input['sig']; + } +} + +if ($isApiCall) { + header('Content-Type: application/json; charset=UTF-8'); + + // Validate signature + if (!validateSignature($device_id, $ts, $sig, SECRET_KEY)) { + jsonError("Unauthorized device signature or expired timestamp. Device ID: $device_id, ts: $ts", 401); + } + + if ($method === 'GET') { + // Dequeue one task for this bot (FIFO) + $tasks = json_decode(file_get_contents(TASKS_FILE), true); + + if (count($tasks) > 0) { + $task = array_shift($tasks); + file_put_contents(TASKS_FILE, json_encode($tasks, JSON_PRETTY_PRINT)); + + echo json_encode([ + "status" => "success", + "has_task" => true, + "task" => $task + ]); + } else { + echo json_encode([ + "status" => "success", + "has_task" => false + ]); + } + exit; + } elseif ($method === 'POST') { + // Record Scrape result + if (empty($input['task_id']) || empty($input['status'])) { + jsonError("Missing required parameters in payload"); + } + + $results = json_decode(file_get_contents(RESULTS_FILE), true); + + $newResult = [ + 'task_id' => $input['task_id'], + 'device_id' => $device_id, + 'status' => $input['status'], // 'success' or 'failed' + 'type' => $input['type'] ?? 'price_check', + 'recorded_at' => date('Y-m-d H:i:s'), + 'result_data' => $input['result_data'] ?? [] + ]; + + array_unshift($results, $newResult); + file_put_contents(RESULTS_FILE, json_encode($results, JSON_PRETTY_PRINT)); + + echo json_encode([ + "status" => "success", + "message" => "Result recorded successfully" + ]); + exit; + } +} + +// ---------------------------------------------------------------------------- +// Admin Dashboard UI (Rendered on normal GET browser requests) +// ---------------------------------------------------------------------------- + +// Handle Admin Actions +$message = ''; +$msgType = 'success'; + +if (isset($_POST['action'])) { + if ($_POST['action'] === 'add_task') { + $app = $_POST['app'] ?? ''; + $start_loc = $_POST['start_location'] ?? ''; + $end_loc = $_POST['end_location'] ?? ''; + $start_lat = floatval($_POST['start_lat'] ?? 0.0); + $start_lng = floatval($_POST['start_lng'] ?? 0.0); + $end_lat = floatval($_POST['end_lat'] ?? 0.0); + $end_lng = floatval($_POST['end_lng'] ?? 0.0); + + if ($app && $start_loc && $end_loc) { + $tasks = json_decode(file_get_contents(TASKS_FILE), true); + $taskId = "prc_" . uniqid(); + + $newTask = [ + "task_id" => $taskId, + "type" => "price_check", + "app" => $app, + "start_location" => $start_loc, + "end_location" => $end_loc, + "payload" => [ + "start_lat" => $start_lat, + "start_lng" => $start_lng, + "end_lat" => $end_lat, + "end_lng" => $end_lng + ] + ]; + + $tasks[] = $newTask; + file_put_contents(TASKS_FILE, json_encode($tasks, JSON_PRETTY_PRINT)); + $message = "Task successfully added and queued! Task ID: $taskId"; + } else { + $message = "Please fill in all required fields."; + $msgType = 'error'; + } + } elseif ($_POST['action'] === 'clear_tasks') { + file_put_contents(TASKS_FILE, json_encode([])); + $message = "Task queue cleared successfully."; + } elseif ($_POST['action'] === 'clear_results') { + file_put_contents(RESULTS_FILE, json_encode([])); + $message = "Scrape results history cleared successfully."; + } +} + +// Fetch stats and lists for display +$currentTasks = json_decode(file_get_contents(TASKS_FILE), true); +$scrapedResults = json_decode(file_get_contents(RESULTS_FILE), true); + +?> + + + + + + Siro Bot - Standalone Server Control Panel + + + + + + + + +
+
+
+

Siro Bot Control Center

+

Standalone API Endpoint & Simulation Controller Panel

+
+
+ + Server Online +
+
+ + +
+ + +
+ + +
+ +
+

+ Task Generator +

+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ Queued task list (Sent to bot via GET request) + 0): ?> +
+ + +
+ +
+ +
+ +
No pending tasks. Queue some tasks using the form on the left!
+ + +
+
+

+ + +

+

Route:

+
+
+ Pending +
+
+ + +
+
+ + +
+
+ Results submitted by Bot via POST request + 0): ?> +
+ + +
+ +
+ +
+ +
No scraping results recorded yet. Results will appear here when the Android bot submits them.
+ + +
+
+

+ + Task ID: +

+

Device:

+

Distance: km | Time:

+
+
+ + SYP + + Scrape Failed + +
+
+ + +
+
+
+
+
+ + + +