feat: implement accessibility-based scraper service and standalone worker backend with device registration UI
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
@@ -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) {
|
||||
fun BotSetupScreen(deviceId: String, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Hello $name!",
|
||||
modifier = modifier
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+397
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
@@ -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> → <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>
|
||||
Reference in New Issue
Block a user