Update: 2026-06-30 02:05:28
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user