Update: 2026-06-30 02:05:28
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.
@@ -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