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