feat: implement accessibility-based scraper service and standalone worker backend with device registration UI

This commit is contained in:
Hamza-Ayed
2026-06-21 15:21:16 +03:00
parent ce6f22dc71
commit b492b5076b
21 changed files with 1402 additions and 9 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -10,7 +13,8 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Android_bot">
android:theme="@style/Theme.Android_bot"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -22,6 +26,18 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.ScraperAccessibilityService"
android:exported="true"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
</application>
</manifest>

View File

@@ -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")
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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")
}
}

View File

@@ -1,3 +1,4 @@
<resources>
<string name="app_name">android_bot</string>
<string name="accessibility_service_description">Siro Scraper Accessibility Service helps automate competitor price checks.</string>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows|flagIncludeNotImportantViews"
android:canRetrieveWindowContent="true"
android:canPerformGestures="true"
android:description="@string/accessibility_service_description"
android:notificationTimeout="100" />

View File

@@ -0,0 +1,753 @@
<?php
// ============================================================================
// backend/bot/standalone_worker.php
// Standalone database-free / Redis-free Task Server & Dashboard for Android Scraper Bot
// ============================================================================
define('TASKS_FILE', __DIR__ . '/tasks.json');
define('RESULTS_FILE', __DIR__ . '/results.json');
define('SECRET_KEY', 'SIRO_BOT_SUPER_SECRET_123'); // Matches WorkerClient.kt secret key
// Ensure JSON data files exist
if (!file_exists(TASKS_FILE)) {
file_put_contents(TASKS_FILE, json_encode([]));
}
if (!file_exists(RESULTS_FILE)) {
file_put_contents(RESULTS_FILE, json_encode([]));
}
// ----------------------------------------------------------------------------
// Helper Security functions
// ----------------------------------------------------------------------------
function validateSignature($device_id, $ts, $sig, $secret_key) {
// Prevent replay attacks (valid for 15 minutes to allow debugging tolerance)
if (abs(time() - $ts) > 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);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Siro Bot - Standalone Server Control Panel</title>
<!-- Premium Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&family=Space+Grotesk:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color: #0b0f19;
--card-bg: rgba(22, 28, 45, 0.45);
--border-color: rgba(255, 255, 255, 0.08);
--accent-color: #3b82f6;
--accent-gradient: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
--text-color: #f3f4f6;
--text-muted: #9ca3af;
--success-color: #10b981;
--error-color: #ef4444;
--glass-blur: blur(12px);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Outfit', sans-serif;
background-color: var(--bg-color);
background-image:
radial-gradient(at 10% 10%, rgba(29, 78, 216, 0.15) 0px, transparent 50%),
radial-gradient(at 90% 90%, rgba(16, 185, 129, 0.1) 0px, transparent 50%);
color: var(--text-color);
min-height: 100vh;
padding: 2rem 1rem;
line-height: 1.5;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
/* Header design */
header {
margin-bottom: 2.5rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
padding-bottom: 1.5rem;
}
.logo-section h1 {
font-family: 'Space Grotesk', sans-serif;
font-size: 2.2rem;
font-weight: 700;
background: linear-gradient(to right, #3b82f6, #60a5fa, #10b981);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.5px;
}
.logo-section p {
color: var(--text-muted);
font-size: 0.95rem;
margin-top: 0.25rem;
}
.badge-status {
background-color: rgba(16, 185, 129, 0.15);
border: 1px solid var(--success-color);
color: var(--success-color);
padding: 0.4rem 0.8rem;
border-radius: 9999px;
font-size: 0.85rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
box-shadow: 0 0 15px rgba(16, 185, 129, 0.2);
}
.status-dot {
width: 8px;
height: 8px;
background-color: var(--success-color);
border-radius: 50%;
display: inline-block;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { transform: scale(0.9); opacity: 0.6; }
50% { transform: scale(1.2); opacity: 1; }
100% { transform: scale(0.9); opacity: 0.6; }
}
/* Notification alert */
.alert {
padding: 1rem;
border-radius: 12px;
margin-bottom: 2rem;
font-weight: 500;
font-size: 0.95rem;
display: flex;
justify-content: space-between;
align-items: center;
animation: fadeIn 0.4s ease-out;
}
.alert-success {
background-color: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
color: #34d399;
}
.alert-error {
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #f87171;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Grid Dashboard Layout */
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 2rem;
}
@media (max-width: 900px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
.card {
background: var(--card-bg);
border: 1px solid var(--border-color);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-radius: 20px;
padding: 1.8rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
box-shadow: 0 15px 35px rgba(59, 130, 246, 0.05);
}
.card-title {
font-family: 'Space Grotesk', sans-serif;
font-size: 1.3rem;
font-weight: 700;
margin-bottom: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
color: #ffffff;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
padding-bottom: 0.75rem;
}
.card-subtitle {
font-size: 0.85rem;
color: var(--text-muted);
font-weight: normal;
}
/* Form Inputs */
.form-group {
margin-bottom: 1.25rem;
}
label {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
input[type="text"], select {
width: 100%;
padding: 0.8rem 1rem;
background: rgba(10, 15, 30, 0.6);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: #fff;
font-family: inherit;
font-size: 0.95rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
input[type="text"]:focus, select:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 0.85rem 1.5rem;
background: var(--accent-gradient);
border: none;
border-radius: 10px;
color: #fff;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
transition: opacity 0.2s, transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 15px rgba(29, 78, 216, 0.4);
}
.btn:hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(29, 78, 216, 0.55);
}
.btn:active {
transform: translateY(1px);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #e5e7eb;
box-shadow: none;
font-weight: 600;
padding: 0.5rem 1rem;
font-size: 0.85rem;
border-radius: 8px;
width: auto;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
box-shadow: none;
transform: none;
}
/* List components */
.list-container {
display: flex;
flex-direction: column;
gap: 1rem;
max-height: 500px;
overflow-y: auto;
padding-right: 0.5rem;
}
/* Scrollbar styling */
.list-container::-webkit-scrollbar {
width: 6px;
}
.list-container::-webkit-scrollbar-track {
background: transparent;
}
.list-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
.list-item {
background: rgba(10, 15, 30, 0.4);
border: 1px solid rgba(255, 255, 255, 0.03);
border-radius: 12px;
padding: 1rem 1.2rem;
display: flex;
justify-content: space-between;
align-items: center;
transition: background-color 0.2s;
}
.list-item:hover {
background: rgba(10, 15, 30, 0.6);
}
.task-info h4 {
font-size: 1rem;
font-weight: 600;
color: #fff;
margin-bottom: 0.25rem;
display: flex;
align-items: center;
gap: 0.6rem;
}
.task-info p {
font-size: 0.85rem;
color: var(--text-muted);
}
.badge-app {
font-size: 0.75rem;
font-weight: 700;
padding: 0.2rem 0.5rem;
border-radius: 6px;
text-transform: uppercase;
}
.app-yallago { background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3); }
.app-zaken { background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); }
.app-tufaddal { background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3); }
.time-badge {
font-size: 0.80rem;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.05);
padding: 0.25rem 0.6rem;
border-radius: 6px;
font-family: 'Space Grotesk', sans-serif;
}
.result-success { border-left: 4px solid var(--success-color); }
.result-failed { border-left: 4px solid var(--error-color); }
.price-text {
font-family: 'Space Grotesk', sans-serif;
font-size: 1.15rem;
font-weight: 700;
color: #fff;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--text-muted);
font-style: italic;
}
.tabs-header {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.tab-btn {
background: none;
border: none;
color: var(--text-muted);
font-family: inherit;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
padding: 0.5rem 0.25rem;
border-bottom: 2px solid transparent;
transition: color 0.2s, border-color 0.2s;
}
.tab-btn.active {
color: #fff;
border-bottom-color: var(--accent-color);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="logo-section">
<h1 id="main-title">Siro Bot Control Center</h1>
<p>Standalone API Endpoint & Simulation Controller Panel</p>
</div>
<div class="badge-status" id="server-status">
<span class="status-dot"></span>
Server Online
</div>
</header>
<?php if ($message): ?>
<div class="alert alert-<?php echo $msgType; ?>" id="info-alert">
<span><?php echo htmlspecialchars($message); ?></span>
<button class="btn-secondary" onclick="document.getElementById('info-alert').style.display='none'">Dismiss</button>
</div>
<?php endif; ?>
<div class="dashboard-grid">
<!-- Left Side: Task Generator Form -->
<div class="card">
<h2 class="card-title">
Task Generator
</h2>
<form method="POST" action="">
<input type="hidden" name="action" value="add_task">
<div class="form-group">
<label for="app">Competitor Application</label>
<select name="app" id="app" required>
<option value="ae.com.yalla.go.dubai.client">YallaGo (ae.com.yalla.go.dubai.client)</option>
<option value="com.zakinn.app">Zaken / Zakinn (com.zakinn.app)</option>
<option value="com.bis.taxi">Tfadal (com.bis.taxi)</option>
</select>
</div>
<div class="form-group">
<label for="start_location">Start Location (Text hint)</label>
<input type="text" name="start_location" id="start_location" placeholder="e.g. Mezzeh Street, Damascus" required value="Mezzeh Street">
</div>
<div class="form-group">
<label for="end_location">End Location (Text hint)</label>
<input type="text" name="end_location" id="end_location" placeholder="e.g. Damascus Airport" required value="Damascus Airport">
</div>
<div class="form-row">
<div class="form-group">
<label for="start_lat">Start Latitude</label>
<input type="text" name="start_lat" id="start_lat" placeholder="33.5074" value="33.5074">
</div>
<div class="form-group">
<label for="start_lng">Start Longitude</label>
<input type="text" name="start_lng" id="start_lng" placeholder="36.2530" value="36.2530">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="end_lat">End Latitude</label>
<input type="text" name="end_lat" id="end_lat" placeholder="33.5138" value="33.5138">
</div>
<div class="form-group">
<label for="end_lng">End Longitude</label>
<input type="text" name="end_lng" id="end_lng" placeholder="36.2765" value="36.2765">
</div>
</div>
<button type="submit" class="btn" id="submit-task-btn">Queue Scraping Task</button>
</form>
</div>
<!-- Right Side: Lists Dashboard -->
<div class="card">
<div class="tabs-header">
<button class="tab-btn active" onclick="switchTab('tasks')">Pending Tasks (<?php echo count($currentTasks); ?>)</button>
<button class="tab-btn" onclick="switchTab('results')">Scrape History (<?php echo count($scrapedResults); ?>)</button>
</div>
<!-- Tab 1: Tasks Queue -->
<div id="tasks-tab" class="tab-content active">
<div class="card-title" style="border:none; margin-bottom:1rem; font-size:1rem; color:var(--text-muted);">
<span>Queued task list (Sent to bot via GET request)</span>
<?php if (count($currentTasks) > 0): ?>
<form method="POST" style="margin: 0;">
<input type="hidden" name="action" value="clear_tasks">
<button type="submit" class="btn-secondary" id="clear-tasks-btn">Clear All</button>
</form>
<?php endif; ?>
</div>
<div class="list-container">
<?php if (count($currentTasks) === 0): ?>
<div class="empty-state">No pending tasks. Queue some tasks using the form on the left!</div>
<?php else: ?>
<?php foreach ($currentTasks as $task):
$appLabel = ($task['app'] === 'ae.com.yalla.go.dubai.client') ? 'yallago' : (($task['app'] === 'com.zakinn.app') ? 'zaken' : 'tufaddal');
?>
<div class="list-item">
<div class="task-info">
<h4>
<span class="badge-app app-<?php echo $appLabel; ?>"><?php echo htmlspecialchars($appLabel); ?></span>
<span><?php echo htmlspecialchars($task['task_id']); ?></span>
</h4>
<p>Route: <strong><?php echo htmlspecialchars($task['start_location']); ?></strong> &rarr; <strong><?php echo htmlspecialchars($task['end_location']); ?></strong></p>
</div>
<div class="time-badge">
Pending
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<!-- Tab 2: Scrape History -->
<div id="results-tab" class="tab-content">
<div class="card-title" style="border:none; margin-bottom:1rem; font-size:1rem; color:var(--text-muted);">
<span>Results submitted by Bot via POST request</span>
<?php if (count($scrapedResults) > 0): ?>
<form method="POST" style="margin: 0;">
<input type="hidden" name="action" value="clear_results">
<button type="submit" class="btn-secondary" id="clear-results-btn">Clear History</button>
</form>
<?php endif; ?>
</div>
<div class="list-container">
<?php if (count($scrapedResults) === 0): ?>
<div class="empty-state">No scraping results recorded yet. Results will appear here when the Android bot submits them.</div>
<?php else: ?>
<?php foreach ($scrapedResults as $res):
$data = $res['result_data'];
$app = $data['app'] ?? 'unknown';
$appLabel = (strpos($app, 'yalla') !== false) ? 'yallago' : ((strpos($app, 'zakinn') !== false || strpos($app, 'zaken') !== false) ? 'zaken' : 'tufaddal');
$isSuccess = ($res['status'] === 'success');
$price = $data['price'] ?? 0;
$dist = $data['distance_km'] ?? 0;
?>
<div class="list-item <?php echo $isSuccess ? 'result-success' : 'result-failed'; ?>">
<div class="task-info">
<h4>
<span class="badge-app app-<?php echo $appLabel; ?>"><?php echo htmlspecialchars($appLabel); ?></span>
<span>Task ID: <?php echo htmlspecialchars($res['task_id']); ?></span>
</h4>
<p>Device: <?php echo htmlspecialchars($res['device_id']); ?></p>
<p>Distance: <strong><?php echo number_format($dist, 2); ?> km</strong> | Time: <?php echo htmlspecialchars($res['recorded_at']); ?></p>
</div>
<div class="price-text">
<?php if ($isSuccess): ?>
<?php echo number_format($price, 0); ?> SYP
<?php else: ?>
<span style="color:var(--error-color); font-size:0.9rem;">Scrape Failed</span>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
<script>
function switchTab(tabId) {
// Remove active classes
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
// Add active class to selected tab button
const buttons = document.querySelectorAll('.tab-btn');
if (tabId === 'tasks') {
buttons[0].classList.add('active');
document.getElementById('tasks-tab').classList.add('active');
} else {
buttons[1].classList.add('active');
document.getElementById('results-tab').classList.add('active');
}
}
</script>
</body>
</html>