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 {
|
enum class BotState {
|
||||||
IDLE,
|
IDLE,
|
||||||
LAUNCHING_APP,
|
LAUNCHING_APP,
|
||||||
|
NAVIGATING_HOME,
|
||||||
SEARCHING_START,
|
SEARCHING_START,
|
||||||
SEARCHING_END,
|
SEARCHING_END,
|
||||||
READING_PRICE,
|
READING_PRICE,
|
||||||
@@ -33,6 +34,22 @@ class ScraperAccessibilityService : AccessibilityService() {
|
|||||||
private var currentState = BotState.IDLE
|
private var currentState = BotState.IDLE
|
||||||
private var currentTask: JSONObject? = null
|
private var currentTask: JSONObject? = null
|
||||||
private var currentAppName: String = ""
|
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() {
|
override fun onServiceConnected() {
|
||||||
super.onServiceConnected()
|
super.onServiceConnected()
|
||||||
@@ -71,15 +88,15 @@ class ScraperAccessibilityService : AccessibilityService() {
|
|||||||
|
|
||||||
Log.i(TAG, "Received Task: $taskId for App: $currentAppName")
|
Log.i(TAG, "Received Task: $taskId for App: $currentAppName")
|
||||||
|
|
||||||
|
taxiFPickupDone = false
|
||||||
|
taxiFDestinationDone = false
|
||||||
currentState = BotState.LAUNCHING_APP
|
currentState = BotState.LAUNCHING_APP
|
||||||
|
|
||||||
// Launch the App
|
// Launch the App
|
||||||
val success = AppLauncher.launchApp(this, currentAppName)
|
val success = AppLauncher.launchApp(this, currentAppName)
|
||||||
if (success) {
|
if (success) {
|
||||||
// We wait for the AccessibilityEvent to tell us the app is opened,
|
currentState = if (currentAppName == "taxif" || currentAppName == "com.taxif.passenger") BotState.NAVIGATING_HOME else BotState.SEARCHING_START
|
||||||
// but we can preemptively change state to SEARCHING_START
|
Log.i(TAG, "State -> ${currentState}")
|
||||||
currentState = BotState.SEARCHING_START
|
|
||||||
Log.i(TAG, "State -> SEARCHING_START")
|
|
||||||
} else {
|
} else {
|
||||||
// Failed to launch (app not installed)
|
// Failed to launch (app not installed)
|
||||||
Log.e(TAG, "Failed to launch app. Returning to IDLE.")
|
Log.e(TAG, "Failed to launch app. Returning to IDLE.")
|
||||||
@@ -471,37 +488,283 @@ class ScraperAccessibilityService : AccessibilityService() {
|
|||||||
val task = currentTask ?: return
|
val task = currentTask ?: return
|
||||||
Log.d(TAG, "TaxiF Automation event. State: $currentState")
|
Log.d(TAG, "TaxiF Automation event. State: $currentState")
|
||||||
when (currentState) {
|
when (currentState) {
|
||||||
BotState.SEARCHING_START -> {
|
BotState.NAVIGATING_HOME -> {
|
||||||
val pickupEdit = findEditableNode(rootNode)
|
if (hasJustClickedHome()) return
|
||||||
if (pickupEdit != null) {
|
val rihlaNode = findNodeByText(rootNode, "رحلة")
|
||||||
val startLoc = task.optString("start_location", "Amman")
|
if (rihlaNode != null) {
|
||||||
val arguments = android.os.Bundle().apply {
|
var clickable: android.view.accessibility.AccessibilityNodeInfo? = rihlaNode
|
||||||
putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, startLoc)
|
while (clickable != null && !clickable.isClickable) {
|
||||||
|
clickable = clickable.parent
|
||||||
}
|
}
|
||||||
pickupEdit.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, arguments)
|
if (clickable != null) {
|
||||||
Log.i(TAG, "TaxiF: Entered start: $startLoc")
|
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
|
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 -> {
|
BotState.SEARCHING_END -> {
|
||||||
val destEdit = findEditableNode(rootNode)
|
if (detectTaxiFPriceScreen(rootNode)) {
|
||||||
if (destEdit != null) {
|
if (hasJustClickedLocation() || hasJustClickedSuggestion()) return
|
||||||
val endLoc = task.optString("end_location", "Airport")
|
if (!taxiFDestinationDone && shouldEditLocation(rootNode, task, isPickup = false)) {
|
||||||
val arguments = android.os.Bundle().apply {
|
clickLocationRow(rootNode, isPickup = false)
|
||||||
putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, endLoc)
|
taxiFLocationClickTime = System.currentTimeMillis()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
destEdit.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, arguments)
|
taxiFDestinationDone = true
|
||||||
Log.i(TAG, "TaxiF: Entered end: $endLoc")
|
|
||||||
currentState = BotState.READING_PRICE
|
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 -> {
|
BotState.READING_PRICE -> {
|
||||||
searchPriceByCurrency(rootNode)
|
if (!hasJustClickedSuggestion()) {
|
||||||
|
collectAndScrollPrices(rootNode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else -> {}
|
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) {
|
private fun handleJeenyAutomation(rootNode: android.view.accessibility.AccessibilityNodeInfo) {
|
||||||
val task = currentTask ?: return
|
val task = currentTask ?: return
|
||||||
Log.d(TAG, "Jeeny Automation event. State: $currentState")
|
Log.d(TAG, "Jeeny Automation event. State: $currentState")
|
||||||
@@ -644,6 +907,15 @@ class ScraperAccessibilityService : AccessibilityService() {
|
|||||||
return list
|
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() {
|
override fun onInterrupt() {
|
||||||
Log.w(TAG, "Accessibility Service Interrupted")
|
Log.w(TAG, "Accessibility Service Interrupted")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user