Update: 2026-06-30 02:05:28

This commit is contained in:
Hamza-Ayed
2026-06-30 02:05:28 +03:00
parent 44fa1c0558
commit cf696d05db
9 changed files with 292 additions and 20 deletions

View File

@@ -15,6 +15,7 @@ import org.json.JSONObject
enum class BotState {
IDLE,
LAUNCHING_APP,
NAVIGATING_HOME,
SEARCHING_START,
SEARCHING_END,
READING_PRICE,
@@ -33,6 +34,22 @@ class ScraperAccessibilityService : AccessibilityService() {
private var currentState = BotState.IDLE
private var currentTask: JSONObject? = null
private var currentAppName: String = ""
private var taxiFHomeClickTime = 0L
private var taxiFLocationClickTime = 0L
private var taxiFSuggestionClickTime = 0L
private var taxiFPickupDone = false
private var taxiFDestinationDone = false
private val taxiFCollectedPrices = mutableListOf<Pair<Double, String>>()
private var taxiFPriceScrollAttempts = 0
companion object {
private val TAXIF_EXCLUDED_TEXTS = setOf(
"JOD", "ECO", "TaxiF", "SUV", "EV", "Mini", "Female", "Van",
"Airport", "Courier", "~", "تحديد نقطة الانطلاق", "الآن", "نقدًا",
"سيارة خاصة", "تاكسي", "جيب", "صغيرة", "كهرباء", "سائقة",
"عائلية", "توصيل", "خدمة", "✈︎", "📦", "🇯🇴"
)
}
override fun onServiceConnected() {
super.onServiceConnected()
@@ -71,15 +88,15 @@ class ScraperAccessibilityService : AccessibilityService() {
Log.i(TAG, "Received Task: $taskId for App: $currentAppName")
taxiFPickupDone = false
taxiFDestinationDone = false
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")
currentState = if (currentAppName == "taxif" || currentAppName == "com.taxif.passenger") BotState.NAVIGATING_HOME else BotState.SEARCHING_START
Log.i(TAG, "State -> ${currentState}")
} else {
// Failed to launch (app not installed)
Log.e(TAG, "Failed to launch app. Returning to IDLE.")
@@ -471,37 +488,283 @@ class ScraperAccessibilityService : AccessibilityService() {
val task = currentTask ?: return
Log.d(TAG, "TaxiF Automation event. State: $currentState")
when (currentState) {
BotState.SEARCHING_START -> {
val pickupEdit = findEditableNode(rootNode)
if (pickupEdit != null) {
val startLoc = task.optString("start_location", "Amman")
val arguments = android.os.Bundle().apply {
putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, startLoc)
BotState.NAVIGATING_HOME -> {
if (hasJustClickedHome()) return
val rihlaNode = findNodeByText(rootNode, "رحلة")
if (rihlaNode != null) {
var clickable: android.view.accessibility.AccessibilityNodeInfo? = rihlaNode
while (clickable != null && !clickable.isClickable) {
clickable = clickable.parent
}
pickupEdit.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, arguments)
Log.i(TAG, "TaxiF: Entered start: $startLoc")
if (clickable != null) {
clickable.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK)
Log.i(TAG, "TaxiF: Clicked 'رحلة' tab, entering trip flow")
} else {
rihlaNode.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK)
Log.i(TAG, "TaxiF: Clicked 'رحلة' node directly")
}
taxiFHomeClickTime = System.currentTimeMillis()
return
}
currentState = BotState.SEARCHING_START
Log.i(TAG, "TaxiF: Home screen navigated, state -> SEARCHING_START")
}
BotState.SEARCHING_START -> {
if (detectTaxiFPriceScreen(rootNode)) {
if (hasJustClickedLocation() || hasJustClickedSuggestion()) return
if (!taxiFPickupDone && shouldEditLocation(rootNode, task, isPickup = true)) {
clickLocationRow(rootNode, isPickup = true)
taxiFLocationClickTime = System.currentTimeMillis()
return
}
taxiFPickupDone = true
currentState = BotState.SEARCHING_END
return
}
if (!taxiFPickupDone && handleTaxiFSearchScreen(rootNode, task.optString("start_location", "Amman"))) {
taxiFPickupDone = true
Log.i(TAG, "TaxiF: Handled pickup search, waiting for price screen")
}
}
BotState.SEARCHING_END -> {
val destEdit = findEditableNode(rootNode)
if (destEdit != 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)
if (detectTaxiFPriceScreen(rootNode)) {
if (hasJustClickedLocation() || hasJustClickedSuggestion()) return
if (!taxiFDestinationDone && shouldEditLocation(rootNode, task, isPickup = false)) {
clickLocationRow(rootNode, isPickup = false)
taxiFLocationClickTime = System.currentTimeMillis()
return
}
destEdit.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, arguments)
Log.i(TAG, "TaxiF: Entered end: $endLoc")
taxiFDestinationDone = true
currentState = BotState.READING_PRICE
taxiFCollectedPrices.clear()
taxiFPriceScrollAttempts = 0
return
}
if (!taxiFDestinationDone && handleTaxiFSearchScreen(rootNode, task.optString("end_location", "Airport"))) {
taxiFDestinationDone = true
Log.i(TAG, "TaxiF: Handled dest search, waiting for price screen")
}
}
BotState.READING_PRICE -> {
searchPriceByCurrency(rootNode)
if (!hasJustClickedSuggestion()) {
collectAndScrollPrices(rootNode)
}
}
else -> {}
}
}
private fun hasJustClickedHome(): Boolean {
return (System.currentTimeMillis() - taxiFHomeClickTime) < 1500
}
private fun hasJustClickedLocation(): Boolean {
return (System.currentTimeMillis() - taxiFLocationClickTime) < 1500
}
private fun hasJustClickedSuggestion(): Boolean {
return (System.currentTimeMillis() - taxiFSuggestionClickTime) < 1500
}
private fun getLocationTexts(node: android.view.accessibility.AccessibilityNodeInfo?): List<String> {
val texts = mutableListOf<String>()
collectLocationTexts(node, texts)
return texts
}
private fun collectLocationTexts(node: android.view.accessibility.AccessibilityNodeInfo?, texts: MutableList<String>) {
if (node == null) return
val text = node.text?.toString()?.trim() ?: ""
val className = node.className?.toString() ?: ""
val isEditText = className == "android.widget.EditText" || node.isEditable
if (text.isNotBlank() && text.length > 2 && !isEditText && TAXIF_EXCLUDED_TEXTS.none { text.contains(it) }) {
texts.add(text)
}
for (i in 0 until node.childCount) {
collectLocationTexts(node.getChild(i), texts)
}
}
private fun shouldEditLocation(rootNode: android.view.accessibility.AccessibilityNodeInfo, task: JSONObject, isPickup: Boolean): Boolean {
val locationTexts = getLocationTexts(rootNode)
val taskLoc = if (isPickup) task.optString("start_location", "") else task.optString("end_location", "")
if (taskLoc.isEmpty()) return false
val matchFound = locationTexts.any { text ->
text.contains(taskLoc, ignoreCase = true) || taskLoc.contains(text, ignoreCase = true)
}
Log.d(TAG, "TaxiF: Location texts=$locationTexts, taskLoc=$taskLoc, matchFound=$matchFound")
return !matchFound
}
private fun clickLocationRow(rootNode: android.view.accessibility.AccessibilityNodeInfo?, isPickup: Boolean) {
if (rootNode == null) return
val locationNodes = getLocationTextNodes(rootNode)
val targetIndex: Int
if (isPickup) {
targetIndex = 0
} else {
val destIdx = locationNodes.indexOfFirst { it.text?.toString() == "عنوان الإنزال" }
targetIndex = if (destIdx >= 0) destIdx else minOf(1, locationNodes.size - 1)
}
if (targetIndex < locationNodes.size) {
val locNode = locationNodes[targetIndex]
var clickable: android.view.accessibility.AccessibilityNodeInfo? = locNode
while (clickable != null && !clickable.isClickable) {
clickable = clickable.parent
}
if (clickable != null) {
clickable.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK)
Log.i(TAG, "TaxiF: Clicked ${if (isPickup) "pickup" else "destination"} row: '${locNode.text}'")
} else {
locNode.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK)
Log.i(TAG, "TaxiF: Clicked ${if (isPickup) "pickup" else "destination"} node directly: '${locNode.text}'")
}
}
}
private fun getLocationTextNodes(node: android.view.accessibility.AccessibilityNodeInfo?, results: MutableList<android.view.accessibility.AccessibilityNodeInfo> = mutableListOf()): List<android.view.accessibility.AccessibilityNodeInfo> {
if (node == null) return results
val text = node.text?.toString()?.trim() ?: ""
val className = node.className?.toString() ?: ""
val isEditText = className == "android.widget.EditText" || node.isEditable
if (text.isNotBlank() && text.length > 2 && !isEditText && TAXIF_EXCLUDED_TEXTS.none { text.contains(it) }) {
results.add(node)
}
for (i in 0 until node.childCount) {
getLocationTextNodes(node.getChild(i), results)
}
return results
}
private fun handleTaxiFSearchScreen(rootNode: android.view.accessibility.AccessibilityNodeInfo, location: String): Boolean {
val editTexts = findAllEditTexts(rootNode)
if (editTexts.isEmpty()) return false
val editField = editTexts[0]
val currentText = editField.text?.toString() ?: ""
if (currentText != location) {
val arguments = android.os.Bundle().apply {
putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, location)
}
editField.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, arguments)
Log.i(TAG, "TaxiF: Typed '$location' in search field")
}
if (hasJustClickedSuggestion()) return false
val firstWord = location.split(" ").firstOrNull() ?: location
if (clickFirstSuggestion(rootNode, firstWord)) {
taxiFSuggestionClickTime = System.currentTimeMillis()
Log.i(TAG, "TaxiF: Clicked suggestion matching '$firstWord'")
return true
}
return false
}
private fun clickFirstSuggestion(rootNode: android.view.accessibility.AccessibilityNodeInfo?, target: String): Boolean {
if (rootNode == null) return false
val recycler = findRecyclerView(rootNode) ?: return false
for (i in 0 until recycler.childCount) {
val child = recycler.getChild(i) ?: continue
if (child.isClickable && suggestionContainsText(child, target)) {
child.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK)
return true
}
}
return false
}
private fun suggestionContainsText(node: android.view.accessibility.AccessibilityNodeInfo?, target: String): Boolean {
if (node == null) return false
val text = node.text?.toString() ?: ""
if (text.contains(target, ignoreCase = true)) return true
for (i in 0 until node.childCount) {
if (suggestionContainsText(node.getChild(i), target)) return true
}
return false
}
private fun findRecyclerView(node: android.view.accessibility.AccessibilityNodeInfo?): android.view.accessibility.AccessibilityNodeInfo? {
if (node == null) return null
val className = node.className?.toString() ?: ""
if (className.contains("RecyclerView") || className.contains("ListView") || className.contains("Recycler")) {
return node
}
for (i in 0 until node.childCount) {
val result = findRecyclerView(node.getChild(i))
if (result != null) return result
}
return null
}
private fun detectTaxiFPriceScreen(node: android.view.accessibility.AccessibilityNodeInfo?): Boolean {
val hasConfirmBtn = findNodeByText(node, "تحديد نقطة الانطلاق") != null
if (hasConfirmBtn) return true
val hasJOD = findNodeByText(node, "JOD") != null
val hasRideOption = findNodeByText(node, "ECO") != null ||
findNodeByText(node, "TaxiF") != null ||
findNodeByText(node, "سيارة خاصة") != null ||
findNodeByText(node, "Airport") != null ||
findNodeByText(node, "توصيل المطار") != null
return hasJOD && hasRideOption
}
private fun collectAndScrollPrices(rootNode: android.view.accessibility.AccessibilityNodeInfo) {
findAllJODPrices(rootNode, taxiFCollectedPrices)
if (taxiFPriceScrollAttempts < 3) {
val recycler = findHorizontalRecyclerView(rootNode)
if (recycler != null) {
recycler.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
taxiFPriceScrollAttempts++
Log.i(TAG, "TaxiF: Scrolled price list, attempt $taxiFPriceScrollAttempts")
}
return
}
val prices = taxiFCollectedPrices.distinct()
taxiFCollectedPrices.clear()
taxiFPriceScrollAttempts = 0
if (prices.isNotEmpty()) {
prices.forEach { (price, label) ->
Log.i(TAG, "TaxiF: Found price option: ${label.take(50)} = $price JOD")
}
val cheapest = prices.minByOrNull { it.first } ?: prices.first()
Log.i(TAG, "TaxiF: Submitting cheapest price: ${cheapest.second.take(50)} = ${cheapest.first} JOD")
submitPriceToServer("${cheapest.first} JOD")
} else {
Log.w(TAG, "TaxiF: No JOD prices found, falling back to generic search")
searchPriceByCurrency(rootNode)
}
currentState = BotState.IDLE
}
private fun findHorizontalRecyclerView(node: android.view.accessibility.AccessibilityNodeInfo?): android.view.accessibility.AccessibilityNodeInfo? {
if (node == null) return null
val className = node.className?.toString() ?: ""
if (className.contains("RecyclerView") || className.contains("HorizontalScrollView")) {
return node
}
for (i in 0 until node.childCount) {
val result = findHorizontalRecyclerView(node.getChild(i))
if (result != null) return result
}
return null
}
private fun findAllJODPrices(node: android.view.accessibility.AccessibilityNodeInfo?, prices: MutableList<Pair<Double, String>>) {
if (node == null) return
val text = node.text?.toString() ?: ""
if (text.contains("JOD")) {
val converted = arabicToWestern(text)
val match = Regex("""(\d+\.?\d*)""").find(converted)
if (match != null) {
val price = match.value.toDoubleOrNull()
if (price != null && price > 0) {
prices.add(price to text.trim())
}
}
}
for (i in 0 until node.childCount) {
findAllJODPrices(node.getChild(i), prices)
}
}
private fun handleJeenyAutomation(rootNode: android.view.accessibility.AccessibilityNodeInfo) {
val task = currentTask ?: return
Log.d(TAG, "Jeeny Automation event. State: $currentState")
@@ -644,6 +907,15 @@ class ScraperAccessibilityService : AccessibilityService() {
return list
}
private fun arabicToWestern(text: String): String {
val mapping = mapOf(
'٠' to '0', '١' to '1', '٢' to '2', '٣' to '3', '٤' to '4',
'٥' to '5', '٦' to '6', '٧' to '7', '٨' to '8', '٩' to '9',
'٫' to '.'
)
return text.map { mapping[it] ?: it }.joinToString("")
}
override fun onInterrupt() {
Log.w(TAG, "Accessibility Service Interrupted")
}