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) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
Reference in New Issue
Block a user