|
| 1 | +@tool |
| 2 | +extends VBoxContainer |
| 3 | + |
| 4 | +const ADB_PATH := "/home/anish/Android/Sdk/platform-tools/adb" |
| 5 | +const PACKAGE_NAME := "org.godotengine.editor.v4.debug" |
| 6 | +const DATA_ROOT := "/data/data/" + PACKAGE_NAME |
| 7 | +const TMP_DIR := "/data/local/tmp" |
| 8 | +const STORAGE_ROOT := "/storage/emulated/0" |
| 9 | +const PULL_PUSH_TEMP = TMP_DIR + "/godot-device-explorer-plugin" |
| 10 | + |
| 11 | +@onready var tree: Tree = $Tree |
| 12 | +@onready var devices_btn: OptionButton = $HBoxContainer/OptionButton |
| 13 | +@onready var menu_button: MenuButton = $HBoxContainer/MenuButton |
| 14 | + |
| 15 | +var current_device := "" |
| 16 | +var show_all := false |
| 17 | + |
| 18 | + |
| 19 | +func _ready() -> void: |
| 20 | + _setup_ui() |
| 21 | + _load_devices() |
| 22 | + |
| 23 | + |
| 24 | +func _setup_ui() -> void: |
| 25 | + tree.hide_root = true |
| 26 | + tree.item_collapsed.connect(_on_item_collapsed) |
| 27 | + |
| 28 | + devices_btn.custom_minimum_size = Vector2(32, 32) |
| 29 | + devices_btn.selected = -1 |
| 30 | + devices_btn.pressed.connect(_load_devices) |
| 31 | + devices_btn.item_selected.connect(_on_device_selected) |
| 32 | + |
| 33 | + menu_button.icon = EditorInterface.get_base_control().get_theme_icon("GuiTabMenuHl", "EditorIcons") |
| 34 | + var popup := menu_button.get_popup() |
| 35 | + popup.clear() |
| 36 | + popup.add_check_item("Show Full Filesystem", 0) |
| 37 | + popup.set_item_checked(0, show_all) |
| 38 | + popup.id_pressed.connect(_on_menu_item_pressed) |
| 39 | + |
| 40 | + |
| 41 | +func _load_devices() -> void: |
| 42 | + var device_list := _get_devices() |
| 43 | + var selected = devices_btn.get_selected_id() |
| 44 | + devices_btn.clear() |
| 45 | + |
| 46 | + for d in device_list: |
| 47 | + devices_btn.add_item(d) |
| 48 | + |
| 49 | + if device_list.size() > 0: |
| 50 | + if selected != -1: |
| 51 | + devices_btn.select(selected) |
| 52 | + else: |
| 53 | + devices_btn.select(0) |
| 54 | + _on_device_selected(0) |
| 55 | + else: |
| 56 | + devices_btn.text = "No Device Found" |
| 57 | + tree.clear() |
| 58 | + |
| 59 | + |
| 60 | +func _on_device_selected(index: int) -> void: |
| 61 | + current_device = devices_btn.get_item_text(index) |
| 62 | + _load_root() |
| 63 | + |
| 64 | + |
| 65 | +func _load_root() -> void: |
| 66 | + tree.clear() |
| 67 | + var root := tree.create_item() |
| 68 | + |
| 69 | + if show_all: |
| 70 | + root.set_text(0, "/") |
| 71 | + root.set_metadata(0, {"path": "/", "is_dir": true}) |
| 72 | + _add_dummy(root) |
| 73 | + _on_dir_expanded(root) |
| 74 | + else: |
| 75 | + root.set_text(0, "Device Scopes") |
| 76 | + _create_tree_item(root, "App Data", DATA_ROOT, true) |
| 77 | + _create_tree_item(root, "Temp Storage", TMP_DIR, true) |
| 78 | + _create_tree_item(root, "Internal Storage", STORAGE_ROOT, true) |
| 79 | + |
| 80 | + _run_adb(["shell", "mkdir", PULL_PUSH_TEMP]) |
| 81 | + |
| 82 | + |
| 83 | +func _create_tree_item(parent: TreeItem, text: String, path: String, is_dir: bool, custom_icon := "", skip_dummy := false) -> TreeItem: |
| 84 | + var item := tree.create_item(parent) |
| 85 | + item.set_text(0, text) |
| 86 | + item.set_metadata(0, {"path": path, "is_dir": is_dir}) |
| 87 | + |
| 88 | + var gui := EditorInterface.get_base_control() |
| 89 | + var icon_name = custom_icon if custom_icon != "" else ("Folder" if is_dir else _get_icon_for_ext(path)) |
| 90 | + item.set_icon(0, gui.get_theme_icon(icon_name, "EditorIcons")) |
| 91 | + |
| 92 | + if is_dir: |
| 93 | + item.set_icon_modulate(0, gui.get_theme_color("accent_color", "Editor")) |
| 94 | + if not skip_dummy: _add_dummy(item) |
| 95 | + |
| 96 | + item.collapsed = true |
| 97 | + return item |
| 98 | + |
| 99 | + |
| 100 | +func _add_dummy(parent: TreeItem) -> void: |
| 101 | + var dummy := tree.create_item(parent) |
| 102 | + dummy.set_text(0, "Empty...") |
| 103 | + dummy.set_metadata(0, null) |
| 104 | + |
| 105 | + |
| 106 | +func _on_item_collapsed(item: TreeItem) -> void: |
| 107 | + if not item.collapsed: |
| 108 | + _on_dir_expanded(item) |
| 109 | + |
| 110 | + |
| 111 | +func _on_dir_expanded(item: TreeItem, refresh := false) -> void: |
| 112 | + var meta = item.get_metadata(0) |
| 113 | + if not meta or not meta.is_dir: return |
| 114 | + |
| 115 | + # Skip if already loaded (and not refreshing) |
| 116 | + if item.get_child_count() > 0 && not refresh: |
| 117 | + var first_child := item.get_child(0) |
| 118 | + if first_child.get_text(0) != "Empty...": |
| 119 | + return |
| 120 | + |
| 121 | + var path: String = meta.path |
| 122 | + |
| 123 | + # Direct access to /data is restricted by system permissions. |
| 124 | + # Manually constructing a sub-directory tree to allow navigation into accessible paths (example, app userdata or tmp). |
| 125 | + if path == "/data": |
| 126 | + var first_child := item.get_child(0) |
| 127 | + first_child.free() # remove the dummy file for it. |
| 128 | + _populate_special_data_dir.call_deferred(item) |
| 129 | + return |
| 130 | + |
| 131 | + var files: Array = _list_dir(path) |
| 132 | + if files.size() > 0: |
| 133 | + # Files are loaded, now remove the dummy file or old items. |
| 134 | + for child in item.get_children(): |
| 135 | + child.free() |
| 136 | + |
| 137 | + for f in files: |
| 138 | + var full_path = path.rstrip("/") + "/" + f.name |
| 139 | + _create_tree_item.call_deferred(item, f.name, full_path, f.is_dir) |
| 140 | + |
| 141 | + |
| 142 | +func _populate_special_data_dir(parent: TreeItem) -> void: |
| 143 | + var data_node = _create_tree_item(parent, "data", "/data/data", true, "", true) |
| 144 | + var local_node = _create_tree_item(parent, "local", "/data/local", true, "", true) |
| 145 | + var tmp_node = _create_tree_item(local_node, "tmp", TMP_DIR, true) |
| 146 | + var pkg_node = _create_tree_item(data_node, PACKAGE_NAME, DATA_ROOT, true) |
| 147 | + |
| 148 | + |
| 149 | +func _on_menu_item_pressed(id: int) -> void: |
| 150 | + if id == 0: |
| 151 | + show_all = !show_all |
| 152 | + menu_button.get_popup().set_item_checked(0, show_all) |
| 153 | + _load_root() |
| 154 | + |
| 155 | + |
| 156 | +func _run_adb(p_args: PackedStringArray) -> String: |
| 157 | + var args: PackedStringArray = [] |
| 158 | + if current_device != "": |
| 159 | + args.append_array(["-s", current_device]) |
| 160 | + args.append_array(p_args) |
| 161 | + |
| 162 | + var output := [] |
| 163 | + OS.execute(ADB_PATH, args, output, true) |
| 164 | + return output[0] if output.size() > 0 else "" |
| 165 | + |
| 166 | + |
| 167 | +func _get_devices() -> Array[String]: |
| 168 | + var raw := _run_adb(["devices"]) |
| 169 | + var lines := raw.split("\n") |
| 170 | + var result: Array[String] = [] |
| 171 | + for i in range(1, lines.size()): |
| 172 | + var parts := lines[i].strip_edges().split("\t") |
| 173 | + if parts.size() >= 2 and parts[1] == "device": |
| 174 | + result.append(parts[0]) |
| 175 | + return result |
| 176 | + |
| 177 | + |
| 178 | +func _list_dir(path: String) -> Array: |
| 179 | + var args := ["shell"] |
| 180 | + if path.begins_with(DATA_ROOT): |
| 181 | + args.append_array(["run-as", PACKAGE_NAME, "ls", "-1", "-p", path]) |
| 182 | + else: |
| 183 | + args.append("ls -1 -p '%s'" % path) |
| 184 | + |
| 185 | + var result := _run_adb(args) |
| 186 | + var files := [] |
| 187 | + for line in result.split("\n"): |
| 188 | + line = line.strip_edges() |
| 189 | + if line == "" or line.begins_with("total"): continue |
| 190 | + files.append({"name": line.rstrip("/"), "is_dir": line.ends_with("/")}) |
| 191 | + return files |
| 192 | + |
| 193 | + |
| 194 | +func _get_icon_for_ext(path: String) -> String: |
| 195 | + var ext := path.get_extension().to_lower() |
| 196 | + match ext: |
| 197 | + "gd": return "GDScript" |
| 198 | + "tscn": return "PackedScene" |
| 199 | + "res", "tres": return "Resource" |
| 200 | + "png", "jpg", "svg": return "ImageTexture" |
| 201 | + "txt", "json": return "TextFile" |
| 202 | + _: return "File" |
| 203 | + |
0 commit comments