Skip to content

Commit b3c73bd

Browse files
committedMar 5, 2025·
Andrroid inspector added
1 parent b654f8f commit b3c73bd

File tree

1 file changed

+414
-0
lines changed

1 file changed

+414
-0
lines changed
 

‎Apps/Mobile/Android_Inspector_ZeuZ.py

+414
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,414 @@
1+
import os
2+
import re
3+
import json
4+
import subprocess
5+
import xml.etree.ElementTree as ET
6+
import tkinter as tk
7+
from tkinter import ttk, messagebox
8+
from PIL import Image, ImageTk, ImageDraw
9+
10+
ADB_PATH = "adb" # Ensure ADB is in PATH
11+
LOGO_PATH = "zeuzlogo.png" # Logo file in the same directory as the script
12+
SCREENSHOT_PATH = "screen.png"
13+
UI_XML_PATH = "ui.xml"
14+
15+
16+
def run_adb_command(command):
17+
"""Run an ADB command and return the output."""
18+
try:
19+
result = subprocess.run(command, shell=True, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
20+
return result.stdout.strip()
21+
except subprocess.CalledProcessError as e:
22+
return f"Error: {e.stderr.strip()}"
23+
24+
25+
def capture_ui_dump():
26+
"""Capture the current UI hierarchy from the device and take a screenshot."""
27+
run_adb_command(f"{ADB_PATH} shell uiautomator dump /sdcard/ui.xml")
28+
run_adb_command(f"{ADB_PATH} pull /sdcard/ui.xml {UI_XML_PATH}")
29+
run_adb_command(f"{ADB_PATH} shell screencap -p /sdcard/screen.png")
30+
run_adb_command(f"{ADB_PATH} pull /sdcard/screen.png {SCREENSHOT_PATH}")
31+
update_treeview()
32+
update_screenshot(None)
33+
34+
35+
def update_treeview():
36+
"""Update the UI element tree."""
37+
global elements_map # Use a global variable to store the mapping of bounds to treeview items
38+
elements_map = {} # Reset the mapping
39+
elements = parse_ui_xml(UI_XML_PATH)
40+
tree.delete(*tree.get_children())
41+
flat_tree.delete(*flat_tree.get_children())
42+
populate_treeview(tree, "", elements)
43+
populate_flat_treeview(flat_tree, elements)
44+
45+
46+
def populate_treeview(tree, parent, elements):
47+
"""Recursively populate the treeview with hierarchical data."""
48+
for element in elements:
49+
# Build the node text dynamically based on available fields
50+
node_text_parts = [element['class']]
51+
if element['resource-id']:
52+
node_text_parts.append(f"ID: {element['resource-id']}")
53+
if element['text']:
54+
node_text_parts.append(f"Text: {element['text']}")
55+
node_text_parts.append(f"Bounds: {element['bounds']}") # Always include bounds
56+
node_text = " - ".join(node_text_parts)
57+
58+
# Insert the item into the treeview and tag it with the individual XML string
59+
node_id = tree.insert(parent, "end", text=node_text, tags=(element["xml"],))
60+
if element["bounds"]: # Only map valid bounds
61+
elements_map[element["bounds"]] = node_id # Map bounds to treeview item ID
62+
if "children" in element:
63+
populate_treeview(tree, node_id, element["children"])
64+
65+
66+
def populate_flat_treeview(tree, elements):
67+
"""Populate the flat treeview with all elements in a tabular format."""
68+
for element in elements:
69+
# Extract the required fields for the flat view
70+
class_name = element['class']
71+
resource_id = element['resource-id']
72+
text = element['text']
73+
74+
# Add clickable/editable status to the flat view
75+
clickable = "Yes" if element.get('clickable') == "true" else "No"
76+
editable = "Yes" if element.get('editable') == "true" else "No"
77+
78+
# Insert the item into the flat treeview
79+
tree.insert("", "end", values=(class_name, resource_id, text, clickable, editable), tags=(element["xml"],))
80+
if element["bounds"]: # Only map valid bounds
81+
elements_map[element["bounds"]] = tree.get_children()[-1] # Map bounds to treeview item ID
82+
if "children" in element:
83+
populate_flat_treeview(tree, element["children"])
84+
85+
86+
def parse_ui_xml(xml_file):
87+
"""Parse the UI XML and extract elements in a hierarchical structure."""
88+
if not os.path.exists(xml_file):
89+
return []
90+
tree_xml = ET.parse(xml_file)
91+
root = tree_xml.getroot()
92+
93+
def extract_elements(node):
94+
# Create a shallow copy of the node (without children)
95+
shallow_node = ET.Element(node.tag, attrib=node.attrib)
96+
element = {
97+
"class": node.attrib.get("class", "Unknown"),
98+
"resource-id": node.attrib.get("resource-id", ""),
99+
"text": node.attrib.get("text", ""),
100+
"bounds": node.attrib.get("bounds", ""), # Extract bounds attribute
101+
"clickable": node.attrib.get("clickable", "false"),
102+
"editable": node.attrib.get("editable", "false"),
103+
"xml": ET.tostring(shallow_node, encoding="unicode"), # Store the XML of the individual node (no children)
104+
"children": []
105+
}
106+
for child in node:
107+
element["children"].append(extract_elements(child))
108+
return element
109+
110+
return [extract_elements(root)]
111+
112+
113+
114+
def generate_zeuz_action(element_xml, action_type, action_value="Sample Text"):
115+
"""
116+
Generate a Zeuz action in the required format.
117+
:param element_xml: The XML string of the selected element.
118+
:param action_type: The type of action to perform (e.g., "text", "click").
119+
:param action_value: The value associated with the action (e.g., "John", "click").
120+
:return: A Zeuz action in the required format (JSON with double quotes).
121+
"""
122+
# Parse the XML to extract attributes using regex
123+
element = {}
124+
pattern = r'([\w:.]+)="([^"]*)"'
125+
matches = re.findall(pattern, element_xml)
126+
127+
# Debugging: Print all parsed attributes
128+
print("Parsed Attributes:", matches)
129+
130+
for key, value in matches:
131+
element[key] = value
132+
133+
# Debugging: Print the final parsed dictionary
134+
print("Final Parsed Element:", element)
135+
136+
# Determine the locator type and value
137+
if element.get("resource-id") or element.get("id"):
138+
locator_type = "resource-id" # Always use "resource-id" as the locator type
139+
raw_locator_value = element.get("resource-id", element.get("id")) # Use "id" as fallback
140+
141+
# Clean up the locator value: Extract the part after the last slash
142+
locator_value = raw_locator_value.split("/")[-1]
143+
elif element.get("text"):
144+
locator_type = "text"
145+
locator_value = element["text"] # Preserve the full text
146+
else:
147+
raise ValueError("Element must have either 'resource-id', 'id', or 'text' to generate an action.")
148+
149+
# Set the action value based on the action type
150+
if action_type == "click":
151+
action_value = "click"
152+
153+
# Construct the Zeuz action
154+
zeuz_action = [
155+
{
156+
"action_name": "None",
157+
"action_disabled": "false",
158+
"step_actions": [
159+
[locator_type, "element parameter", locator_value],
160+
[action_type, "appium action", action_value]
161+
]
162+
}
163+
]
164+
165+
# Return the action as a JSON-formatted string with double quotes
166+
return json.dumps(zeuz_action)
167+
168+
169+
def copy_zeuz_action():
170+
"""Generate and copy the Zeuz action for the selected element."""
171+
selected_item = tree.selection() if view_mode.get() == "tree" else flat_tree.selection()
172+
if not selected_item:
173+
messagebox.showwarning("No Selection", "Please select an element first.")
174+
return
175+
176+
# Retrieve the full XML of the selected element
177+
full_xml = tree.item(selected_item, "tags")[0] if view_mode.get() == "tree" else flat_tree.item(selected_item, "tags")[0]
178+
179+
# Get the selected action from the dropdown
180+
action_type = action_var.get()
181+
action_value = "Sample Text" if action_type == "text" else "click"
182+
183+
# Generate the Zeuz action using the full XML
184+
try:
185+
zeuz_action = generate_zeuz_action(full_xml, action_type, action_value)
186+
except ValueError as e:
187+
messagebox.showerror("Error", str(e))
188+
return
189+
190+
# Copy the action to the clipboard
191+
root.clipboard_clear()
192+
root.clipboard_append(str(zeuz_action))
193+
root.update() # Ensure the clipboard is updated
194+
195+
# Display a confirmation message
196+
messagebox.showinfo("Action Copied", "Zeuz action copied to clipboard!")
197+
198+
199+
def toggle_view():
200+
"""Toggle between tree view and flat view."""
201+
if view_mode.get() == "tree":
202+
tree_frame.pack_forget() # Hide tree view
203+
flat_tree_frame.pack(fill="both", expand=True) # Show flat view
204+
view_mode.set("flat")
205+
else:
206+
flat_tree_frame.pack_forget() # Hide flat view
207+
tree_frame.pack(fill="both", expand=True) # Show tree view
208+
view_mode.set("tree")
209+
update_screenshot(None)
210+
211+
212+
def update_screenshot(event=None):
213+
"""Update and highlight only the selected element in the screenshot."""
214+
if os.path.exists(SCREENSHOT_PATH):
215+
img = Image.open(SCREENSHOT_PATH)
216+
draw = ImageDraw.Draw(img)
217+
selected_item = tree.selection() if view_mode.get() == "tree" else flat_tree.selection()
218+
if selected_item:
219+
# Retrieve the full XML of the selected element
220+
full_xml = tree.item(selected_item, "tags")[0] if view_mode.get() == "tree" else flat_tree.item(selected_item, "tags")[0]
221+
222+
# Extract bounds from the full XML
223+
bounds = extract_bounds_from_xml(full_xml)
224+
coords = extract_coordinates(bounds) if bounds else None
225+
if coords:
226+
x1, y1, x2, y2 = coords
227+
draw.rectangle([x1, y1, x2, y2], outline="red", width=3)
228+
229+
# Display the full XML content in the bottom panel
230+
full_text_label.delete(1.0, tk.END) # Clear previous content
231+
full_text_label.insert(tk.END, full_xml) # Insert new content
232+
233+
img.thumbnail((500, 500)) # Resize the image for display
234+
img = ImageTk.PhotoImage(img)
235+
screenshot_label.config(image=img)
236+
screenshot_label.image = img
237+
238+
239+
def extract_bounds_from_xml(xml_string):
240+
"""Extract the bounds attribute from the full XML string."""
241+
start = xml_string.find('bounds="') + len('bounds="')
242+
end = xml_string.find('"', start)
243+
return xml_string[start:end] if start != -1 and end != -1 else None
244+
245+
246+
def extract_coordinates(bounds):
247+
"""Extract x1, y1, x2, y2 coordinates from the bounds string."""
248+
if not bounds:
249+
return None
250+
parts = bounds.replace("[", "").replace("]", ",").split(",")
251+
try:
252+
return int(parts[0]), int(parts[1]), int(parts[2]), int(parts[3])
253+
except (ValueError, IndexError):
254+
return None
255+
256+
257+
def on_image_click(event):
258+
"""Handle clicks on the screenshot image."""
259+
if not os.path.exists(SCREENSHOT_PATH):
260+
return
261+
img = Image.open(SCREENSHOT_PATH)
262+
img_width, img_height = img.size
263+
thumbnail_width, thumbnail_height = 500, 500 # Thumbnail size
264+
scale_x = img_width / thumbnail_width
265+
scale_y = img_height / thumbnail_height
266+
267+
# Scale the click coordinates back to the original image dimensions
268+
x = int(event.x * scale_x)
269+
y = int(event.y * scale_y)
270+
271+
# Find the deepest child element whose bounds contain the click coordinates
272+
selected_item = None
273+
for bounds, item_id in reversed(list(elements_map.items())): # Reverse to prioritize child nodes
274+
coords = extract_coordinates(bounds)
275+
if coords and coords[0] <= x <= coords[2] and coords[1] <= y <= coords[3]:
276+
selected_item = item_id
277+
break
278+
279+
if selected_item:
280+
# Highlight the corresponding treeview item
281+
tree.selection_set(selected_item) if view_mode.get() == "tree" else flat_tree.selection_set(selected_item)
282+
tree.see(selected_item) if view_mode.get() == "tree" else flat_tree.see(selected_item)
283+
update_screenshot()
284+
285+
286+
def tap_element():
287+
"""Tap on a selected UI element using ADB coordinates."""
288+
selected_item = tree.selection() if view_mode.get() == "tree" else flat_tree.selection()
289+
if not selected_item:
290+
messagebox.showwarning("No Selection", "Please select an element first.")
291+
return
292+
293+
# Retrieve the full XML of the selected element
294+
full_xml = tree.item(selected_item, "tags")[0] if view_mode.get() == "tree" else flat_tree.item(selected_item, "tags")[0]
295+
296+
# Extract bounds from the full XML
297+
bounds = extract_bounds_from_xml(full_xml)
298+
coords = extract_coordinates(bounds) if bounds else None
299+
if coords:
300+
x, y = (coords[0] + coords[2]) // 2, (coords[1] + coords[3]) // 2
301+
run_adb_command(f"{ADB_PATH} shell input tap {x} {y}")
302+
messagebox.showinfo("Tap Successful", f"Tapped at coordinates ({x}, {y})")
303+
304+
305+
# GUI Setup
306+
root = tk.Tk()
307+
root.title("ZeuZ Android Inspector")
308+
root.geometry("1200x800") # Increased vertical size
309+
root.configure(bg="#1E1E1E")
310+
311+
if os.path.exists(LOGO_PATH):
312+
logo_image = Image.open(LOGO_PATH)
313+
logo_image.thumbnail((100, 100))
314+
logo_photo = ImageTk.PhotoImage(logo_image)
315+
logo_label = tk.Label(root, image=logo_photo, bg="#1E1E1E")
316+
logo_label.image = logo_photo
317+
logo_label.pack(side="top", pady=10)
318+
319+
main_frame = ttk.Frame(root)
320+
main_frame.pack(fill="both", expand=True, padx=10, pady=10)
321+
322+
left_frame = ttk.Frame(main_frame)
323+
left_frame.pack(side="left", fill="both", expand=True, padx=10)
324+
325+
# Button Frame for Horizontal Layout
326+
button_frame = ttk.Frame(left_frame)
327+
button_frame.pack(fill="x", pady=5)
328+
329+
btn_capture = ttk.Button(button_frame, text="Capture UI", command=capture_ui_dump)
330+
btn_capture.pack(side="left", padx=5)
331+
332+
btn_tap = ttk.Button(button_frame, text="Tap Element", command=tap_element)
333+
btn_tap.pack(side="left", padx=5)
334+
335+
btn_toggle_view = ttk.Button(button_frame, text="Toggle View", command=toggle_view)
336+
btn_toggle_view.pack(side="left", padx=5)
337+
338+
# View mode (Tree or Flat)
339+
view_mode = tk.StringVar(value="tree")
340+
341+
# Treeview (Hierarchical View)
342+
tree_frame = ttk.Frame(left_frame)
343+
tree_frame.pack(fill="both", expand=True)
344+
tree = ttk.Treeview(tree_frame, show="tree")
345+
tree.pack(fill="both", expand=True)
346+
tree.bind("<<TreeviewSelect>>", lambda event: update_screenshot())
347+
348+
# Flat Treeview (Flat View)
349+
flat_tree_frame = ttk.Frame(left_frame)
350+
flat_tree = ttk.Treeview(flat_tree_frame, columns=("Class", "Resource-ID", "Text", "Clickable", "Editable"), show="headings")
351+
flat_tree.heading("Class", text="Class")
352+
flat_tree.heading("Resource-ID", text="Resource-ID")
353+
flat_tree.heading("Text", text="Text")
354+
flat_tree.heading("Clickable", text="Clickable")
355+
flat_tree.heading("Editable", text="Editable")
356+
flat_tree.column("Class", width=200, anchor="w")
357+
flat_tree.column("Resource-ID", width=200, anchor="w")
358+
flat_tree.column("Text", width=400, anchor="w")
359+
flat_tree.column("Clickable", width=80, anchor="center")
360+
flat_tree.column("Editable", width=80, anchor="center")
361+
flat_tree.pack(fill="both", expand=True)
362+
flat_tree.bind("<<TreeviewSelect>>", lambda event: update_screenshot())
363+
364+
# Right Panel (Screenshot Display)
365+
right_frame = ttk.Frame(main_frame)
366+
right_frame.pack(side="right", fill="both", expand=True, pady=5)
367+
368+
screenshot_label = tk.Label(right_frame, bg="#1E1E1E")
369+
screenshot_label.pack()
370+
screenshot_label.bind("<Button-1>", on_image_click) # Bind left mouse click to the image
371+
372+
# Action Buttons Below the Screenshot
373+
action_frame = ttk.Frame(right_frame)
374+
action_frame.pack(fill="x", pady=5)
375+
376+
# Action Dropdown
377+
action_var = tk.StringVar(value="click")
378+
action_dropdown = ttk.Combobox(action_frame, textvariable=action_var, state="readonly", width=10)
379+
action_dropdown["values"] = ("click", "text")
380+
action_dropdown.pack(side="left", padx=5)
381+
382+
# Copy Zeuz Action Button
383+
btn_copy_action = ttk.Button(action_frame, text="Copy Zeuz Action", command=copy_zeuz_action)
384+
btn_copy_action.pack(side="left", padx=5)
385+
386+
# Bottom Panel (Full Text Display)
387+
bottom_frame = ttk.Frame(root)
388+
bottom_frame.pack(side="bottom", fill="both", expand=True, padx=10, pady=5)
389+
390+
bold_font = ("Arial", 10, "bold")
391+
full_text_label = tk.Text(
392+
bottom_frame,
393+
bg="#2E2E2E",
394+
fg="white",
395+
relief="groove",
396+
padx=10,
397+
pady=5,
398+
font=bold_font,
399+
wrap="word",
400+
height=10 # Increased height for better visibility
401+
)
402+
full_text_label.pack(fill="both", expand=True)
403+
404+
# Start with tree view
405+
tree_frame.pack(fill="both", expand=True)
406+
flat_tree_frame.pack_forget()
407+
408+
# Global variable to store the mapping of bounds to treeview items
409+
elements_map = {}
410+
411+
# Auto-capture UI on startup
412+
capture_ui_dump()
413+
414+
root.mainloop()

0 commit comments

Comments
 (0)
Please sign in to comment.