diff --git a/android_bot/.gradle/8.13/executionHistory/executionHistory.bin b/android_bot/.gradle/8.13/executionHistory/executionHistory.bin index 469cd254..66eeb691 100644 Binary files a/android_bot/.gradle/8.13/executionHistory/executionHistory.bin and b/android_bot/.gradle/8.13/executionHistory/executionHistory.bin differ diff --git a/android_bot/.gradle/8.13/executionHistory/executionHistory.lock b/android_bot/.gradle/8.13/executionHistory/executionHistory.lock index 4ac5b306..1b3f1fd9 100644 Binary files a/android_bot/.gradle/8.13/executionHistory/executionHistory.lock and b/android_bot/.gradle/8.13/executionHistory/executionHistory.lock differ diff --git a/android_bot/.gradle/8.13/fileHashes/fileHashes.bin b/android_bot/.gradle/8.13/fileHashes/fileHashes.bin index 14f3301c..80921cf5 100644 Binary files a/android_bot/.gradle/8.13/fileHashes/fileHashes.bin and b/android_bot/.gradle/8.13/fileHashes/fileHashes.bin differ diff --git a/android_bot/.gradle/8.13/fileHashes/fileHashes.lock b/android_bot/.gradle/8.13/fileHashes/fileHashes.lock index 9273e8fb..926142d7 100644 Binary files a/android_bot/.gradle/8.13/fileHashes/fileHashes.lock and b/android_bot/.gradle/8.13/fileHashes/fileHashes.lock differ diff --git a/android_bot/.gradle/8.13/fileHashes/resourceHashesCache.bin b/android_bot/.gradle/8.13/fileHashes/resourceHashesCache.bin index e4bcf8e8..e4f3e863 100644 Binary files a/android_bot/.gradle/8.13/fileHashes/resourceHashesCache.bin and b/android_bot/.gradle/8.13/fileHashes/resourceHashesCache.bin differ diff --git a/android_bot/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/android_bot/.gradle/buildOutputCleanup/buildOutputCleanup.lock index d61de71a..1df63993 100644 Binary files a/android_bot/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/android_bot/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/android_bot/.gradle/buildOutputCleanup/outputFiles.bin b/android_bot/.gradle/buildOutputCleanup/outputFiles.bin index b2e93cb5..5e180feb 100644 Binary files a/android_bot/.gradle/buildOutputCleanup/outputFiles.bin and b/android_bot/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/android_bot/.gradle/file-system.probe b/android_bot/.gradle/file-system.probe index 964ca61f..4a66b290 100644 Binary files a/android_bot/.gradle/file-system.probe and b/android_bot/.gradle/file-system.probe differ diff --git a/android_bot/app/src/main/java/com/siro/android_bot/service/ScraperAccessibilityService.kt b/android_bot/app/src/main/java/com/siro/android_bot/service/ScraperAccessibilityService.kt index 1e54437a..2403efa1 100644 --- a/android_bot/app/src/main/java/com/siro/android_bot/service/ScraperAccessibilityService.kt +++ b/android_bot/app/src/main/java/com/siro/android_bot/service/ScraperAccessibilityService.kt @@ -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>() + 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 { + val texts = mutableListOf() + collectLocationTexts(node, texts) + return texts + } + + private fun collectLocationTexts(node: android.view.accessibility.AccessibilityNodeInfo?, texts: MutableList) { + 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 = mutableListOf()): List { + 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>) { + 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") }