From 0414d2b4595235baaf4894b5f3c72c01f638217e Mon Sep 17 00:00:00 2001 From: MG1937 <2586364982@qq.com> Date: Tue, 13 Oct 2026 16:49:21 +0800 Subject: [PATCH 1/3] add superclass and interface type idxs property in tinydex, add feature for finding extends and impl --- .../asc_core/findrefs/findrefs_manager.py | 22 +++++++- .../asc_core/findrefs/locator/base_locator.py | 2 +- .../findrefs/locator/field_locator.py | 28 +--------- .../findrefs/locator/method_locator.py | 30 +---------- .../asc_core/findrefs/locator/type_locator.py | 24 ++++++++- .../findrefs/scan/class_def_item_scan.py | 51 +++++++++++++++++++ droidasc/asc_core/utils/tinydex.py | 22 ++++++++ 7 files changed, 120 insertions(+), 59 deletions(-) create mode 100644 droidasc/asc_core/findrefs/scan/class_def_item_scan.py diff --git a/droidasc/asc_core/findrefs/findrefs_manager.py b/droidasc/asc_core/findrefs/findrefs_manager.py index 84dc87b..4444c6c 100644 --- a/droidasc/asc_core/findrefs/findrefs_manager.py +++ b/droidasc/asc_core/findrefs/findrefs_manager.py @@ -5,6 +5,7 @@ from .locator.string_locator import StringLocator from .locator.type_locator import TypeLocator from .scan.code_item_scan import CodeItemScanner +from .scan.class_def_item_scan import ClassDefItemScan class FindRefManager: @@ -17,6 +18,7 @@ def __init__(self, dex, debug = False): self.field_locator = None self.insn_locator = None self.code_scanner = None + self.class_def_item_scanner = None def _get_str_locator(self, build = False): locator = self.str_locator @@ -33,7 +35,7 @@ def _get_type_locator(self, build = False): if locator is None: locator = TypeLocator(self.dex) locator.set_debug(self.debug) - locator.set_str_locator(self._get_str_locator(True)) + locator.set_str_locator(self._get_str_locator(build)) self.type_locator = locator if build and not locator.parsed: locator._build_map() @@ -78,9 +80,25 @@ def _get_code_scanner(self): self.code_scanner = scanner return scanner - # find struct: {"string" : "foo", "type" : "bar", "method" : {...}, "field" : {...}} + def _get_class_def_item_scanner(self): + scanner = self.class_def_item_scanner + if scanner is None: + # precisly locate type id, no need to build string/type map + scanner = ClassDefItemScan(self._get_type_locator(False)) + self.class_def_item_scanner = scanner + return scanner + + # find struct: {"string" : "foo", "type" : "bar", "method" : {...}, "field" : {...}, "extends" : "Lfoo;"} # locate result will overwrite input find in-place, then scanner writes ref mids back into each key + # extends writes class idx back into find and returns directly, dont scan code item def find_ref(self, find : dict, mark = False): + if "extends" in find: + find["extends"] = self._get_class_def_item_scanner().scan_extends(find["extends"]) + return find["extends"] + if "impl" in find: + find["impl"] = self._get_class_def_item_scanner().scan_implements(find["impl"]) + return find["impl"] + has_idx = False if "string" in find: find["string"] = self._get_str_locator(True).locate(find["string"]) diff --git a/droidasc/asc_core/findrefs/locator/base_locator.py b/droidasc/asc_core/findrefs/locator/base_locator.py index 144eed8..d001ee4 100644 --- a/droidasc/asc_core/findrefs/locator/base_locator.py +++ b/droidasc/asc_core/findrefs/locator/base_locator.py @@ -1,4 +1,4 @@ -from ...utils.tinydex import DEX +from ...utils.tinydex import DEX import time # Auth: MG1937 diff --git a/droidasc/asc_core/findrefs/locator/field_locator.py b/droidasc/asc_core/findrefs/locator/field_locator.py index 4a6bcf5..a507418 100644 --- a/droidasc/asc_core/findrefs/locator/field_locator.py +++ b/droidasc/asc_core/findrefs/locator/field_locator.py @@ -17,7 +17,7 @@ def __init__(self, dex): self.field_maps = defaultdict(set) # {name_idx : {field_idx, ...}} def set_str_locator(self, locator): - self.str_locator = locator + self.str_locator = locator def set_type_locator(self, locator): self.type_locator = locator @@ -40,30 +40,6 @@ def _build_map(self): self.parsed = True self._debug_log("build_map", t_start, field_ids_size) - # which is different with type locator, this func for precise clz name - # while type locator is for fuzzy search - def _find_type_idx_precisely(self, clz : str): - left = 0 - type_ids_off, type_ids_size = self.header.types - right = type_ids_size - 1 - buf = self.buf - strings = self.dex.strings - # see MethodLocator._find_type_idx_precisely: type_ids is sorted by UTF-16 - # code unit, so the search has to compare in that order, not Python's - key = clz.encode('utf-16-be', 'surrogatepass') - - while left <= right: - mid = (left + right) >> 1 - desc_idx = _STRUCT_I.unpack_from(buf, type_ids_off + (mid << 2))[0] - desc = strings[desc_idx] - if desc == clz: - return mid - if desc.encode('utf-16-be', 'surrogatepass') < key: - left = mid + 1 - else: - right = mid - 1 - return -1 - def _collect_clz_fids(self, type_idxs) -> set: ret = set() clz_maps = self.clz_maps @@ -129,7 +105,7 @@ def locate(self, find : dict) -> set: return ret if clz_precise: - type_idx = self._find_type_idx_precisely(clz) + type_idx = self.type_locator._find_type_idx_precisely(clz) if type_idx == -1: self._debug_log("locate", t_start, 0) return set() diff --git a/droidasc/asc_core/findrefs/locator/method_locator.py b/droidasc/asc_core/findrefs/locator/method_locator.py index f46bcfe..e28e28a 100644 --- a/droidasc/asc_core/findrefs/locator/method_locator.py +++ b/droidasc/asc_core/findrefs/locator/method_locator.py @@ -39,34 +39,6 @@ def _build_map(self): self.parsed = True self._debug_log("build_map", t_start, method_ids_size) - # which is different with type locator, this func for precise clz name - # while type locator is for fuzzy search - def _find_type_idx_precisely(self, clz : str): - left = 0 - type_ids_off, type_ids_size = self.header.types - right = type_ids_size - 1 - buf = self.buf - strings = self.dex.strings - # type_ids is sorted by UTF-16 code unit, which is not Python's order above - # the BMP: a surrogate pair is 0xD800-0xDFFF, so U+10000 sorts before - # U+E000, while Python compares those two the other way round. Comparing - # string values prunes the wrong half and misses entries; comparing the - # UTF-16BE encoding compares in the table's own order. surrogatepass because - # a malformed dex can decode to lone surrogates, which utf-16 refuses. - key = clz.encode('utf-16-be', 'surrogatepass') - - while left <= right: - mid = (left + right) >> 1 - desc_idx = _STRUCT_I.unpack_from(buf, type_ids_off + (mid << 2))[0] - desc = strings[desc_idx] - if desc == clz: - return mid - if desc.encode('utf-16-be', 'surrogatepass') < key: - left = mid + 1 - else: - right = mid - 1 - return -1 - def _collect_clz_mids(self, type_idxs) -> set: ret = set() clz_maps = self.clz_maps @@ -133,7 +105,7 @@ def locate(self, find : dict) -> set: return ret if clz_precise: - type_idx = self._find_type_idx_precisely(clz) + type_idx = self.type_locator._find_type_idx_precisely(clz) if type_idx == -1: self._debug_log("locate", t_start, 0) return set() diff --git a/droidasc/asc_core/findrefs/locator/type_locator.py b/droidasc/asc_core/findrefs/locator/type_locator.py index 9d770b1..5d5b547 100644 --- a/droidasc/asc_core/findrefs/locator/type_locator.py +++ b/droidasc/asc_core/findrefs/locator/type_locator.py @@ -12,10 +12,32 @@ def __init__(self, dex): self.str_locator = None self.parsed = False self.type_maps = defaultdict(set) # {str_idx : {type_idx, ...}} - + def set_str_locator(self, locator): self.str_locator = locator + def _find_type_idx_precisely(self, clz : str): + left = 0 + type_ids_off, type_ids_size = self.header.types + right = type_ids_size - 1 + buf = self.buf + strings = self.dex.strings + # see MethodLocator._find_type_idx_precisely: type_ids is sorted by UTF-16 + # code unit, so the search has to compare in that order, not Python's + key = clz.encode('utf-16-be', 'surrogatepass') + + while left <= right: + mid = (left + right) >> 1 + desc_idx = _STRUCT_I.unpack_from(buf, type_ids_off + (mid << 2))[0] + desc = strings[desc_idx] + if desc == clz: + return mid + if desc.encode('utf-16-be', 'surrogatepass') < key: + left = mid + 1 + else: + right = mid - 1 + return -1 + def _build_map(self): if self.parsed: return diff --git a/droidasc/asc_core/findrefs/scan/class_def_item_scan.py b/droidasc/asc_core/findrefs/scan/class_def_item_scan.py new file mode 100644 index 0000000..29dba8e --- /dev/null +++ b/droidasc/asc_core/findrefs/scan/class_def_item_scan.py @@ -0,0 +1,51 @@ +# Auth: MG193.7 +# currently which is for subclass or subimplements finding 20261012 + +from ..locator.type_locator import TypeLocator +import re +import struct + +_STRUCT_I = struct.Struct(' list: + target_type_idx = self.type_locator._find_type_idx_precisely(target_clz) + if target_type_idx == -1: + return [] + + pattern = re.compile(re.escape(_STRUCT_I.pack(target_type_idx))) + ret = [] + for match in pattern.finditer(self.submem): + class_def_off = match.start() - 0x8 # goto start offset of class_def_item + if class_def_off & 0x1f: # check for 0x20 alignment + continue + class_idx = _STRUCT_I.unpack_from(self.submem, class_def_off)[0] + ret.append(class_idx) + return ret + + def scan_implements(self, target_clz: str) -> list: + target_type_idx = self.type_locator._find_type_idx_precisely(target_clz) + if target_type_idx == -1: + return [] + + ret = [] + # https://source.android.com/docs/core/runtime/dex-format#class-def-item + # interfaces_off field offset inside a 0x20 class_def_item = 3 * 0x4 + for class_index in range(self.class_def_item_size): + if _STRUCT_I.unpack_from(self.submem, class_index * 0x20 + 3 * 0x4)[0] == 0: + continue + clazz = self.type_locator.dex.classes[class_index] + if target_type_idx in clazz.interface_type_idxs: + ret.append(clazz.class_idx) + + return ret diff --git a/droidasc/asc_core/utils/tinydex.py b/droidasc/asc_core/utils/tinydex.py index 06aaca8..d98d409 100644 --- a/droidasc/asc_core/utils/tinydex.py +++ b/droidasc/asc_core/utils/tinydex.py @@ -327,6 +327,28 @@ def fields(self): self._parse_class_data() return self._fields + @property + def superclass_idx(self): + if not hasattr(self, '_superclass_idx'): + self._superclass_idx = _STRUCT_I.unpack_from(self.dex.buf, self._class_def_off + 8)[0] + return self._superclass_idx + + @property + def interface_type_idxs(self) -> list: + if not hasattr(self, '_interface_type_idxs'): + interfaces_off = _STRUCT_I.unpack_from(self.dex.buf, self._class_def_off + 12)[0] + if interfaces_off == 0: + self._interface_type_idxs = [] + else: + size = _STRUCT_I.unpack_from(self.dex.buf, interfaces_off)[0] + idxs = [] + off = interfaces_off + 4 + for _ in range(size): + idxs.append(_STRUCT_H.unpack_from(self.dex.buf, off)[0]) + off += 2 + self._interface_type_idxs = idxs + return self._interface_type_idxs + class DEX: @staticmethod def parse(buf, name=""): From 19affadbe1f338dcb340442b23ec55da053d0705 Mon Sep 17 00:00:00 2001 From: MG1937 <2586364982@qq.com> Date: Tue, 13 Oct 2026 20:47:38 +0800 Subject: [PATCH 2/3] adjust test for _find_type_idx_precisely --- tests/test_source_edit.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_source_edit.py b/tests/test_source_edit.py index 1568496..e625681 100644 --- a/tests/test_source_edit.py +++ b/tests/test_source_edit.py @@ -449,12 +449,10 @@ def test_precise_class_lookup_survives_utf16_code_unit_order(self): names = ['Ll/\U00010000;', 'Ll/;'] dex = DEX.parse(memoryview(bytes(_make_class_dex(names))), 'utf16-order.dex') - method = FindRefManager(dex)._get_method_locator(True) - field = FindRefManager(dex)._get_field_locator(True) + locator = FindRefManager(dex)._get_type_locator() for index, name in enumerate(names): with self.subTest(name=name): - self.assertEqual(method._find_type_idx_precisely(name), index) - self.assertEqual(field._find_type_idx_precisely(name), index) + self.assertEqual(locator._find_type_idx_precisely(name), index) def test_exact_field_locator_does_not_use_substring_matching(self): from dex_fixture import make_static_field_dex From d3c05a201a997b9690ca394761d72ba38a727c03 Mon Sep 17 00:00:00 2001 From: MG1937 <2586364982@qq.com> Date: Wed, 14 Oct 2026 01:27:58 +0800 Subject: [PATCH 3/3] refactor and decouple the asc_client to improve readability --- .github/workflows/tests.yml | 4 + droidasc/asc_client/apk/__init__.py | 1 + droidasc/asc_client/apk/dex_reader.py | 149 ++ droidasc/asc_client/apk/loader.py | 144 ++ droidasc/asc_client/apk/worker.py | 111 ++ droidasc/asc_client/apk_handler.py | 557 ++---- droidasc/asc_client/asc_handler.py | 10 + droidasc/asc_client/dex_container.py | 11 + droidasc/asc_client/gui/app.py | 1555 ++--------------- droidasc/asc_client/gui/backend/__init__.py | 5 + droidasc/asc_client/gui/backend/search.py | 553 ++++++ droidasc/asc_client/gui/backend/store.py | 257 +++ .../{search_worker.py => backend/worker.py} | 7 +- droidasc/asc_client/gui/editor/__init__.py | 5 + droidasc/asc_client/gui/editor/controller.py | 536 ++++++ droidasc/asc_client/gui/editor/source.py | 532 ++++++ droidasc/asc_client/gui/editor/view.py | 512 ++++++ droidasc/asc_client/gui/runtime.py | 605 ------- droidasc/asc_client/gui/settings.py | 39 - droidasc/asc_client/gui/source_edit.py | 381 ---- droidasc/asc_client/gui/state.py | 19 + droidasc/asc_client/gui/text_utils.py | 102 -- droidasc/asc_client/gui/ui/__init__.py | 10 + droidasc/asc_client/gui/ui/class_tree.py | 154 ++ droidasc/asc_client/gui/ui/dialogs.py | 331 ++++ .../gui/{widgets.py => ui/tab_bar.py} | 122 +- droidasc/asc_client/gui/{ => ui}/theme.py | 14 +- droidasc/asc_client/manifest_handler.py | 2 + tests/TESTING.md | 11 +- tests/run_tests.py | 28 +- tests/test_apk_modules.py | 137 ++ tests/test_decompiler.py | 4 +- tests/test_gui_backend.py | 300 ++++ tests/test_gui_editor.py | 414 +++++ tests/test_gui_views.py | 357 ++++ tests/test_source_edit.py | 185 +- 36 files changed, 5036 insertions(+), 3128 deletions(-) create mode 100644 droidasc/asc_client/apk/__init__.py create mode 100644 droidasc/asc_client/apk/dex_reader.py create mode 100644 droidasc/asc_client/apk/loader.py create mode 100644 droidasc/asc_client/apk/worker.py create mode 100644 droidasc/asc_client/gui/backend/__init__.py create mode 100644 droidasc/asc_client/gui/backend/search.py create mode 100644 droidasc/asc_client/gui/backend/store.py rename droidasc/asc_client/gui/{search_worker.py => backend/worker.py} (77%) create mode 100644 droidasc/asc_client/gui/editor/__init__.py create mode 100644 droidasc/asc_client/gui/editor/controller.py create mode 100644 droidasc/asc_client/gui/editor/source.py create mode 100644 droidasc/asc_client/gui/editor/view.py delete mode 100644 droidasc/asc_client/gui/runtime.py delete mode 100644 droidasc/asc_client/gui/settings.py delete mode 100644 droidasc/asc_client/gui/source_edit.py create mode 100644 droidasc/asc_client/gui/state.py delete mode 100644 droidasc/asc_client/gui/text_utils.py create mode 100644 droidasc/asc_client/gui/ui/__init__.py create mode 100644 droidasc/asc_client/gui/ui/class_tree.py create mode 100644 droidasc/asc_client/gui/ui/dialogs.py rename droidasc/asc_client/gui/{widgets.py => ui/tab_bar.py} (85%) rename droidasc/asc_client/gui/{ => ui}/theme.py (94%) create mode 100644 tests/test_apk_modules.py create mode 100644 tests/test_gui_backend.py create mode 100644 tests/test_gui_editor.py create mode 100644 tests/test_gui_views.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d4b8ef7..a099134 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,11 +34,15 @@ jobs: - run: python -m pip install -r requirements.txt - name: Install legacy baseline dependencies for performance comparison run: python -m pip install -r .performance-base/requirements.txt + - name: Install virtual display for Tk GUI tests + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y xvfb xauth - run: python -m pip check - name: Unit tests (no skips) run: python tests/run_tests.py --require-decompiler --suite unit - name: Integration tests (no skips) run: python tests/run_tests.py --require-decompiler --suite integration + - name: GUI tests (no skips) + run: xvfb-run -a python tests/run_tests.py --require-decompiler --suite gui - name: Reject material performance regressions against the target branch run: python tests/benchmark_compare.py --baseline .performance-base --samples 31 - name: Check cold-process startup with --debug diff --git a/droidasc/asc_client/apk/__init__.py b/droidasc/asc_client/apk/__init__.py new file mode 100644 index 0000000..db4f8ac --- /dev/null +++ b/droidasc/asc_client/apk/__init__.py @@ -0,0 +1 @@ +"""Internal APK loading helpers.""" diff --git a/droidasc/asc_client/apk/dex_reader.py b/droidasc/asc_client/apk/dex_reader.py new file mode 100644 index 0000000..9bb5377 --- /dev/null +++ b/droidasc/asc_client/apk/dex_reader.py @@ -0,0 +1,149 @@ +import struct + + +_U32_FROM = struct.Struct(" int: + while buf[off] & 0x80: + off += 1 + return off + 1 + + +def _read_string_data_bytes(buf, str_off : int) -> bytes: + # The leading uleb128 stores UTF-16 code-unit count, not byte length. + # Reading to the terminating NUL is therefore required for MUTF-8. + ptr = _skip_uleb128(buf, str_off) + end = buf.find(b"\x00", ptr) + if end < 0: + raise ValueError("unterminated string_data_item") + return buf[ptr:end] + + +def _find_type_idx(buf, target_bytes : bytes) -> int: + if len(buf) < 0x70 or buf[:3] != b"dex": + return -1 + + string_ids_size = _U32_FROM(buf, 0x38)[0] + string_ids_off = _U32_FROM(buf, 0x3C)[0] + type_ids_size = _U32_FROM(buf, 0x40)[0] + type_ids_off = _U32_FROM(buf, 0x44)[0] + + if string_ids_size == 0 or type_ids_size == 0: + return -1 + if string_ids_off + string_ids_size * 4 > len(buf): + raise ValueError("bad string_ids range") + if type_ids_off + type_ids_size * 4 > len(buf): + raise ValueError("bad type_ids range") + + left = 0 + right = type_ids_size - 1 + # type_ids follows string-id order. DEX MUTF-8 bytes preserve UTF-16 + # code-unit ordering, so compare encoded descriptors without decoding. + while left <= right: + mid = (left + right) >> 1 + str_idx = _U32_FROM(buf, type_ids_off + (mid << 2))[0] + if str_idx >= string_ids_size: + raise ValueError("bad type_id->string_idx") + str_off = _U32_FROM(buf, string_ids_off + (str_idx << 2))[0] + if str_off >= len(buf): + raise ValueError("bad string_data_off") + + cls_bytes = _read_string_data_bytes(buf, str_off) + if cls_bytes == target_bytes: + return mid + if cls_bytes < target_bytes: + left = mid + 1 + else: + right = mid - 1 + + return -1 + + +def _class_defs_contains_type_idx(buf, type_idx : int) -> bool: + class_defs_size = _U32_FROM(buf, 0x60)[0] + class_defs_off = _U32_FROM(buf, 0x64)[0] + + if class_defs_size == 0: + return False + + class_defs_end = class_defs_off + (class_defs_size << 5) + if class_defs_end > len(buf): + raise ValueError("bad class_defs range") + + needle = type_idx.to_bytes(4, "little") + pos = buf.find(needle, class_defs_off, class_defs_end) + while pos != -1: + # The same four bytes may occur in another class_def field. class_idx + # is valid only at the first word of each 32-byte class_def_item. + if ((pos - class_defs_off) & 0x1F) == 0: + return True + pos = buf.find(needle, pos + 1, class_defs_end) + return False + + +def _dex_defines_class(buf, target_bytes : bytes) -> bool: + type_idx = _find_type_idx(buf, target_bytes) + if type_idx < 0: + return False + return _class_defs_contains_type_idx(buf, type_idx) + + +def _decode_name(raw_name : bytes) -> str: + try: + return raw_name.decode("utf-8") + except UnicodeDecodeError: + # Surrogate-pair MUTF-8 is invalid strict UTF-8. Import the fallback + # only for such names so ordinary listclass startup stays cheap. + from droidasc.asc_core.utils.tinydex import _decode_mutf8_fallback + + return _decode_mutf8_fallback(raw_name) + + +def list_dex_classes(buf, prefix : str = None, prefix_bytes : bytes = None): + if len(buf) < 0x70 or buf[:3] != b"dex": + raise ValueError("invalid DEX header") + + string_ids_size = _U32_FROM(buf, 0x38)[0] + string_ids_off = _U32_FROM(buf, 0x3C)[0] + type_ids_size = _U32_FROM(buf, 0x40)[0] + type_ids_off = _U32_FROM(buf, 0x44)[0] + class_defs_size = _U32_FROM(buf, 0x60)[0] + class_defs_off = _U32_FROM(buf, 0x64)[0] + + if class_defs_size == 0: + return [] + if string_ids_off + string_ids_size * 4 > len(buf): + raise ValueError("bad string_ids range") + if type_ids_off + type_ids_size * 4 > len(buf): + raise ValueError("bad type_ids range") + if class_defs_off + class_defs_size * 32 > len(buf): + raise ValueError("bad class_defs range") + + names = [] + append = names.append + class_defs_end = class_defs_off + class_defs_size * 32 + for class_def_off in range(class_defs_off, class_defs_end, 32): + type_idx = _U32_FROM(buf, class_def_off)[0] + if type_idx >= type_ids_size: + raise ValueError("bad class_def->type_idx") + string_idx = _U32_FROM(buf, type_ids_off + (type_idx << 2))[0] + if string_idx >= string_ids_size: + raise ValueError("bad type_id->string_idx") + string_off = _U32_FROM(buf, string_ids_off + (string_idx << 2))[0] + if string_off >= len(buf): + raise ValueError("bad string_data_off") + try: + raw_name = _read_string_data_bytes(buf, string_off) + except IndexError as error: + raise ValueError("bad string_data_off") from error + + # ASCII prefixes can be rejected before decoding every class name. + if prefix_bytes is not None: + if raw_name.startswith(prefix_bytes): + append(_decode_name(raw_name)) + else: + name = _decode_name(raw_name) + if prefix is None or name.startswith(prefix): + append(name) + return names diff --git a/droidasc/asc_client/apk/loader.py b/droidasc/asc_client/apk/loader.py new file mode 100644 index 0000000..96ed303 --- /dev/null +++ b/droidasc/asc_client/apk/loader.py @@ -0,0 +1,144 @@ +import mmap +import struct +import zlib + + +_U16_FROM = struct.Struct(" int: + # EOCD can only be followed by a 65535-byte ZIP comment and its 22-byte + # fixed header, so searching the whole APK is unnecessary. + search_start = max(0, len(mm) - 65536 - 22) + return mm.rfind(_EOCD_SIG, search_start) + + +def _parse_cd_dex_entries(mm : mmap.mmap): + eocd_idx = _find_eocd(mm) + if eocd_idx < 0: + raise ValueError("EOCD not found") + + cd_size = _U32_FROM(mm, eocd_idx + 12)[0] + cd_off = _U32_FROM(mm, eocd_idx + 16)[0] + cd_end = cd_off + cd_size + entries = [] + seen_names = set() + pos = cd_off + + # APKs normally name DEX files classes*.dex. Searching those bytes avoids + # walking every resource entry in a large central directory. + while True: + pos = mm.find(b"classes", pos, cd_end) + if pos < 0: + break + header_off = pos - 46 + pos += 7 + if header_off < cd_off or mm[header_off:header_off + 4] != _CD_SIG: + continue + + name_len = _U16_FROM(mm, header_off + 28)[0] + name_start = pos - 7 + name_end = name_start + name_len + if name_end > cd_end: + continue + + name_bytes = mm[name_start:name_end] + if ( + not name_bytes.endswith(_DEX_SUFFIX) + or _SLASH in name_bytes + or name_bytes in seen_names + ): + continue + + seen_names.add(name_bytes) + # name, uncompressed size, compressed size, local-header offset, + # compression method. Keep this tuple compact: it crosses processes. + entries.append(( + name_bytes.decode("utf-8", errors="ignore"), + _U32_FROM(mm, header_off + 24)[0], + _U32_FROM(mm, header_off + 20)[0], + _U32_FROM(mm, header_off + 42)[0], + _U16_FROM(mm, header_off + 10)[0], + )) + + if entries: + return entries + + # Fall back to a complete central-directory walk for valid but unusual ZIP + # layouts where the name search cannot recover an entry header. + ptr = cd_off + while ptr + 46 <= cd_end: + if mm[ptr:ptr + 4] != _CD_SIG: + break + + name_len = _U16_FROM(mm, ptr + 28)[0] + extra_len = _U16_FROM(mm, ptr + 30)[0] + comment_len = _U16_FROM(mm, ptr + 32)[0] + name_start = ptr + 46 + name_end = name_start + name_len + name_bytes = mm[name_start:name_end] + if name_bytes.endswith(_DEX_SUFFIX) and _SLASH not in name_bytes: + entries.append(( + name_bytes.decode("utf-8", errors="ignore"), + _U32_FROM(mm, ptr + 24)[0], + _U32_FROM(mm, ptr + 20)[0], + _U32_FROM(mm, ptr + 42)[0], + _U16_FROM(mm, ptr + 10)[0], + )) + + ptr = name_end + extra_len + comment_len + + return entries + + +def _inflate_deflate_chunks(comp_view, stop_event): + if stop_event is None: + # A single call is about 4% faster when cancellation is unnecessary. + return zlib.decompress(comp_view, -15) + decomp = zlib.decompressobj(-15) + out = bytearray() + pos = 0 + total = len(comp_view) + while pos < total: + if stop_event.is_set(): + return None + end = min(pos + _DEFLATE_CHUNK, total) + out.extend(decomp.decompress(comp_view[pos:end])) + pos = end + out.extend(decomp.flush()) + if stop_event.is_set(): + return None + return bytes(out) + + +def _inflate_dex(mm : mmap.mmap, entry, stop_event = None): + _name, uncomp_size, comp_size, local_header_off, comp_method = entry + if mm[local_header_off:local_header_off + 4] != _LH_SIG: + raise ValueError("bad local header signature") + + name_len = _U16_FROM(mm, local_header_off + 26)[0] + extra_len = _U16_FROM(mm, local_header_off + 28)[0] + # File data starts after the variable local filename and extra fields, not + # at the corresponding central-directory record. + data_off = local_header_off + 30 + name_len + extra_len + comp_view = memoryview(mm)[data_off:data_off + comp_size] + + if comp_method == 0: + data = bytes(comp_view) + elif comp_method == 8: + data = _inflate_deflate_chunks(comp_view, stop_event) + if data is None: + return None + else: + raise ValueError(f"unsupported compression method: {comp_method}") + + if uncomp_size and len(data) != uncomp_size: + raise ValueError(f"size mismatch: expect {uncomp_size}, got {len(data)}") + return data diff --git a/droidasc/asc_client/apk/worker.py b/droidasc/asc_client/apk/worker.py new file mode 100644 index 0000000..d3de68a --- /dev/null +++ b/droidasc/asc_client/apk/worker.py @@ -0,0 +1,111 @@ +import atexit +import mmap +import os +import sys +import threading +import time +import types + +from droidasc.asc_client.apk.loader import _inflate_dex +from droidasc.asc_client.dex_container import iter_logical_dex_buffers + + +_WORKER_APK_PATH = None +_WORKER_APK_FP = None +_WORKER_APK_MM = None + + +def _process_pool_context(): + """Use fork only when the platform and parent process make it safe.""" + if os.name == "nt": + return None + try: + import multiprocessing + + # Startup shims may leave a module-like placeholder in sys.modules. + # ProcessPoolExecutor can hang if that object is accepted as a context. + if ( + not isinstance(multiprocessing, types.ModuleType) + or not hasattr(multiprocessing, "get_context") + ): + return None + if threading.active_count() != 1: + return None + return multiprocessing.get_context("fork") + except (ImportError, ValueError, OSError): + return None + + +def _close_worker_apk(): + global _WORKER_APK_PATH, _WORKER_APK_FP, _WORKER_APK_MM + + mm = _WORKER_APK_MM + fp = _WORKER_APK_FP + _WORKER_APK_MM = None + _WORKER_APK_FP = None + _WORKER_APK_PATH = None + + if mm is not None: + mm.close() + if fp is not None: + fp.close() + + +atexit.register(_close_worker_apk) + + +def _get_worker_apk_mm(apk_path : str): + global _WORKER_APK_PATH, _WORKER_APK_FP, _WORKER_APK_MM + + mm = _WORKER_APK_MM + if mm is not None and _WORKER_APK_PATH == apk_path: + # One worker handles several DEX entries from the same APK. Reusing its + # read-only mapping avoids reopening a large archive for every task. + return mm + + _close_worker_apk() + fp = open(apk_path, "rb") + mm = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ) + _WORKER_APK_PATH = apk_path + _WORKER_APK_FP = fp + _WORKER_APK_MM = mm + return mm + + +def _findrefs_worker( + apk_path : str, + entry, + find_type : str, + find : dict, + aggregate : bool = True, +): + # Keep the decompiler graph out of CLI getclass/listclass startup. Spawned + # workers import it only after they receive a reference-search task. + from droidasc.asc_client.asc_handler import AscHandler + + mm = _get_worker_apk_mm(apk_path) + t0 = time.perf_counter() + data = _inflate_dex(mm, entry) + t1 = time.perf_counter() + lines = [] + handler = AscHandler(False) + # A physical classes*.dex entry may contain several logical DEX files in + # the DEX 041 container format; each needs an independent reference scan. + for dex_name, dex_buf in iter_logical_dex_buffers(entry[0], data): + lines.extend( + handler.findrefs( + dex_name, + dex_buf, + find_type, + find, + aggregate=aggregate, + ) + ) + t2 = time.perf_counter() + return ( + entry[0], + lines, + (t1 - t0) * 1000000, + (t2 - t1) * 1000000, + os.getpid(), + ) diff --git a/droidasc/asc_client/apk_handler.py b/droidasc/asc_client/apk_handler.py index f943455..891d655 100644 --- a/droidasc/asc_client/apk_handler.py +++ b/droidasc/asc_client/apk_handler.py @@ -1,340 +1,55 @@ -import atexit import mmap -import os -import struct import sys import threading import time -import types -import zlib -from concurrent.futures import ThreadPoolExecutor, FIRST_COMPLETED, wait - +from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait + +from droidasc.asc_client.apk.dex_reader import ( + _dex_defines_class as _defines_class, + list_dex_classes as _list_classes, +) +from droidasc.asc_client.apk.loader import ( + _inflate_dex as _load_dex, + _parse_cd_dex_entries as _parse_entries, +) from droidasc.asc_client.dex_container import iter_logical_dex_buffers -_U16 = struct.Struct(" int: - while buf[off] & 0x80: - off += 1 - return off + 1 - - -def _read_string_data_bytes(buf, str_off : int) -> bytes: - ptr = _skip_uleb128(buf, str_off) - end = buf.find(b"\x00", ptr) - if end < 0: - raise ValueError("unterminated string_data_item") - return buf[ptr:end] - - -def _find_type_idx(buf, target_bytes : bytes) -> int: - if len(buf) < 0x70 or buf[:3] != b"dex": - return -1 - - string_ids_size = _U32_FROM(buf, 0x38)[0] - string_ids_off = _U32_FROM(buf, 0x3C)[0] - type_ids_size = _U32_FROM(buf, 0x40)[0] - type_ids_off = _U32_FROM(buf, 0x44)[0] - - if string_ids_size == 0 or type_ids_size == 0: - return -1 - if string_ids_off + string_ids_size * 4 > len(buf): - raise ValueError("bad string_ids range") - if type_ids_off + type_ids_size * 4 > len(buf): - raise ValueError("bad type_ids range") - - left = 0 - right = type_ids_size - 1 - while left <= right: - mid = (left + right) >> 1 - str_idx = _U32_FROM(buf, type_ids_off + (mid << 2))[0] - if str_idx >= string_ids_size: - raise ValueError("bad type_id->string_idx") - str_off = _U32_FROM(buf, string_ids_off + (str_idx << 2))[0] - if str_off >= len(buf): - raise ValueError("bad string_data_off") - - cls_bytes = _read_string_data_bytes(buf, str_off) - if cls_bytes == target_bytes: - return mid - if cls_bytes < target_bytes: - left = mid + 1 - else: - right = mid - 1 - - return -1 - - -def _class_defs_contains_type_idx(buf, type_idx : int) -> bool: - class_defs_size = _U32_FROM(buf, 0x60)[0] - class_defs_off = _U32_FROM(buf, 0x64)[0] - - if class_defs_size == 0: - return False - - class_defs_end = class_defs_off + (class_defs_size << 5) - if class_defs_end > len(buf): - raise ValueError("bad class_defs range") - - needle = type_idx.to_bytes(4, "little") - pos = buf.find(needle, class_defs_off, class_defs_end) - while pos != -1: - if ((pos - class_defs_off) & 0x1F) == 0: - return True - pos = buf.find(needle, pos + 1, class_defs_end) - return False - - -def _dex_defines_class(buf, target_bytes : bytes) -> bool: - type_idx = _find_type_idx(buf, target_bytes) - if type_idx < 0: - return False - return _class_defs_contains_type_idx(buf, type_idx) - - -def _find_eocd(mm : mmap.mmap) -> int: - search_start = max(0, len(mm) - 65536 - 22) - return mm.rfind(_EOCD_SIG, search_start) - - -def _parse_cd_dex_entries(mm : mmap.mmap): - eocd_idx = _find_eocd(mm) - if eocd_idx < 0: - raise ValueError("EOCD not found") - - cd_size = _U32_FROM(mm, eocd_idx + 12)[0] - cd_off = _U32_FROM(mm, eocd_idx + 16)[0] - cd_end = cd_off + cd_size - entries = [] - seen_names = set() - pos = cd_off - - while True: - pos = mm.find(b"classes", pos, cd_end) - if pos < 0: - break - header_off = pos - 46 - pos += 7 - if header_off < cd_off or mm[header_off:header_off + 4] != _CD_SIG: - continue - - name_len = _U16_FROM(mm, header_off + 28)[0] - name_start = pos - 7 - name_end = name_start + name_len - if name_end > cd_end: - continue - - name_bytes = mm[name_start:name_end] - if ( - not name_bytes.endswith(_DEX_SUFFIX) - or _SLASH in name_bytes - or name_bytes in seen_names - ): - continue - - seen_names.add(name_bytes) - entries.append(( - name_bytes.decode("utf-8", errors="ignore"), - _U32_FROM(mm, header_off + 24)[0], - _U32_FROM(mm, header_off + 20)[0], - _U32_FROM(mm, header_off + 42)[0], - _U16_FROM(mm, header_off + 10)[0], - )) - - if entries: - return entries - - ptr = cd_off - while ptr + 46 <= cd_end: - if mm[ptr:ptr + 4] != _CD_SIG: - break - - name_len = _U16_FROM(mm, ptr + 28)[0] - extra_len = _U16_FROM(mm, ptr + 30)[0] - comment_len = _U16_FROM(mm, ptr + 32)[0] - name_start = ptr + 46 - name_end = name_start + name_len - name_bytes = mm[name_start:name_end] - if name_bytes.endswith(_DEX_SUFFIX) and _SLASH not in name_bytes: - entries.append(( - name_bytes.decode("utf-8", errors="ignore"), - _U32_FROM(mm, ptr + 24)[0], - _U32_FROM(mm, ptr + 20)[0], - _U32_FROM(mm, ptr + 42)[0], - _U16_FROM(mm, ptr + 10)[0], - )) - - ptr = name_end + extra_len + comment_len - - return entries - - -def _close_worker_apk(): - global _WORKER_APK_PATH, _WORKER_APK_FP, _WORKER_APK_MM - - mm = _WORKER_APK_MM - fp = _WORKER_APK_FP - _WORKER_APK_MM = None - _WORKER_APK_FP = None - _WORKER_APK_PATH = None - - if mm is not None: - mm.close() - if fp is not None: - fp.close() - - -atexit.register(_close_worker_apk) - - -def _get_worker_apk_mm(apk_path : str): - global _WORKER_APK_PATH, _WORKER_APK_FP, _WORKER_APK_MM - - mm = _WORKER_APK_MM - if mm is not None and _WORKER_APK_PATH == apk_path: - return mm - - _close_worker_apk() - fp = open(apk_path, "rb") - mm = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ) - _WORKER_APK_PATH = apk_path - _WORKER_APK_FP = fp - _WORKER_APK_MM = mm - return mm - - -def _inflate_deflate_chunks(comp_view, stop_event): - if stop_event is None: - # nothing to cancel: hand zlib the whole stream in one call instead of - # re-entering it every _DEFLATE_CHUNK bytes (measured ~4% faster per dex) - return zlib.decompress(comp_view, -15) - decomp = zlib.decompressobj(-15) - out = bytearray() - pos = 0 - total = len(comp_view) - while pos < total: - if stop_event.is_set(): - return None - end = min(pos + _DEFLATE_CHUNK, total) - out.extend(decomp.decompress(comp_view[pos:end])) - pos = end - out.extend(decomp.flush()) - if stop_event.is_set(): - return None - return bytes(out) - - -def _inflate_dex(mm : mmap.mmap, entry, stop_event = None): - name, uncomp_size, comp_size, local_header_off, comp_method = entry - if mm[local_header_off:local_header_off + 4] != _LH_SIG: - raise ValueError("bad local header signature") - - name_len = _U16_FROM(mm, local_header_off + 26)[0] - extra_len = _U16_FROM(mm, local_header_off + 28)[0] - data_off = local_header_off + 30 + name_len + extra_len - comp_view = memoryview(mm)[data_off:data_off + comp_size] - - if comp_method == 0: - data = bytes(comp_view) - elif comp_method == 8: - data = _inflate_deflate_chunks(comp_view, stop_event) - if data is None: - return None - else: - raise ValueError(f"unsupported compression method: {comp_method}") - - if uncomp_size and len(data) != uncomp_size: - raise ValueError(f"size mismatch: expect {uncomp_size}, got {len(data)}") - return data - - -def _findrefs_worker(apk_path : str, entry, find_type : str, find : dict, aggregate : bool = True): - from droidasc.asc_client.asc_handler import AscHandler - - mm = _get_worker_apk_mm(apk_path) - t0 = time.perf_counter() - data = _inflate_dex(mm, entry) - t1 = time.perf_counter() - lines = [] - handler = AscHandler(False) - for dex_name, dex_buf in iter_logical_dex_buffers(entry[0], data): - lines.extend(handler.findrefs(dex_name, dex_buf, find_type, find, aggregate=aggregate)) - t2 = time.perf_counter() - return ( - entry[0], - lines, - (t1 - t0) * 1000000, - (t2 - t1) * 1000000, - os.getpid(), - ) - - -def _inflate_and_hit(mm : mmap.mmap, entry, target_bytes : bytes, stop_event : threading.Event, log): +def _inflate_and_hit( + mm : mmap.mmap, + entry, + target_bytes : bytes, + stop_event, + log, +): tid = threading.get_ident() & 0xFFFF name = entry[0] - if stop_event.is_set(): + # Multi-DEX lookup shares this event so a hit stops queued inflates and + # logical-DEX scans. The single-entry fast path passes None. + if stop_event is not None and stop_event.is_set(): return False, None, None t0 = time.perf_counter() - data = _inflate_dex(mm, entry, stop_event) + data = _load_dex(mm, entry, stop_event) if data is None: return False, None, None t1 = time.perf_counter() - if stop_event.is_set(): + if stop_event is not None and stop_event.is_set(): return False, None, None hit = False hit_name = None hit_data = None for dex_name, dex_buf in iter_logical_dex_buffers(name, data): - if stop_event.is_set(): + if stop_event is not None and stop_event.is_set(): return False, None, None - if _dex_defines_class(dex_buf, target_bytes): + if _defines_class(dex_buf, target_bytes): hit = True hit_name = dex_name hit_data = dex_buf break t2 = time.perf_counter() - if hit: + if hit and stop_event is not None: stop_event.set() log( f"[APK] [T{tid:04x}] '{name}' inflate={(t1 - t0) * 1000000:.2f} us " @@ -359,66 +74,99 @@ def _open_apk(self): return fp, mm def get_class_dex(self, dalvik_class : str): - # string_data_item is MUTF-8, not UTF-8: a code point above the BMP is - # stored as a surrogate pair (CESU-8), so a UTF-8-encoded query can never - # byte-match it and such a class was unreachable. MUTF-8 also keeps byte - # order aligned with the table's UTF-16 code-unit order, which the binary - # search in _find_type_idx relies on. - # - # The two encodings differ only above the BMP (and for an embedded NUL, - # which a descriptor cannot hold), so only such a name pays for the encoder - # and an ordinary lookup stays on the plain UTF-8 path. + # DEX string_data_item uses MUTF-8. Non-BMP descriptors therefore need + # CESU-8 bytes to preserve the table's UTF-16 code-unit ordering. target_bytes = dalvik_class.encode("utf-8") - if any(ord(ch) > 0xFFFF for ch in dalvik_class): + if not dalvik_class.isascii() and any( + ord(ch) > 0xFFFF for ch in dalvik_class + ): from mutf8 import encode_modified_utf8 + target_bytes = encode_modified_utf8(dalvik_class) t_start = time.perf_counter() fp, mm = self._open_apk() try: - entries = _parse_cd_dex_entries(mm) + entries = _parse_entries(mm) + # Small compressed entries finish first, increasing the chance that + # a hit cancels expensive inflates which have not started yet. entries.sort(key=lambda x: x[2]) if self.debug: t_ready = time.perf_counter() - self._log(f"[APK] scan setup={(t_ready - t_start) * 1000000:.2f} us entries={len(entries)}") + self._log( + f"[APK] scan setup={(t_ready - t_start) * 1000000:.2f} us " + f"entries={len(entries)}" + ) if not entries: return None - stop_event = threading.Event() hit_name = None hit_data = None - cap = min(len(entries), max(6, min(self.max_workers, 12))) - - with ThreadPoolExecutor(max_workers=self.max_workers) as ex: - inflight = {} - idx = 0 - while idx < len(entries) or inflight: - while idx < len(entries) and len(inflight) < cap and not stop_event.is_set(): - entry = entries[idx] - idx += 1 - fut = ex.submit(_inflate_and_hit, mm, entry, target_bytes, stop_event, self._log) - inflight[fut] = entry - - if not inflight: - break - done, _pending = wait(list(inflight.keys()), return_when=FIRST_COMPLETED) - for fut in done: - entry = inflight.pop(fut) - ok, dex_name, data = fut.result() - if ok: - hit_name = dex_name - hit_data = data - stop_event.set() + # A thread pool cannot parallelize one item. Skipping it also lets + # Deflate use the faster non-cancellable one-shot path. + if len(entries) == 1 and self.max_workers > 0: + ok, hit_name, hit_data = _inflate_and_hit( + mm, + entries[0], + target_bytes, + None, + self._log, + ) + if not ok: + hit_name = None + hit_data = None + else: + stop_event = threading.Event() + # Limit outstanding inflates even when the configured executor + # is large; decompressed DEX buffers can consume substantial RAM. + cap = min(len(entries), max(6, min(self.max_workers, 12))) + with ThreadPoolExecutor(max_workers=self.max_workers) as ex: + inflight = {} + idx = 0 + while idx < len(entries) or inflight: + while ( + idx < len(entries) + and len(inflight) < cap + and not stop_event.is_set() + ): + entry = entries[idx] + idx += 1 + fut = ex.submit( + _inflate_and_hit, + mm, + entry, + target_bytes, + stop_event, + self._log, + ) + inflight[fut] = entry + + if not inflight: break - if hit_name is not None: - for fut in inflight: - fut.cancel() - break + done, _pending = wait( + list(inflight.keys()), + return_when=FIRST_COMPLETED, + ) + for fut in done: + inflight.pop(fut) + ok, dex_name, data = fut.result() + if ok: + hit_name = dex_name + hit_data = data + stop_event.set() + break + + if hit_name is not None: + for fut in inflight: + fut.cancel() + break if self.debug: t_end = time.perf_counter() - self._log(f"[APK] getclass total={(t_end - t_start) * 1000000:.2f} us") + self._log( + f"[APK] getclass total={(t_end - t_start) * 1000000:.2f} us" + ) if hit_name is None: return None return hit_name, hit_data @@ -427,10 +175,6 @@ def get_class_dex(self, dalvik_class : str): fp.close() def list_classes(self, prefix : str = None): - # deferred like the ProcessPoolExecutor import in for_each_findrefs, so the - # getclass path does not pay for it - from droidasc.asc_core.utils.tinydex import _decode_mutf8_fallback - if self.max_workers <= 0: raise ValueError("Worker count must be greater than zero") if prefix is not None: @@ -441,73 +185,24 @@ def list_classes(self, prefix : str = None): prefix = "L" + prefix.replace(".", "/") prefix_bytes = prefix.encode("ascii") if prefix and prefix.isascii() else None - def decode_name(raw_name : bytes) -> str: - # string_data_item is MUTF-8, so strict UTF-8 rejects a surrogate pair - # above the BMP; decode exactly as DEX.get_string does, or a listing - # names a class differently from every other command - try: - return raw_name.decode("utf-8") - except UnicodeDecodeError: - return _decode_mutf8_fallback(raw_name) - - def list_dex_classes(buf): - if len(buf) < 0x70 or buf[:3] != b"dex": - raise ValueError("invalid DEX header") - - string_ids_size = _U32_FROM(buf, 0x38)[0] - string_ids_off = _U32_FROM(buf, 0x3C)[0] - type_ids_size = _U32_FROM(buf, 0x40)[0] - type_ids_off = _U32_FROM(buf, 0x44)[0] - class_defs_size = _U32_FROM(buf, 0x60)[0] - class_defs_off = _U32_FROM(buf, 0x64)[0] - - if class_defs_size == 0: - return [] - if string_ids_off + string_ids_size * 4 > len(buf): - raise ValueError("bad string_ids range") - if type_ids_off + type_ids_size * 4 > len(buf): - raise ValueError("bad type_ids range") - if class_defs_off + class_defs_size * 32 > len(buf): - raise ValueError("bad class_defs range") - - names = [] - append = names.append - for class_def_off in range(class_defs_off, class_defs_off + class_defs_size * 32, 32): - type_idx = _U32_FROM(buf, class_def_off)[0] - if type_idx >= type_ids_size: - raise ValueError("bad class_def->type_idx") - string_idx = _U32_FROM(buf, type_ids_off + (type_idx << 2))[0] - if string_idx >= string_ids_size: - raise ValueError("bad type_id->string_idx") - string_off = _U32_FROM(buf, string_ids_off + (string_idx << 2))[0] - if string_off >= len(buf): - raise ValueError("bad string_data_off") - try: - raw_name = _read_string_data_bytes(buf, string_off) - except IndexError as error: - raise ValueError("bad string_data_off") from error - - if prefix_bytes is not None: - if raw_name.startswith(prefix_bytes): - append(decode_name(raw_name)) - else: - name = decode_name(raw_name) - if prefix is None or name.startswith(prefix): - append(name) - return names - t_start = time.perf_counter() fp, mm = self._open_apk() try: - entries = _parse_cd_dex_entries(mm) + entries = _parse_entries(mm) if not entries: return [] def list_entry(entry): - data = _inflate_dex(mm, entry) + data = _load_dex(mm, entry) names = [] - for dex_name, dex_buf in iter_logical_dex_buffers(entry[0], data): - names.extend(list_dex_classes(dex_buf)) + for _dex_name, dex_buf in iter_logical_dex_buffers(entry[0], data): + names.extend( + _list_classes( + dex_buf, + prefix=prefix, + prefix_bytes=prefix_bytes, + ) + ) return entry[0], names workers = min(len(entries), self.max_workers) @@ -515,6 +210,8 @@ def list_entry(entry): results = [list_entry(entry) for entry in entries] else: with ThreadPoolExecutor(max_workers=workers) as ex: + # map keeps central-directory order even when workers finish + # out of order, so CLI class listing stays deterministic. results = list(ex.map(list_entry, entries)) names = [] @@ -534,15 +231,14 @@ def list_entry(entry): fp.close() def for_each_findrefs(self, find_type : str, find : dict): - # imported before the timer so this once-per-process import is not charged to a - # single search; it used to happen at apk_handler import time, and keeping the - # getclass path (thread pool only) from paying for it is worth ~8ms of startup + # Keep the process-pool import off the getclass startup path. from concurrent.futures import ProcessPoolExecutor + from droidasc.asc_client.apk import worker as _worker t_start = time.perf_counter() fp, mm = self._open_apk() try: - entries = _parse_cd_dex_entries(mm) + entries = _parse_entries(mm) entries.sort(key=lambda x: x[2]) finally: mm.close() @@ -551,25 +247,38 @@ def for_each_findrefs(self, find_type : str, find : dict): if not entries: return - # a forked child inherits whatever is still sitting in the parent's stdio - # buffers; flush first so it cannot be emitted a second time on exit + # A forked child inherits pending stdio buffers, so flush before forking. sys.stdout.flush() sys.stderr.flush() - with ProcessPoolExecutor(max_workers=self.max_workers, - mp_context=_process_pool_context()) as ex: + with ProcessPoolExecutor( + max_workers=self.max_workers, + mp_context=_worker._process_pool_context(), + ) as ex: futures = {} for entry in entries: - fut = ex.submit(_findrefs_worker, self.apk_path, entry, find_type, find) + fut = ex.submit( + _worker._findrefs_worker, + self.apk_path, + entry, + find_type, + find, + ) futures[fut] = entry[0] + # Findrefs is intentionally streamed in completion order. The CLI + # can print a finished DEX without waiting for slower siblings. while futures: - done, _pending = wait(list(futures.keys()), return_when=FIRST_COMPLETED) + done, _pending = wait( + list(futures.keys()), + return_when=FIRST_COMPLETED, + ) for fut in done: futures.pop(fut) dex_name, lines, inflate_us, process_us, pid = fut.result() if self.debug: self._log( - f"[APK] [P{pid}] '{dex_name}' inflate={inflate_us:.2f} us " + f"[APK] [P{pid}] '{dex_name}' " + f"inflate={inflate_us:.2f} us " f"process={process_us:.2f} us" ) yield dex_name, lines diff --git a/droidasc/asc_client/asc_handler.py b/droidasc/asc_client/asc_handler.py index 69cb513..0ad2427 100644 --- a/droidasc/asc_client/asc_handler.py +++ b/droidasc/asc_client/asc_handler.py @@ -17,6 +17,8 @@ def _install_pure_python_mutf8_shim(): if "mutf8.cmutf8" in sys.modules: return + # Some mutf8 distributions ship the Python implementation but not the + # optional native module imported by Androguard. Mirror only its two APIs. pkg_spec = importlib.util.find_spec("mutf8") if pkg_spec is None or not pkg_spec.submodule_search_locations: return @@ -41,6 +43,8 @@ def _lazy_import(): if _DexManager is not None: return + # Core/decompiler imports are large and optional for listclass/findrefs + # startup. Resolve them once, only when source decompilation is requested. _install_pure_python_mutf8_shim() from droidasc.asc_core.core.dex.dex_manager import DexManager from droidasc.asc_core.findrefs.findrefs_manager import FindRefManager @@ -99,12 +103,16 @@ def findrefs(self, dex_name : str, dex_buf : bytes, find_type : str, find : dict dex = DEX.parse(memoryview(dex_buf), dex_name) ref_manager = FindRefManager(dex) + # find_ref rewrites the selected query list with containing method + # indexes. Keep the caller's query reusable across DEX workers. query = copy.deepcopy(find) matched_idxs = ref_manager.find_ref(query, True) mids = query[find_type] if not aggregate: ret = [] occurrences = defaultdict(int) + # matched_idxs and mids are parallel arrays. One matched item may + # belong to several methods, hence the list normalization below. for i in range(len(mids)): midx = mids[i] if midx is None: @@ -128,6 +136,8 @@ def findrefs(self, dex_name : str, dex_buf : bytes, find_type : str, find : dict return ret grouped = defaultdict(set) + # Aggregate mode collapses repeated targets per containing method while + # preserving deterministic method/target ordering in the output. for i in range(len(mids)): midx = mids[i] if midx is None: diff --git a/droidasc/asc_client/dex_container.py b/droidasc/asc_client/dex_container.py index 52c491d..f8afe19 100644 --- a/droidasc/asc_client/dex_container.py +++ b/droidasc/asc_client/dex_container.py @@ -17,6 +17,8 @@ def dex041_logical_offsets(data : bytes): offsets = [] off = 0 data_len = len(data) + # DEX 041 containers concatenate complete logical files. Each header's + # file_size points to the next header rather than the end of the APK entry. while off + _DEX_HEADER_MIN_SIZE <= data_len and data[off:off + 8] == _DEX041_MAGIC: file_size = _U32_FROM(data, off + 0x20)[0] if file_size < _DEX_HEADER_MIN_SIZE or off + file_size > data_len: @@ -28,6 +30,7 @@ def dex041_logical_offsets(data : bytes): def _patch_u32(buf : bytearray, off : int, delta : int): val = _U32_FROM(buf, off)[0] + # Zero and NO_INDEX are sentinels, not physical offsets. if val != 0 and val != 0xFFFFFFFF and val >= delta: buf[off:off + 4] = (val - delta).to_bytes(4, "little") @@ -74,12 +77,16 @@ def normalize_dex041_logical(data : bytes, header_off : int): header_size = _U32_FROM(data, header_off + 0x24)[0] if header_size < _DEX_HEADER_MIN_SIZE or header_off + header_size > len(data): header_size = _DEX_HEADER_MIN_SIZE + # Most DEX041 offsets remain absolute to the container. Keep the backing + # bytes in place and copy only the selected header to offset zero. out = bytearray(data) out[:header_size] = data[header_off:header_off + header_size] return bytes(out) def _patch_class_data_code_offsets(buf : bytearray, class_defs_off : int, class_defs_size : int, delta : int): + # class_data_item is a run of delta-encoded fields followed by methods. + # Only each method's final code_off is an address that needs rebasing. for class_idx in range(class_defs_size): class_def_off = class_defs_off + class_idx * 32 if class_def_off + 32 > len(buf): @@ -102,6 +109,8 @@ def _patch_class_data_code_offsets(buf : bytearray, class_defs_off : int, class_ def _patch_code_item_debug_offsets(buf : bytearray, delta : int): + # Locate code_item through map_list; code items are variable-sized and + # cannot be found safely by scanning for plausible headers. map_off = _U32_FROM(buf, 0x34)[0] if not (0 < map_off + 4 <= len(buf)): return @@ -144,6 +153,8 @@ def iter_logical_dex_buffers(name : str, data : bytes): if len(offsets) <= 1: yield name, data return + # Preserve the physical APK entry in the synthetic name so logs and search + # results can still identify which container produced the logical DEX. for idx, header_off in enumerate(offsets): logical_name = f"{name}!classes{idx + 1}.dex" yield logical_name, normalize_dex041_logical(data, header_off) diff --git a/droidasc/asc_client/gui/app.py b/droidasc/asc_client/gui/app.py index d39c405..e7b068b 100644 --- a/droidasc/asc_client/gui/app.py +++ b/droidasc/asc_client/gui/app.py @@ -6,44 +6,16 @@ import tkinter as tk from tkinter import ttk -from droidasc.asc_client.gui.runtime import GuiDexStore, dalvik_to_dot, is_member_search -from droidasc.asc_client.gui.settings import SettingsDialog -from droidasc.asc_client.gui.source_edit import ( - find_method_range, - identifier_occurrences_in_range, - index_to_offset, - is_identifier, - find_member_declaration, - find_search_reference, - linkable_member_spans, - member_reference_at_offset, - remap_ranges_after_replacements, - rename_identifier_in_range, - token_at_offset, -) -from droidasc.asc_client.gui.text_utils import decode_java_unicode_escapes_with_ranges -from droidasc.asc_client.gui.theme import ThemeManager -from droidasc.asc_client.gui.widgets import EditorTab, EditorTabBar +from droidasc.asc_client.gui.backend import GuiDexStore, is_member_search +from droidasc.asc_client.gui.editor.controller import EditorController +from droidasc.asc_client.gui.editor.view import EditorView +from droidasc.asc_client.gui.ui.class_tree import ClassTree +from droidasc.asc_client.gui.ui.dialogs import SearchDialog, SettingsDialog +from droidasc.asc_client.gui.ui.tab_bar import EditorTabBar +from droidasc.asc_client.gui.ui.theme import ThemeManager from droidasc.asc_client.manifest_handler import get_manifest_xml -_MAX_FILTER_CLASSES = 5000 -_COMMENT_GAP = " " -_SOURCE_NAV_KEYS = { - "Left", "Right", "Up", "Down", "Home", "End", "Prior", "Next", - "Control_L", "Control_R", "Shift_L", "Shift_R", "Alt_L", "Alt_R", -} -_JAVA_KEYWORDS = { - "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", "class", - "const", "continue", "default", "do", "double", "else", "enum", "extends", "final", - "finally", "float", "for", "goto", "if", "implements", "import", "instanceof", "int", - "interface", "long", "native", "new", "package", "private", "protected", "public", - "return", "short", "static", "strictfp", "super", "switch", "synchronized", "this", - "throw", "throws", "transient", "try", "void", "volatile", "while", "true", "false", - "null", -} - - def _debug_log(enabled : bool, scope : str, msg : str): if not enabled: return @@ -52,170 +24,6 @@ def _debug_log(enabled : bool, scope : str, msg : str): print(f"[GUI DEBUG] [{scope}] [T{tid:04x}] {now:.6f} {msg}") -def _pkg_label(pkg_path : str): - if not pkg_path: - return "(root)" - return pkg_path.rsplit("/", 1)[-1] - - -def _class_label(dalvik_class : str): - return dalvik_class.split("/")[-1].rstrip(";") - - -class SearchDialog: - def __init__(self, app, find_type : str, value : str, class_name, fuzzy_class : bool): - self.app = app - self.find_type = find_type - self.value = value - self.class_name = class_name - self.fuzzy_class = fuzzy_class - self.rows = [] - self.all_rows = [] - self.searching = True - - self.win = tk.Toplevel(app.root) - self.win.title("Search") - self.win.geometry("1280x760") - self.win.transient(app.root) - - self.status_var = tk.StringVar(value="Searching...") - self.progress_var = tk.IntVar(value=0) - self.filter_var = tk.StringVar(value="") - - self._build_ui() - - def _build_ui(self): - self.win.columnconfigure(0, weight=1) - self.win.rowconfigure(1, weight=1) - - top = ttk.Frame(self.win, padding=8) - top.grid(row=0, column=0, sticky="ew") - top.columnconfigure(0, weight=1) - top.columnconfigure(1, weight=0) - top.columnconfigure(2, weight=1) - - ttk.Label(top, textvariable=self.status_var).grid(row=0, column=0, sticky="w") - ttk.Label(top, text="Filter").grid(row=0, column=1, sticky="e", padx=(12, 0)) - self.filter_entry = ttk.Entry(top, textvariable=self.filter_var) - self.filter_entry.grid(row=0, column=2, sticky="ew", padx=(8, 0)) - self.filter_entry.bind("", self._on_filter_changed) - self.filter_entry.config(state=tk.DISABLED) - self.progress = ttk.Progressbar(top, mode="determinate", variable=self.progress_var, maximum=100) - self.progress.grid(row=1, column=0, columnspan=3, sticky="ew", pady=(8, 0)) - - body = ttk.Frame(self.win, padding=(8, 0, 8, 8)) - body.grid(row=1, column=0, sticky="nsew") - body.columnconfigure(0, weight=1) - body.rowconfigure(0, weight=1) - - self.tree = ttk.Treeview( - body, - columns=("dex", "class", "method", "matched"), - show="headings", - ) - self.tree.heading("dex", text="DEX") - self.tree.heading("class", text="Class") - self.tree.heading("method", text="Method") - self.tree.heading("matched", text="Matched") - self.tree.column("dex", width=120, stretch=False) - self.tree.column("class", width=320, stretch=False) - self.tree.column("method", width=260, stretch=False) - self.tree.column("matched", width=560, stretch=True) - self.tree.grid(row=0, column=0, sticky="nsew") - self.tree.bind("", self._open_selected) - - ybar = ttk.Scrollbar(body, orient=tk.VERTICAL, command=self.tree.yview) - ybar.grid(row=0, column=1, sticky="ns") - xbar = ttk.Scrollbar(body, orient=tk.HORIZONTAL, command=self.tree.xview) - xbar.grid(row=1, column=0, sticky="ew") - self.tree.config(yscrollcommand=ybar.set, xscrollcommand=xbar.set) - self.apply_theme() - - def apply_theme(self): - if self.win.winfo_exists(): - self.app.apply_theme_to_window(self.win) - - def update_progress(self, done : int, total : int, hit_count : int): - percent = int((done / total) * 100) if total else 0 - self.progress_var.set(percent) - self.status_var.set(f"Searching {done}/{total} dex, hits={hit_count}") - - def finish(self, payload): - if not self.rows and payload["results"]: - self.append_results(payload["results"]) - self.searching = False - self.filter_entry.config(state=tk.NORMAL) - msg = f"Done. hits={payload['total_hits']} workers={payload['workers']} backend={payload['backend']}" - # if payload["truncated"]: - # msg += f" showing first {len(payload['results'])}" - self.status_var.set(msg) - self.progress_var.set(100) - - def append_results(self, rows): - if not rows: - return - self.all_rows.extend(rows) - if self.searching: - self._append_visible_rows(rows) - return - keyword = self.filter_var.get().strip().lower() - if keyword: - rows = [row for row in rows if self._row_matches_filter(row, keyword)] - self._append_visible_rows(rows) - - def fail(self, message : str): - self.searching = False - self.filter_entry.config(state=tk.NORMAL) - self.status_var.set(message) - - def _open_selected(self, _event = None): - selection = self.tree.selection() - if not selection: - return - row = self.rows[int(selection[0])] - self.app.open_search_result(row, self.find_type) - - def _append_visible_rows(self, rows): - if not rows: - return - start_idx = len(self.rows) - self.rows.extend(rows) - for offset, row in enumerate(rows): - method_name = row["method_text"].split("->", 1)[1] - self.tree.insert( - "", - tk.END, - iid=str(start_idx + offset), - values=(row["dex_name"], row["class_display"], method_name, row["matched_text"]), - ) - - def _row_matches_filter(self, row, keyword : str): - if not keyword: - return True - return ( - keyword in row["dex_name"].lower() - or keyword in row["class_display"].lower() - or keyword in row["method_text"].lower() - or keyword in row["matched_text"].lower() - ) - - def _rebuild_filtered_rows(self): - if self.searching: - return - keyword = self.filter_var.get().strip().lower() - self.tree.delete(*self.tree.get_children()) - self.rows = [] - if not keyword: - self._append_visible_rows(self.all_rows) - return - self._append_visible_rows([row for row in self.all_rows if self._row_matches_filter(row, keyword)]) - - def _on_filter_changed(self, _event = None): - if self.searching: - return - self._rebuild_filtered_rows() - - class AscGuiApp: def __init__(self, root, apk_path : str, max_workers : int = 8, debug : bool = False, search_executor = None): self.root = root @@ -225,33 +33,23 @@ def __init__(self, root, apk_path : str, max_workers : int = 8, debug : bool = F self.search_executor = search_executor self.store = None + # Background threads/process callbacks only enqueue plain data. The Tk + # thread drains this queue and owns every widget mutation. self.events = queue.Queue() self.search_dialog = None self.search_inflight = False - self.editor_tabs = [] - self.active_tab_idx = -1 self.theme_manager = ThemeManager(root, "light") self.theme_name_var = tk.StringVar(value=self.theme_manager.name) self.theme = self.theme_manager.theme - self.class_filter_var = tk.StringVar() - self.class_info_var = tk.StringVar(value="Classes") self.status_var = tk.StringVar(value="Loading APK...") self.search_type_var = tk.StringVar(value="string refs") self.search_value_var = tk.StringVar() self.search_class_var = tk.StringVar() self.fuzzy_class_var = tk.BooleanVar(value=False) - self.editor_find_var = tk.StringVar() - self.editor_find_status_var = tk.StringVar(value="") - self._last_editor_find_text = "" - self._highlight_generation = 0 - self._highlight_apply_batch = 400 - self._highlight_apply_job = None - self._ctrl_member_links_visible = False - self._pending_member_navigation = None self._build_ui() - self._bind_editor_shortcuts() + self.editor.bind_shortcuts() self._set_controls_enabled(False) self._start_load() self.root.after(50, self._drain_events) @@ -280,10 +78,18 @@ def _dispose_search_executor(self): if executor is None: return _debug_log(self.debug, "app", "dispose search executor") + # Do not block Tk on child shutdown. Completed search workers have + # already returned; this only releases the pool lifecycle. executor.shutdown(wait=False, cancel_futures=False) def _open_settings(self): - SettingsDialog(self) + SettingsDialog( + self.root, + self.theme_name_var.get(), + self.theme_manager.names(), + self.set_theme, + self.apply_theme_to_window, + ) def set_theme(self, name : str): self.theme_manager.set_theme(name) @@ -294,27 +100,11 @@ def set_theme(self, name : str): def apply_theme_to_window(self, win): self.theme_manager.apply_toplevel(win) - def _configure_source_tags(self): - t = self.theme - self.theme_manager.apply_text(self.source_text) - self.source_text.tag_configure("java_keyword", foreground=t["java_keyword"]) - self.source_text.tag_configure("java_comment", foreground=t["java_comment"]) - self.source_text.tag_configure("java_string", foreground=t["java_string"]) - self.source_text.tag_configure("java_annotation", foreground=t["java_annotation"]) - self.source_text.tag_configure("manifest_string", foreground=t["java_string"]) - self.source_text.tag_configure("user_comment", foreground=t["java_comment"], font=("Consolas", 10, "italic")) - self.source_text.tag_configure("active_line", background=t["active_line"]) - self.source_text.tag_configure("symbol_match", background=t["symbol_match"]) - self.source_text.tag_configure("symbol_current", background=t["symbol_current"]) - self.source_text.tag_configure("find_match", background=t["find_match"], foreground=t["editor_fg"]) - self.source_text.tag_configure("find_current", background=t["find_current"], foreground=t["editor_fg"]) - self.source_text.tag_configure("member_link", foreground=t["accent"], underline=True) - def apply_theme(self): self.theme = self.theme_manager.theme self.theme_manager.apply_root(self.root) - if hasattr(self, "source_text"): - self._configure_source_tags() + if hasattr(self, "editor_view"): + self.editor_view.configure_tags(self.theme) if hasattr(self, "editor_tab_bar"): self.editor_tab_bar.apply_theme() if self.search_dialog is not None and self.search_dialog.win.winfo_exists(): @@ -338,39 +128,17 @@ def _build_ui(self): body = ttk.Panedwindow(self.root, orient=tk.HORIZONTAL) body.grid(row=1, column=0, sticky="nsew") - left = ttk.Frame(body, padding=8) right = ttk.Frame(body, padding=8) - left.columnconfigure(0, weight=1) - left.rowconfigure(1, weight=1) right.columnconfigure(0, weight=1) right.rowconfigure(1, weight=1) - body.add(left, weight=1) + self.class_browser = ClassTree( + body, + open_class=lambda dalvik_class: self.editor.open_class(dalvik_class), + open_manifest=self._open_manifest, + ) + body.add(self.class_browser, weight=1) body.add(right, weight=4) - left_top = ttk.Frame(left) - left_top.grid(row=0, column=0, sticky="ew") - left_top.columnconfigure(1, weight=1) - ttk.Label(left_top, textvariable=self.class_info_var).grid(row=0, column=0, sticky="w") - self.class_filter_entry = ttk.Entry(left_top, textvariable=self.class_filter_var) - self.class_filter_entry.grid(row=0, column=1, sticky="ew", padx=(8, 0)) - self.class_filter_entry.bind("", self._on_class_filter_changed) - self.manifest_button = ttk.Button(left_top, text="Manifest", command=self._open_manifest) - self.manifest_button.grid(row=0, column=2, sticky="e", padx=(8, 0)) - - class_frame = ttk.Frame(left) - class_frame.grid(row=1, column=0, sticky="nsew", pady=(8, 0)) - class_frame.columnconfigure(0, weight=1) - class_frame.rowconfigure(0, weight=1) - - self.class_tree = ttk.Treeview(class_frame, show="tree") - self.class_tree.grid(row=0, column=0, sticky="nsew") - self.class_tree.bind("<>", self._on_tree_open) - self.class_tree.bind("<>", self._on_tree_select) - - class_scroll = ttk.Scrollbar(class_frame, orient=tk.VERTICAL, command=self.class_tree.yview) - class_scroll.grid(row=0, column=1, sticky="ns") - self.class_tree.config(yscrollcommand=class_scroll.set) - controls = ttk.Frame(right) controls.grid(row=0, column=0, sticky="ew") for idx in range(7): @@ -407,83 +175,58 @@ def _build_ui(self): source_frame = ttk.Frame(right) source_frame.grid(row=1, column=0, sticky="nsew", pady=(8, 0)) source_frame.columnconfigure(0, weight=1) - source_frame.rowconfigure(2, weight=1) - - self.editor_tab_bar = EditorTabBar(source_frame, self) - self.editor_tab_bar.grid(row=0, column=0, columnspan=2, sticky="ew") + source_frame.rowconfigure(1, weight=1) - self.editor_find_frame = ttk.Frame(source_frame) - self.editor_find_frame.grid(row=1, column=0, sticky="ew", pady=(6, 6)) - self.editor_find_frame.columnconfigure(1, weight=1) - ttk.Label(self.editor_find_frame, text="Find").grid(row=0, column=0, sticky="w") - self.editor_find_entry = ttk.Entry(self.editor_find_frame, textvariable=self.editor_find_var) - self.editor_find_entry.grid(row=0, column=1, sticky="ew", padx=(8, 8)) - self.editor_find_entry.bind("", self._on_editor_find_return) - self.editor_find_entry.bind("", self._on_editor_find_return) - self.editor_find_entry.bind("", self._on_editor_find_prev) - self.editor_find_entry.bind("", self._on_editor_find_prev) - self.editor_find_entry.bind("", self._hide_editor_find) - self.editor_find_entry.bind("", self._on_editor_find_changed) - ttk.Button(self.editor_find_frame, text="Next", command=self._editor_find_next).grid( - row=0, column=2, sticky="e" + self.editor_view = EditorView( + source_frame, + self.root, + self.events.put, + self.theme_manager, ) - ttk.Button(self.editor_find_frame, text="Prev", command=self._editor_find_prev).grid( - row=0, column=3, sticky="e", padx=(6, 0) + self.editor = EditorController( + self.root, + self.editor_view, + get_store=lambda: self.store, + emit_event=self.events.put, + set_status=self.status_var.set, + select_class=self.class_browser.select_class, + start_reference_search=self._start_reference_search, + apply_theme_to_window=self.apply_theme_to_window, + debug=self.debug, + debug_log=lambda scope, msg: _debug_log(self.debug, scope, msg), ) - ttk.Button(self.editor_find_frame, text="Close", command=self._hide_editor_find).grid( - row=0, column=4, sticky="e", padx=(6, 0) + self.editor_tab_bar = EditorTabBar( + source_frame, + get_tabs=lambda: self.editor.tabs, + get_active_index=lambda: self.editor.active_tab_idx, + activate_tab=self.editor.activate_tab, + close_tab=self.editor.close_tab, + clear_tabs=self.editor.clear_tabs, + open_settings=self._open_settings, + get_theme=lambda: self.theme, + apply_theme_to_window=self.apply_theme_to_window, + apply_listbox_theme=self.theme_manager.apply_listbox, ) - ttk.Label(self.editor_find_frame, textvariable=self.editor_find_status_var).grid( - row=0, column=5, sticky="e", padx=(12, 0) - ) - - self.source_text = tk.Text(source_frame, wrap="none") - self.source_text.grid(row=2, column=0, sticky="nsew") - src_y = ttk.Scrollbar(source_frame, orient=tk.VERTICAL, command=self.source_text.yview) - src_y.grid(row=2, column=1, sticky="ns") - src_x = ttk.Scrollbar(source_frame, orient=tk.HORIZONTAL, command=self.source_text.xview) - src_x.grid(row=3, column=0, sticky="ew") - self.source_text.config(yscrollcommand=src_y.set, xscrollcommand=src_x.set) - self._configure_source_tags() + self.editor.set_tab_bar(self.editor_tab_bar) + self.editor_tab_bar.grid(row=0, column=0, columnspan=2, sticky="ew") + self.editor_view.grid(row=1, column=0, columnspan=2, sticky="nsew") + self.editor_view.configure_tags(self.theme) self.apply_theme() - self.editor_find_frame.grid_remove() - self._show_empty_editor() + self.editor_view.show_empty() self._on_search_type_changed() - def _bind_editor_shortcuts(self): - self.root.bind("", self._show_editor_find) - self.root.bind("", self._show_editor_find) - self.root.bind("", self._close_active_tab) - self.root.bind("", self._close_active_tab) - self.root.bind("", self._next_tab) - self.root.bind("", self._prev_tab) - self.source_text.bind("", self._set_editor_cursor_from_click) - self.source_text.bind("", self._open_member_from_click) - self.source_text.bind("", self._hide_member_links, add="+") - self.root.bind("", self._show_member_links) - self.root.bind("", self._show_member_links) - self.root.bind("", self._hide_member_links) - self.root.bind("", self._hide_member_links) - self.source_text.bind("", self._on_source_key_press) - self.source_text.bind("", self._on_source_key_release) - self.source_text.bind("<>", lambda _event: "break") - self.source_text.bind("<>", lambda _event: "break") - self.source_text.bind("<>", lambda _event: "break") - self.source_text.bind("", self._hide_editor_find) - def _set_controls_enabled(self, enabled : bool): state = tk.NORMAL if enabled else tk.DISABLED - self.class_tree.config(selectmode="browse") + self.class_browser.set_enabled(enabled) self.search_button.config(state=state) self.search_value_entry.config(state=state) self.search_type_box.config(state="readonly" if enabled else tk.DISABLED) self.search_class_entry.config(state=state) self.fuzzy_class_check.config(state=state) - self.class_filter_entry.config(state=state) - if hasattr(self, "manifest_button"): - self.manifest_button.config(state=state) def _drain_events(self): + # This callback always runs on Tk's event loop, making it the single + # synchronization point between background work and widgets. while True: try: event = self.events.get_nowait() @@ -494,6 +237,17 @@ def _drain_events(self): def _handle_event(self, event): kind = event[0] + dialog = self.search_dialog + if kind.startswith("search_") and dialog is not None: + # Search work outlives its optional result window. Continue updating + # global state, but never send events to a destroyed Toplevel. + try: + if not dialog.win.winfo_exists(): + dialog = None + self.search_dialog = None + except tk.TclError: + dialog = None + self.search_dialog = None if kind == "load_progress": _kind, done, total, dex_name, class_count = event self.status_var.set(f"Indexing {done}/{total} dex, classes={class_count}, last={dex_name}") @@ -501,7 +255,7 @@ def _handle_event(self, event): if kind == "load_done": self.store = event[1] self._set_controls_enabled(True) - self._refresh_class_tree() + self.class_browser.set_store(self.store) self.status_var.set(f"Ready. dex={len(self.store.entries)} classes={len(self.store.class_names)}") return if kind == "load_error": @@ -509,46 +263,51 @@ def _handle_event(self, event): return if kind == "source_done": _kind, dalvik_class, dex_name, source, member_references = event - self._finish_open_tab(dalvik_class, dex_name, source, member_references) + self.editor.finish_open_tab(dalvik_class, dex_name, source, member_references) return if kind == "text_tab_done": _kind, tab_id, title, source, dex_name = event - self._open_text_tab(tab_id, title, source, dex_name=dex_name) + self.editor.open_text_tab(tab_id, title, source, dex_name=dex_name) self.status_var.set(f"Opened {title}") return if kind == "highlight_ready": _kind, generation, spans = event - self._apply_highlight_spans_async(generation, spans) + self.editor_view.apply_highlight_spans(generation, spans) return if kind == "source_error": _kind, dalvik_class, message = event - self._fail_open_tab(dalvik_class, message) + self.editor.fail_open_tab(dalvik_class, message) return - if kind == "search_progress" and self.search_dialog is not None: + if kind == "search_progress": _kind, done, total, hit_count = event - self.search_dialog.update_progress(done, total, hit_count) + if dialog is not None: + dialog.update_progress(done, total, hit_count) self.status_var.set(f"Searching {done}/{total} dex, hits={hit_count}") return - if kind == "search_batch" and self.search_dialog is not None: + if kind == "search_batch": _kind, rows, done, total, hit_count = event - self.search_dialog.append_results(rows) - self.search_dialog.update_progress(done, total, hit_count) + if dialog is not None: + dialog.append_results(rows) + dialog.update_progress(done, total, hit_count) self.status_var.set(f"Searching {done}/{total} dex, hits={hit_count}") return - if kind == "search_done" and self.search_dialog is not None: + if kind == "search_done": payload = event[1] self.search_inflight = False self.search_button.config(state=tk.NORMAL) - self.search_dialog.finish(payload) + if dialog is not None: + dialog.finish(payload) self.status_var.set( f"Search done. hits={payload['total_hits']} workers={payload['workers']} backend={payload['backend']}" ) return - if kind == "search_error" and self.search_dialog is not None: + if kind == "search_error": self.search_inflight = False self.search_button.config(state=tk.NORMAL) - self.search_dialog.fail(event[1]) + if dialog is not None: + dialog.fail(event[1]) self.status_var.set(event[1]) + return def _start_load(self): def worker(): @@ -561,6 +320,8 @@ def worker(): search_executor=self.search_executor, ) store.load( + # Progress callbacks run on the loader thread and therefore + # must be converted to main-thread events. lambda done, total, dex_name, class_count: self.events.put( ("load_progress", done, total, dex_name, class_count) ) @@ -574,72 +335,6 @@ def worker(): threading.Thread(target=worker, daemon=True).start() - def _reset_tree(self): - self.class_tree.delete(*self.class_tree.get_children()) - - def _insert_package_node(self, parent_id : str, pkg_path : str): - item_id = f"pkg:{pkg_path}" - if self.class_tree.exists(item_id): - return item_id - self.class_tree.insert(parent_id, tk.END, iid=item_id, text=_pkg_label(pkg_path), values=("pkg",)) - self.class_tree.insert(item_id, tk.END, iid=f"{item_id}:stub", text="...") - return item_id - - def _populate_package_children(self, pkg_path : str): - item_id = f"pkg:{pkg_path}" - if not self.class_tree.exists(item_id): - return - children = self.class_tree.get_children(item_id) - if len(children) == 1 and children[0] == f"{item_id}:stub": - self.class_tree.delete(children[0]) - elif children: - return - - for child_pkg in self.store.iter_child_packages(pkg_path): - self._insert_package_node(item_id, child_pkg) - for dalvik_class in self.store.iter_package_classes(pkg_path): - class_id = f"cls:{dalvik_class}" - self.class_tree.insert(item_id, tk.END, iid=class_id, text=_class_label(dalvik_class), values=("class",)) - - def _refresh_class_tree(self): - if self.store is None: - return - keyword = self.class_filter_var.get().strip() - self._reset_tree() - if keyword: - matches = self.store.iter_filtered_classes(keyword, _MAX_FILTER_CLASSES) - for dalvik_class in matches: - self.class_tree.insert("", tk.END, iid=f"cls:{dalvik_class}", text=dalvik_to_dot(dalvik_class)) - total_text = f"Classes ({len(matches)} filtered)" - if len(matches) >= _MAX_FILTER_CLASSES: - total_text = f"Classes ({_MAX_FILTER_CLASSES}+ filtered)" - self.class_info_var.set(total_text) - return - - for pkg_path in self.store.iter_root_packages(): - self._insert_package_node("", pkg_path) - for dalvik_class in self.store.iter_package_classes(""): - self.class_tree.insert("", tk.END, iid=f"cls:{dalvik_class}", text=_class_label(dalvik_class)) - self.class_info_var.set(f"Packages / Classes ({len(self.store.class_names)})") - - def _on_class_filter_changed(self, _event = None): - self._refresh_class_tree() - - def _on_tree_open(self, _event = None): - selection = self.class_tree.focus() - if not selection.startswith("pkg:"): - return - self._populate_package_children(selection[4:]) - - def _on_tree_select(self, _event = None): - selection = self.class_tree.selection() - if not selection: - return - item_id = selection[0] - if not item_id.startswith("cls:"): - return - self.open_class(item_id[4:]) - def _open_manifest(self): self.status_var.set("Parsing AndroidManifest.xml...") @@ -654,510 +349,6 @@ def worker(): threading.Thread(target=worker, daemon=True).start() - def _find_tab_index(self, dalvik_class : str): - for idx, tab in enumerate(self.editor_tabs): - if tab.dalvik_class == dalvik_class: - return idx - return None - - def _open_text_tab(self, tab_id : str, title : str, source : str, dex_name : str = ""): - idx = self._find_tab_index(tab_id) - if idx is None: - self._remember_active_view() - self.editor_tabs.append(EditorTab(dalvik_class=tab_id, title=title, kind="text")) - idx = len(self.editor_tabs) - 1 - tab = self.editor_tabs[idx] - tab.dex_name = dex_name - tab.source = source - tab.edited_source = "" - tab.loading = False - tab.error = "" - self.activate_tab(idx) - - def _active_tab(self): - if self.active_tab_idx < 0 or self.active_tab_idx >= len(self.editor_tabs): - return None - return self.editor_tabs[self.active_tab_idx] - - def _remember_active_view(self): - tab = self._active_tab() - if tab is None: - return - try: - tab.yview = self.source_text.yview() - tab.xview = self.source_text.xview() - tab.insert_index = self.source_text.index("insert") - except tk.TclError: - pass - - def _set_source_text(self, text : str, reset_view : bool = True): - self.source_text.config(state=tk.NORMAL) - self.source_text.delete("1.0", tk.END) - self.source_text.insert("1.0", text) - if reset_view: - self.source_text.mark_set("insert", "1.0") - self.source_text.xview_moveto(0.0) - self.source_text.yview_moveto(0.0) - - def _render_tab_source(self, tab : EditorTab): - source = tab.edited_source or tab.source - if not tab.comments: - tab.rendered_member_references = list(tab.member_references) - return source, [] - - out = [] - comment_spans = [] - insertions = [] - offset = 0 - source_offset = 0 - lines = source.splitlines(keepends=True) - for line_no, line in enumerate(lines, 1): - newline = "" - body = line - if line.endswith("\r\n"): - body = line[:-2] - newline = "\r\n" - elif line.endswith("\n"): - body = line[:-1] - newline = "\n" - - comment = tab.comments.get(line_no) - if not comment: - rendered = line - else: - pad = _COMMENT_GAP - comment_text = f"{pad}// {comment}" - insertions.append((source_offset + len(body), len(comment_text))) - start = offset + len(body) + len(pad) - end = start + len(comment_text) - len(pad) - rendered = f"{body}{comment_text}{newline}" - comment_spans.append((start, end)) - out.append(rendered) - offset += len(rendered) - source_offset += len(line) - - if not lines and tab.comments: - comment = tab.comments.get(1, "") - rendered = f"// {comment}" - comment_spans.append((0, len(rendered))) - return rendered, comment_spans - - def shift_start(point): - return point + sum(length for position, length in insertions if position <= point) - - def shift_end(point): - return point + sum(length for position, length in insertions if position < point) - - tab.rendered_member_references = [ - (shift_start(item[0]), shift_end(item[1]), *item[2:]) - for item in tab.member_references - ] - return "".join(out), comment_spans - - def _comment_line_from_index(self, index : str): - try: - return int(str(index).split(".", 1)[0]) - except (TypeError, ValueError): - return None - - def _highlight_active_line(self): - self.source_text.tag_remove("active_line", "1.0", tk.END) - try: - line_no = self._comment_line_from_index(self.source_text.index("insert")) - except tk.TclError: - line_no = None - if line_no is None: - return - self.source_text.tag_add("active_line", f"{line_no}.0", f"{line_no}.end+1c") - self.source_text.tag_lower("active_line") - - def _set_editor_cursor_from_click(self, event): - try: - self.source_text.focus_set() - index = self.source_text.index(f"@{event.x},{event.y}") - self.source_text.mark_set("insert", index) - self._highlight_active_line() - self._highlight_related_identifier(index) - except tk.TclError: - pass - - def _show_member_links(self, _event = None): - if self._ctrl_member_links_visible: - return None - self._ctrl_member_links_visible = True - self.source_text.tag_remove("member_link", "1.0", tk.END) - tab = self._active_tab() - if tab is not None and tab.kind == "class" and not tab.loading and not tab.error: - self._tag_ranges( - "member_link", - linkable_member_spans( - tab.rendered_member_references, - self.store.class_to_dex if self.store is not None else (), - ), - ) - self.source_text.tag_raise("member_link") - return None - - def _hide_member_links(self, _event = None): - self._ctrl_member_links_visible = False - self.source_text.tag_remove("member_link", "1.0", tk.END) - return None - - def _member_at_index(self, index : str): - tab = self._active_tab() - if tab is None or tab.kind != "class" or tab.loading or tab.error: - return None - rendered = tab.edited_source or tab.source - references = tab.rendered_member_references or tab.member_references - if tab.comments: - rendered, _comment_spans = self._render_tab_source(tab) - references = tab.rendered_member_references - offset = index_to_offset(rendered, index) - return member_reference_at_offset(references, offset) - - def _open_member_from_click(self, event): - try: - index = self.source_text.index(f"@{event.x},{event.y}") - except tk.TclError: - return "break" - reference = self._member_at_index(index) - if reference is None: - return "break" - _start, _end, member_type, class_name, member_name, descriptor, _declaration = reference - if self.store is None or class_name not in self.store.class_to_dex: - self.status_var.set(f"Declaration class is not present in this APK: {dalvik_to_dot(class_name)}") - return "break" - self._pending_member_navigation = (class_name, member_type, member_name, descriptor) - self.open_class(class_name) - self._complete_pending_member_navigation(class_name) - return "break" - - def _complete_pending_member_navigation(self, dalvik_class : str): - target = self._pending_member_navigation - if target is None or target[0] != dalvik_class: - return - idx = self._find_tab_index(dalvik_class) - if idx is None: - return - tab = self.editor_tabs[idx] - if tab.loading or tab.error or not tab.source: - return - member_type = target[1] - if member_type == "search": - ( - _class_name, _kind, method_name, method_descriptor, - target_type, target_class, target_name, target_descriptor, occurrence, - ) = target - source = tab.edited_source or tab.source - references = tab.rendered_member_references - if tab.comments: - source, _comment_spans = self._render_tab_source(tab) - references = tab.rendered_member_references - reference = find_search_reference( - source, references, method_name, method_descriptor, - target_type, target_class, target_name, target_descriptor, occurrence, - ) - exact = reference is not None - if reference is None: - reference = find_member_declaration( - references, "method", method_name, method_descriptor - ) - if reference is None: - reference = find_member_declaration(references, "method", method_name, "") - self._pending_member_navigation = None - if reference is None: - self.status_var.set(f"Search location not found: {dalvik_to_dot(dalvik_class)}->{method_name}") - return - start = reference[0] - index = f"1.0+{start}c" - self.source_text.mark_set("insert", index) - self.source_text.see(index) - tab.insert_index = self.source_text.index("insert") - tab.yview = self.source_text.yview() - self._highlight_active_line() - self._highlight_related_identifier(tab.insert_index) - suffix = "" if exact else " (exact expression unavailable)" - self.status_var.set( - f"Opened search hit {dalvik_to_dot(dalvik_class)}->{method_name}{suffix}" - ) - return - _class_name, member_type, member_name, descriptor = target - reference = find_member_declaration( - tab.rendered_member_references, member_type, member_name, descriptor - ) - self._pending_member_navigation = None - if reference is None: - self.status_var.set(f"Declaration not found: {dalvik_to_dot(dalvik_class)}->{member_name}") - return - start = reference[0] - index = f"1.0+{start}c" - self.source_text.mark_set("insert", index) - self.source_text.see(index) - tab.insert_index = self.source_text.index("insert") - tab.yview = self.source_text.yview() - self._highlight_active_line() - self._highlight_related_identifier(tab.insert_index) - if member_type == "class": - self.status_var.set(f"Opened class {dalvik_to_dot(dalvik_class)}") - else: - self.status_var.set(f"Opened declaration {dalvik_to_dot(dalvik_class)}->{member_name}") - - def _on_source_key_press(self, event): - if event.state & 0x0004: - return None - if event.keysym in ("x", "X"): - return self._find_current_member_references() - if event.keysym == "n": - return self._show_rename_dialog() - if event.char == ";": - return self._show_line_comment_dialog() - if event.keysym in _SOURCE_NAV_KEYS: - return None - return "break" - - def _on_source_key_release(self, event): - if event.keysym in _SOURCE_NAV_KEYS: - self._highlight_active_line() - self._highlight_related_identifier(self.source_text.index("insert")) - return None - - def _find_current_member_references(self, _event = None): - tab = self._active_tab() - if tab is None or tab.kind != "class" or tab.loading or tab.error or not tab.source: - return "break" - - try: - cursor_index = self.source_text.index("insert") - except tk.TclError: - return "break" - reference = self._member_at_index(cursor_index) - if reference is None: - self.status_var.set("Place the cursor on a method or field name to find its references") - return "break" - _start, _end, member_type, class_name, member_name, *_metadata = reference - if member_type not in ("method", "field"): - self.status_var.set("Place the cursor on a method or field name to find its references") - return "break" - - self.search_type_var.set(f"{member_type} refs") - self.search_value_var.set(member_name) - self.search_class_var.set(class_name) - self.fuzzy_class_var.set(False) - self._on_search_type_changed() - self._start_search(exact_member=True) - return "break" - - def _highlight_related_identifier(self, index : str): - self.source_text.tag_remove("symbol_match", "1.0", tk.END) - self.source_text.tag_remove("symbol_current", "1.0", tk.END) - tab = self._active_tab() - if tab is None or tab.kind != "class" or tab.loading or tab.error: - return - source = tab.edited_source or tab.source - if tab.comments: - source, _comment_spans = self._render_tab_source(tab) - if not source: - return - offset = index_to_offset(source, index) - token = token_at_offset(source, offset) - if token is None: - return - start, end, name = token - method_range = find_method_range(source, start) - if method_range is None: - method_range = (0, len(source)) - spans = identifier_occurrences_in_range(source, method_range[0], method_range[1], name) - if len(spans) <= 1: - return - for span_start, span_end in spans: - tag = "symbol_current" if span_start == start and span_end == end else "symbol_match" - self._tag_range(tag, span_start, span_end) - self.source_text.tag_raise("symbol_match") - self.source_text.tag_raise("symbol_current") - - def _original_line_text(self, tab : EditorTab, line_no : int): - lines = (tab.edited_source or tab.source).splitlines() - if line_no < 1 or line_no > len(lines): - return "" - return lines[line_no - 1] - - def _show_empty_editor(self): - self._highlight_generation += 1 - self._clear_source_tags() - self.source_text.tag_remove("user_comment", "1.0", tk.END) - self.source_text.tag_remove("active_line", "1.0", tk.END) - self.source_text.tag_remove("symbol_match", "1.0", tk.END) - self.source_text.tag_remove("symbol_current", "1.0", tk.END) - self.source_text.tag_remove("find_match", "1.0", tk.END) - self.source_text.tag_remove("find_current", "1.0", tk.END) - self.source_text.tag_remove("member_link", "1.0", tk.END) - self._set_source_text("Open a class from the package tree or search results.", reset_view=True) - self.editor_find_status_var.set("") - - def _show_tab(self, tab : EditorTab): - self._highlight_generation += 1 - self._clear_source_tags() - self.source_text.tag_remove("user_comment", "1.0", tk.END) - self.source_text.tag_remove("active_line", "1.0", tk.END) - self.source_text.tag_remove("symbol_match", "1.0", tk.END) - self.source_text.tag_remove("symbol_current", "1.0", tk.END) - self.source_text.tag_remove("find_match", "1.0", tk.END) - self.source_text.tag_remove("find_current", "1.0", tk.END) - self.source_text.tag_remove("member_link", "1.0", tk.END) - if tab.loading: - self._set_source_text(f"Decompiling {dalvik_to_dot(tab.dalvik_class)}...", reset_view=True) - return - if tab.error: - self._set_source_text(tab.error, reset_view=True) - return - - rendered, comment_spans = self._render_tab_source(tab) - self._set_source_text(rendered, reset_view=False) - try: - self.source_text.mark_set("insert", tab.insert_index) - self.source_text.xview_moveto(tab.xview[0]) - self.source_text.yview_moveto(tab.yview[0]) - except tk.TclError: - self.source_text.mark_set("insert", "1.0") - for start, end in comment_spans: - self._tag_range("user_comment", start, end) - if tab.kind == "class": - self._start_async_highlight(rendered) - else: - self._highlight_quoted_strings(rendered) - self._refresh_editor_find_marks(reset_cursor=False) - self._highlight_active_line() - if self._ctrl_member_links_visible: - self._ctrl_member_links_visible = False - self._show_member_links() - - def activate_tab(self, index : int): - if index < 0 or index >= len(self.editor_tabs): - return - if index == self.active_tab_idx: - self.editor_tab_bar.ensure_visible(index) - self.editor_tab_bar.redraw() - self._complete_pending_member_navigation(self.editor_tabs[index].dalvik_class) - return - self._remember_active_view() - self.active_tab_idx = index - tab = self.editor_tabs[index] - self._show_tab(tab) - self.editor_tab_bar.ensure_visible(index) - self.editor_tab_bar.redraw() - if tab.loading: - self.status_var.set(f"Decompiling {dalvik_to_dot(tab.dalvik_class)}...") - elif tab.error: - self.status_var.set(tab.error) - else: - if tab.kind == "class": - self.status_var.set(f"Opened {dalvik_to_dot(tab.dalvik_class)} from {tab.dex_name}") - else: - self.status_var.set(f"Opened {tab.title}") - if tab.kind == "class": - self._select_class_in_tree(tab.dalvik_class) - self._complete_pending_member_navigation(tab.dalvik_class) - - def close_tab(self, index : int): - if index < 0 or index >= len(self.editor_tabs): - return - self._remember_active_view() - closing_tab = self.editor_tabs[index] - was_active = index == self.active_tab_idx - self.editor_tabs.pop(index) - if ( - self._pending_member_navigation is not None - and self._pending_member_navigation[0] == closing_tab.dalvik_class - ): - self._pending_member_navigation = None - if not self.editor_tabs: - self.active_tab_idx = -1 - self.editor_tab_bar.tab_scroll_index = 0 - self.editor_tab_bar.redraw() - self._show_empty_editor() - self.status_var.set("No class tab open") - return - if self.active_tab_idx > index: - self.active_tab_idx -= 1 - elif was_active: - self.active_tab_idx = min(index, len(self.editor_tabs) - 1) - self._show_tab(self.editor_tabs[self.active_tab_idx]) - self.editor_tab_bar.ensure_visible(self.active_tab_idx) - self.editor_tab_bar.redraw() - - def clear_tabs(self): - if not self.editor_tabs: - return - self.editor_tabs = [] - self.active_tab_idx = -1 - self._pending_member_navigation = None - self.editor_tab_bar.tab_scroll_index = 0 - self.editor_tab_bar.redraw() - self._show_empty_editor() - self.status_var.set("No class tab open") - - def _close_active_tab(self, _event = None): - self.close_tab(self.active_tab_idx) - return "break" - - def _cycle_tab(self, step : int): - if not self.editor_tabs: - return "break" - self.activate_tab((self.active_tab_idx + step) % len(self.editor_tabs)) - return "break" - - def _next_tab(self, _event = None): - return self._cycle_tab(1) - - def _prev_tab(self, _event = None): - return self._cycle_tab(-1) - - def _finish_open_tab(self, dalvik_class : str, dex_name : str, source : str, member_references): - idx = self._find_tab_index(dalvik_class) - if idx is None: - return - tab = self.editor_tabs[idx] - tab.dex_name = dex_name - tab.source, tab.member_references = decode_java_unicode_escapes_with_ranges( - source, member_references - ) - tab.edited_source = "" - tab.loading = False - tab.error = "" - if idx == self.active_tab_idx: - self._show_tab(tab) - self.editor_tab_bar.ensure_visible(idx if idx == self.active_tab_idx else self.active_tab_idx) - self.editor_tab_bar.redraw() - self.status_var.set(f"Opened {dalvik_to_dot(dalvik_class)} from {dex_name}") - if idx == self.active_tab_idx: - self._complete_pending_member_navigation(dalvik_class) - - def _fail_open_tab(self, dalvik_class : str, message : str): - idx = self._find_tab_index(dalvik_class) - if idx is None: - self.status_var.set(message) - return - tab = self.editor_tabs[idx] - tab.loading = False - tab.error = message - if ( - self._pending_member_navigation is not None - and self._pending_member_navigation[0] == dalvik_class - ): - self._pending_member_navigation = None - if idx == self.active_tab_idx: - self._show_tab(tab) - self.editor_tab_bar.redraw() - self.status_var.set(message) - - def _select_class_in_tree(self, dalvik_class : str): - item_id = f"cls:{dalvik_class}" - if not self.class_tree.exists(item_id): - return - self.class_tree.selection_set(item_id) - self.class_tree.focus(item_id) - self.class_tree.see(item_id) - def _on_search_type_changed(self, _event = None): find_type = self.search_type_var.get() need_class = find_type in ("method refs", "field refs") @@ -1170,540 +361,16 @@ def _on_search_enter(self, _event = None): self._start_search() return "break" - def _tag_range(self, tag_name : str, start : int, end : int): - if end <= start: - return - self.source_text.tag_add(tag_name, f"1.0+{start}c", f"1.0+{end}c") - - def _tag_ranges(self, tag_name : str, spans, batch_size : int = 512): - for offset in range(0, len(spans), batch_size): - args = [] - for start, end in spans[offset:offset + batch_size]: - if end <= start: - continue - args.append(f"1.0+{start}c") - args.append(f"1.0+{end}c") - if args: - self.source_text.tag_add(tag_name, *args) - - def _offset_spans_to_index_spans(self, text : str, spans): - line_starts = [0] - pos = 0 - while True: - pos = text.find("\n", pos) - if pos < 0: - break - pos += 1 - line_starts.append(pos) - - def to_index(offset : int): - left = 0 - right = len(line_starts) - while left < right: - mid = (left + right) >> 1 - if line_starts[mid] <= offset: - left = mid + 1 - else: - right = mid - line_idx = left - 1 - return f"{line_idx + 1}.{offset - line_starts[line_idx]}" - - ret = {} - for tag_name, tag_spans in spans.items(): - ret[tag_name] = [(to_index(start), to_index(end)) for start, end in tag_spans] - return ret - - def _tag_index_ranges(self, tag_name : str, spans, batch_size : int = 512): - for offset in range(0, len(spans), batch_size): - args = [] - for start, end in spans[offset:offset + batch_size]: - args.append(start) - args.append(end) - if args: - self.source_text.tag_add(tag_name, *args) - - def _clear_source_tags(self): - for tag_name in ("java_keyword", "java_comment", "java_string", "java_annotation", "manifest_string"): - self.source_text.tag_remove(tag_name, "1.0", tk.END) - - def _highlight_quoted_strings(self, text : str): - spans = [] - start = None - escape = False - line = 1 - col = 0 - for ch in text: - if start is None: - if ch == '"': - start = f"{line}.{col}" - escape = False - else: - if escape: - escape = False - elif ch == "\\": - escape = True - elif ch == '"': - spans.append((start, f"{line}.{col + 1}")) - start = None - if ch == "\n": - line += 1 - col = 0 - else: - col += 1 - self._tag_index_ranges("manifest_string", spans) - - def _collect_basic_java_highlight_spans(self, text : str): - spans = { - "java_keyword": [], - "java_comment": [], - "java_string": [], - "java_annotation": [], - } - n = len(text) - i = 0 - while i < n: - ch = text[i] - - if ch == "/" and i + 1 < n: - nxt = text[i + 1] - if nxt == "/": - start = i - i += 2 - while i < n and text[i] != "\n": - i += 1 - spans["java_comment"].append((start, i)) - continue - if nxt == "*": - start = i - i += 2 - while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"): - i += 1 - i = min(i + 2, n) - spans["java_comment"].append((start, i)) - continue - - if ch == '"' or ch == "'": - quote = ch - start = i - i += 1 - while i < n: - cur = text[i] - if cur == "\\": - i += 2 - continue - if cur == quote: - i += 1 - break - i += 1 - spans["java_string"].append((start, i)) - continue - - if ch == "@": - start = i - i += 1 - while i < n and (text[i].isalnum() or text[i] in "._$"): - i += 1 - spans["java_annotation"].append((start, i)) - continue - - if ch.isalpha() or ch == "_": - start = i - i += 1 - while i < n and (text[i].isalnum() or text[i] in "_$"): - i += 1 - token = text[start:i] - if token in _JAVA_KEYWORDS: - spans["java_keyword"].append((start, i)) - continue - - i += 1 - return spans - - def _start_async_highlight(self, text : str): - self._highlight_generation += 1 - generation = self._highlight_generation - if self._highlight_apply_job is not None: - try: - self.root.after_cancel(self._highlight_apply_job) - except tk.TclError: - pass - self._highlight_apply_job = None - self._clear_source_tags() - - def worker(): - spans = self._collect_basic_java_highlight_spans(text) - spans = self._offset_spans_to_index_spans(text, spans) - self.events.put(("highlight_ready", generation, spans)) - - threading.Thread(target=worker, daemon=True).start() - - def _apply_highlight_spans_async(self, generation : int, spans): - if generation != self._highlight_generation: - return - work_items = [] - for tag_name in ("java_comment", "java_string", "java_annotation", "java_keyword"): - for start, end in spans[tag_name]: - work_items.append((tag_name, start, end)) - if not work_items: - return - - def apply_chunk(offset : int = 0): - if generation != self._highlight_generation: - self._highlight_apply_job = None - return - end_offset = min(offset + self._highlight_apply_batch, len(work_items)) - for idx in range(offset, end_offset): - tag_name, start, end = work_items[idx] - self.source_text.tag_add(tag_name, start, end) - if end_offset < len(work_items): - self._highlight_apply_job = self.root.after(1, apply_chunk, end_offset) - else: - self.source_text.tag_raise("user_comment") - self.source_text.tag_lower("active_line") - self._highlight_apply_job = None - - apply_chunk() - - def _show_editor_find(self, _event = None): - self._hide_member_links() - self.editor_find_frame.grid() - self.editor_find_entry.focus_set() - self.editor_find_entry.selection_range(0, tk.END) - self._last_editor_find_text = self.editor_find_var.get() - self._refresh_editor_find_marks(reset_cursor=True) - return "break" - - def _hide_editor_find(self, _event = None): - self.editor_find_frame.grid_remove() - self.source_text.tag_remove("find_match", "1.0", tk.END) - self.source_text.tag_remove("find_current", "1.0", tk.END) - self.editor_find_status_var.set("") - self.source_text.focus_set() - return "break" - - def _refresh_editor_find_marks(self, reset_cursor : bool = False): - needle = self.editor_find_var.get() - self.source_text.tag_remove("find_match", "1.0", tk.END) - self.source_text.tag_remove("find_current", "1.0", tk.END) - if reset_cursor: - self.source_text.mark_set("insert", "1.0") - if not needle: - self.editor_find_status_var.set("") - return 0 - - count = tk.IntVar() - pos = "1.0" - hits = 0 - while True: - idx = self.source_text.search(needle, pos, stopindex=tk.END, nocase=True, count=count) - if not idx: - break - if count.get() <= 0: - break - end = f"{idx}+{count.get()}c" - self.source_text.tag_add("find_match", idx, end) - hits += 1 - pos = end - self.editor_find_status_var.set(f"{hits} matches" if hits else "No match") - return hits - - def _editor_find_step(self, backwards : bool): - needle = self.editor_find_var.get() - if not needle: - self.editor_find_status_var.set("Empty query") - return "break" - - current_ranges = self.source_text.tag_ranges("find_current") - current_start = str(current_ranges[0]) if current_ranges else None - current_end = str(current_ranges[1]) if current_ranges else None - self._refresh_editor_find_marks(reset_cursor=False) - count = tk.IntVar() - insert_idx = self.source_text.index("insert") - if backwards: - start_idx = current_start or insert_idx - idx = self.source_text.search( - needle, start_idx, stopindex="1.0", backwards=True, nocase=True, count=count - ) - if not idx: - idx = self.source_text.search( - needle, tk.END, stopindex="1.0", backwards=True, nocase=True, count=count - ) - else: - start_idx = current_end or insert_idx - idx = self.source_text.search(needle, start_idx, stopindex=tk.END, nocase=True, count=count) - if not idx: - idx = self.source_text.search(needle, "1.0", stopindex=tk.END, nocase=True, count=count) - if not idx or count.get() <= 0: - self.editor_find_status_var.set("No match") - return "break" - - end = f"{idx}+{count.get()}c" - self.source_text.tag_remove("find_current", "1.0", tk.END) - self.source_text.tag_add("find_current", idx, end) - self.source_text.mark_set("insert", end if not backwards else idx) - self.source_text.see(idx) - self.editor_find_status_var.set(f"Match at {idx}") - return "break" - - def _editor_find_next(self): - return self._editor_find_step(False) - - def _editor_find_prev(self): - return self._editor_find_step(True) - - def _on_editor_find_next(self, _event = None): - return self._editor_find_next() - - def _on_editor_find_prev(self, _event = None): - return self._editor_find_prev() - - def _on_editor_find_return(self, event = None): - if event is not None and (event.state & 0x0001): - return self._editor_find_prev() - return self._editor_find_next() - - def _on_editor_find_changed(self, _event = None): - if _event is not None and _event.keysym in ( - "Return", "KP_Enter", "Shift_L", "Shift_R", "Control_L", "Control_R", "Alt_L", "Alt_R", - ): - return "break" - current = self.editor_find_var.get() - if current == self._last_editor_find_text: - return - self._last_editor_find_text = current - self._refresh_editor_find_marks(reset_cursor=True) - - def _show_line_comment_dialog(self, _event = None): - tab = self._active_tab() - if tab is None or tab.kind != "class" or tab.loading or tab.error or not tab.source: - return "break" - - line_no = self._comment_line_from_index(self.source_text.index("insert")) - if line_no is None: - return "break" - source_line = self._original_line_text(tab, line_no) - if source_line == "": - return "break" - - win = tk.Toplevel(self.root) - win.title(f"Comment line {line_no}") - win.transient(self.root) - win.resizable(True, False) - self.apply_theme_to_window(win) - - width = 640 - x = self.root.winfo_rootx() + max(80, (self.root.winfo_width() - width) // 2) - y = self.root.winfo_rooty() + 160 - win.geometry(f"{width}x118+{x}+{y}") - win.minsize(420, 118) - - frame = ttk.Frame(win, padding=10) - frame.grid(row=0, column=0, sticky="nsew") - win.columnconfigure(0, weight=1) - frame.columnconfigure(1, weight=1) - - ttk.Label(frame, text=f"Line {line_no}").grid(row=0, column=0, sticky="w") - preview = self._ellipsize_text(source_line.strip(), 92) - ttk.Label(frame, text=preview).grid(row=0, column=1, sticky="ew", padx=(8, 0)) - - comment_var = tk.StringVar(value=tab.comments.get(line_no, "")) - ttk.Label(frame, text="Comment").grid(row=1, column=0, sticky="w", pady=(8, 0)) - entry = ttk.Entry(frame, textvariable=comment_var) - entry.grid(row=1, column=1, sticky="ew", padx=(8, 0), pady=(8, 0)) - - actions = ttk.Frame(frame) - actions.grid(row=2, column=0, columnspan=2, sticky="e", pady=(10, 0)) - - def save(_event = None): - comment = comment_var.get().strip() - self._set_line_comment(tab, line_no, comment) - win.destroy() - return "break" - - def delete_comment(): - self._set_line_comment(tab, line_no, "") - win.destroy() - - ttk.Button(actions, text="Save", command=save).grid(row=0, column=0, sticky="e") - ttk.Button(actions, text="Delete", command=delete_comment).grid(row=0, column=1, sticky="e", padx=(6, 0)) - ttk.Button(actions, text="Cancel", command=win.destroy).grid(row=0, column=2, sticky="e", padx=(6, 0)) - - entry.bind("", save) - entry.bind("", lambda _event: win.destroy()) - entry.focus_set() - entry.selection_range(0, tk.END) - return "break" - - def _ellipsize_text(self, text : str, limit : int): - if len(text) <= limit: - return text - return text[:max(0, limit - 3)] + "..." - - def _set_line_comment(self, tab : EditorTab, line_no : int, comment : str): - if comment: - tab.comments[line_no] = comment - else: - tab.comments.pop(line_no, None) - tab.insert_index = f"{line_no}.0" - try: - tab.yview = self.source_text.yview() - tab.xview = self.source_text.xview() - except tk.TclError: - pass - self._show_tab(tab) - self.status_var.set(f"Updated comment on line {line_no}") - - def _show_rename_dialog(self, _event = None): - tab = self._active_tab() - if tab is None or tab.kind != "class" or tab.loading or tab.error or not tab.source: - return "break" - - source = tab.edited_source or tab.source - try: - cursor_index = self.source_text.index("insert") - except tk.TclError: - return "break" - offset = index_to_offset(source, cursor_index) - token = token_at_offset(source, offset) - if token is None: - self.status_var.set("No identifier at cursor") - return "break" - start, _end, old_name = token - method_range = find_method_range(source, start) - if method_range is None: - self.status_var.set("Rename is only available inside a method body") - return "break" - - win = tk.Toplevel(self.root) - win.title(f"Rename {old_name}") - win.transient(self.root) - win.resizable(True, False) - self.apply_theme_to_window(win) - - width = 520 - x = self.root.winfo_rootx() + max(80, (self.root.winfo_width() - width) // 2) - y = self.root.winfo_rooty() + 160 - win.geometry(f"{width}x110+{x}+{y}") - win.minsize(360, 110) - - frame = ttk.Frame(win, padding=10) - frame.grid(row=0, column=0, sticky="nsew") - win.columnconfigure(0, weight=1) - frame.columnconfigure(1, weight=1) - - ttk.Label(frame, text="Rename").grid(row=0, column=0, sticky="w") - name_var = tk.StringVar(value=old_name) - entry = ttk.Entry(frame, textvariable=name_var) - entry.grid(row=0, column=1, sticky="ew", padx=(8, 0)) - - actions = ttk.Frame(frame) - actions.grid(row=1, column=0, columnspan=2, sticky="e", pady=(12, 0)) - - def save(_event = None): - new_name = name_var.get().strip() - if not is_identifier(new_name): - self.status_var.set("Invalid identifier") - return "break" - self._rename_identifier(tab, method_range, old_name, new_name) - win.destroy() - return "break" - - ttk.Button(actions, text="Rename", command=save).grid(row=0, column=0, sticky="e") - ttk.Button(actions, text="Cancel", command=win.destroy).grid(row=0, column=1, sticky="e", padx=(6, 0)) - - entry.bind("", save) - entry.bind("", lambda _event: win.destroy()) - entry.focus_set() - entry.selection_range(0, tk.END) - return "break" - - def _rename_identifier(self, tab : EditorTab, method_range, old_name : str, new_name : str): - if old_name == new_name: - return - source = tab.edited_source or tab.source - renamed_spans = identifier_occurrences_in_range( - source, method_range[0], method_range[1], old_name - ) - new_source, count = rename_identifier_in_range( - source, - method_range[0], - method_range[1], - old_name, - new_name, - ) - if count == 0: - self.status_var.set(f"No occurrences of {old_name} in current method") - return - tab.edited_source = new_source - tab.member_references = remap_ranges_after_replacements( - tab.member_references, renamed_spans, len(new_name) - ) - try: - tab.insert_index = self.source_text.index("insert") - tab.yview = self.source_text.yview() - tab.xview = self.source_text.xview() - except tk.TclError: - pass - self._show_tab(tab) - self.status_var.set(f"Renamed {count} occurrence(s) in current method") - - def open_class(self, dalvik_class : str): - if dalvik_class is None or self.store is None: - return - idx = self._find_tab_index(dalvik_class) - created = idx is None - if idx is None: - title = _class_label(dalvik_class) - self._remember_active_view() - self.editor_tabs.append(EditorTab(dalvik_class=dalvik_class, title=title)) - idx = len(self.editor_tabs) - 1 - self.activate_tab(idx) - tab = self.editor_tabs[idx] - if tab.loading and not created: - return - if not tab.error and tab.source: - return - tab.loading = True - tab.error = "" - tab.source = "" - self._show_tab(tab) - self.editor_tab_bar.redraw() - - self.status_var.set(f"Decompiling {dalvik_to_dot(dalvik_class)}...") - _debug_log(self.debug, "app", f"open class request class={dalvik_class}") - - def worker(): - try: - _debug_log(self.debug, "app", f"open class worker start class={dalvik_class}") - dex_name, source, member_references = self.store.get_source_with_metadata(dalvik_class) - self.events.put(( - "source_done", dalvik_class, dex_name, source, member_references - )) - _debug_log(self.debug, "app", f"open class worker done class={dalvik_class} dex={dex_name}") - except Exception as e: - if self.debug: - traceback.print_exc() - self.events.put(("source_error", dalvik_class, f"Decompile failed: {e}")) - - threading.Thread(target=worker, daemon=True).start() - def open_search_result(self, row, find_type : str): - class_name = row["class_name"] - target_type = row.get("target_type", "") - if find_type in ("method", "field"): - self._pending_member_navigation = ( - class_name, target_type, row.get("target_name", ""), - row.get("target_descriptor", ""), - ) - else: - self._pending_member_navigation = ( - class_name, "search", row.get("method_name", ""), - row.get("method_descriptor", ""), target_type, - row.get("target_class", ""), row.get("target_name", ""), - row.get("target_descriptor", ""), row.get("occurrence", 0), - ) - self.open_class(class_name) - self._complete_pending_member_navigation(class_name) + self.editor.open_search_result(row, find_type) + + def _start_reference_search(self, member_type : str, member_name : str, class_name : str): + self.search_type_var.set(f"{member_type} refs") + self.search_value_var.set(member_name) + self.search_class_var.set(class_name) + self.fuzzy_class_var.set(False) + self._on_search_type_changed() + self._start_search(exact_member=True) def _start_search(self, exact_member : bool = False): if self.store is None: @@ -1724,11 +391,14 @@ def _start_search(self, exact_member : bool = False): return self.search_dialog = SearchDialog( - self, + self.root, find_type=find_type, value=value, class_name=class_name or None, fuzzy_class=fuzzy_class, + open_result=self.open_search_result, + apply_theme=self.apply_theme_to_window, + on_destroy=self._on_search_dialog_destroyed, ) self.search_inflight = True self.search_button.config(state=tk.DISABLED) @@ -1742,6 +412,8 @@ def worker(): try: _debug_log(self.debug, "app", "search worker start") if is_member_search(find_type): + # Declaration listing uses already-loaded locator indexes; + # xrefs below require isolated process workers. payload = self.store.search_members( find_type=find_type, value=value, @@ -1781,10 +453,16 @@ def worker(): traceback.print_exc() self.events.put(("search_error", f"Search failed: {e}")) finally: + # A fresh pool keeps later searches away from module state which + # a decompile may have loaded or replaced in this process. self._dispose_search_executor() threading.Thread(target=worker, daemon=True).start() + def _on_search_dialog_destroyed(self, dialog): + if self.search_dialog is dialog: + self.search_dialog = None + def launch_gui(apk_path : str, max_workers : int = 20, debug : bool = False): root = tk.Tk() @@ -1795,6 +473,7 @@ def launch_gui(apk_path : str, max_workers : int = 20, debug : bool = False): def on_close(): nonlocal closed + # protocol callback and mainloop finally can both reach this function. if closed: return closed = True diff --git a/droidasc/asc_client/gui/backend/__init__.py b/droidasc/asc_client/gui/backend/__init__.py new file mode 100644 index 0000000..8202731 --- /dev/null +++ b/droidasc/asc_client/gui/backend/__init__.py @@ -0,0 +1,5 @@ +from .search import dalvik_to_dot, is_member_search +from .store import GuiDexStore + + +__all__ = ["GuiDexStore", "dalvik_to_dot", "is_member_search"] diff --git a/droidasc/asc_client/gui/backend/search.py b/droidasc/asc_client/gui/backend/search.py new file mode 100644 index 0000000..3e01267 --- /dev/null +++ b/droidasc/asc_client/gui/backend/search.py @@ -0,0 +1,553 @@ +import json +import os +import subprocess +import sys +import tempfile +import threading +import time +import traceback +from concurrent.futures import ThreadPoolExecutor, as_completed + +from droidasc.asc_client.apk.worker import _findrefs_worker + + +_MAX_UI_RESULTS = 5000 +# Source decompilation temporarily changes sys.modules while process-pool +# submission imports multiprocessing internals. Serialize those two operations. +_GUI_MP_LOCK = threading.Lock() +_REF_SEARCH_TYPES = { + "string refs": "string", + "type refs": "type", + "method refs": "method", + "field refs": "field", +} + + +def _debug_log(enabled : bool, scope : str, msg : str): + if not enabled: + return + tid = threading.get_ident() & 0xFFFF + now = time.perf_counter() + print(f"[GUI DEBUG] [{scope}] [T{tid:04x}] {now:.6f} {msg}") + + +def _multiprocessing_state() -> str: + mp_mod = sys.modules.get("multiprocessing") + red_mod = sys.modules.get("multiprocessing.reduction") + return ( + f"multiprocessing={type(mp_mod).__name__}" + f" reduction={type(red_mod).__name__}" + ) + + +def _snapshot_sys_modules(): + return dict(sys.modules) + + +def _restore_sys_modules(snapshot): + # Dadguard/Androguard load optional modules dynamically. Restore both added + # and replaced entries so a decompile cannot poison later process spawning. + for name in tuple(sys.modules.keys()): + if name not in snapshot: + sys.modules.pop(name, None) + sys.modules.update(snapshot) + + +def format_class_name(name : str) -> str: + if not name: + raise ValueError("Class name cannot be empty") + if name.startswith("L") and name.endswith(";") and "/" in name: + return name + name = name.replace(".", "/") + if not name.startswith("L"): + name = f"L{name}" + if not name.endswith(";"): + name = f"{name};" + return name + + +def dalvik_to_dot(name : str) -> str: + if name.startswith("L") and name.endswith(";"): + return name[1:-1].replace("/", ".") + return name + + +def _normalize_class_query(name : str, fuzzy : bool): + if name is None or name == "": + return None + if fuzzy: + if "." in name and "/" not in name: + return name.replace(".", "/") + return name + return format_class_name(name) + + +def build_find_query( + find_type : str, value : str, class_name = None, fuzzy_class : bool = False, + exact_member : bool = False, +): + find_type = _REF_SEARCH_TYPES.get(find_type, find_type) + if find_type == "string": + return "string", {"string": value} + if find_type == "type": + return "type", {"type": value} + + class_name = _normalize_class_query(class_name, fuzzy_class) + if class_name is None and not value: + raise ValueError(f"{find_type} query needs at least one of class or {find_type} name") + + # FindRefManager uses [value, precise] for exact matching; a plain string is + # intentionally fuzzy. Keep this protocol at the client/core boundary. + member = [value, True] if exact_member and value else value or None + if class_name is None: + return find_type, {find_type: {"class": None, find_type: member}} + return find_type, {find_type: {"class": [class_name, not fuzzy_class], find_type: member}} + + +def _split_member_text(text : str, member_type : str): + class_name, member_text = text.split("->", 1) + if member_type == "method": + split_at = member_text.find("(") + else: + split_at = member_text.rfind(":") + if split_at < 0: + return class_name, member_text, "" + if member_type == "method": + return class_name, member_text[:split_at], member_text[split_at:] + return class_name, member_text[:split_at], member_text[split_at + 1:] + + +def parse_result_line(line : str, find_type : str = ""): + # Worker output is a stable text protocol shared by CLI, process-pool and + # subprocess backends. Parse it once into the row model used by the GUI. + dex_name, method_text, matched_text = line.split(" | ", 2) + matched_text, sep, occurrence_text = matched_text.rpartition(" | occurrence=") + occurrence = int(occurrence_text) if sep and occurrence_text.isdigit() else 0 + if not sep: + matched_text = occurrence_text + class_name, method_name, method_descriptor = _split_member_text(method_text, "method") + matched_value = matched_text[len("matched=("):-1] if matched_text.startswith("matched=(") else matched_text + target_class = "" + target_name = matched_value + target_descriptor = "" + if find_type in ("method", "field"): + target_class, target_name, target_descriptor = _split_member_text(matched_value, find_type) + elif find_type == "type": + target_class = matched_value + return { + "dex_name": dex_name, + "class_name": class_name, + "class_display": dalvik_to_dot(class_name), + "method_text": method_text, + "method_name": method_name, + "method_descriptor": method_descriptor, + "matched_text": matched_text, + "target_type": "class" if find_type == "type" else find_type, + "target_class": target_class, + "target_name": target_name, + "target_descriptor": target_descriptor, + "occurrence": occurrence, + "line": line, + } + + +def is_member_search(find_type : str) -> bool: + return find_type in ("method", "field") + + +def get_effective_search_workers(store, requested = None): + cpu = os.cpu_count() or 4 + workers = requested if requested is not None else store.max_workers + if workers is None or workers <= 0: + workers = cpu + if len(store.entries) > 1: + workers = max(2, workers) + return min(len(store.entries), workers) + + +def search_members(store, find_type : str, value : str, limit : int = _MAX_UI_RESULTS): + keyword = (value or "").strip() + dex_names = [entry[0] for entry in store.entries] + + def search_dex(dex_name): + manager = store.findref_managers.get(dex_name) + if manager is None: + return 0, [] + dex = manager.dex + dex_rows = [] + # Direct member search asks locator indexes for declarations. Reference + # search takes the worker path below and scans containing methods. + if find_type == "method": + locator = manager._get_method_locator(True) + member_idxs = locator.locate({"class": None, "method": keyword}) + for midx in sorted(member_idxs): + method = dex.methods[midx] + class_name = method.cls.fullname + proto = method.prototype + descriptor = "(" + "".join(item.descriptor for item in proto.parameters_type) + ")" + descriptor += dex.get_type(proto.return_type_idx).descriptor + method_text = f"{class_name}->{method.name}{descriptor}" + dex_rows.append({ + "dex_name": dex_name, + "class_name": class_name, + "class_display": dalvik_to_dot(class_name), + "method_text": method_text, + "method_name": method.name, + "method_descriptor": descriptor, + "matched_text": f"method=({method.name})", + "target_type": "method", + "target_class": class_name, + "target_name": method.name, + "target_descriptor": descriptor, + "occurrence": 0, + "line": method_text, + }) + return len(member_idxs), dex_rows + + locator = manager._get_field_locator(True) + member_idxs = locator.locate({"class": None, "field": keyword}) + for fidx in sorted(member_idxs): + field = dex.fields[fidx] + class_name = field.cls.fullname + method_text = f"{class_name}->{field.name}" + dex_rows.append({ + "dex_name": dex_name, + "class_name": class_name, + "class_display": dalvik_to_dot(class_name), + "method_text": method_text, + "method_name": field.name, + "method_descriptor": "", + "matched_text": f"field=({field.name})", + "target_type": "field", + "target_class": class_name, + "target_name": field.name, + "target_descriptor": field.type.descriptor, + "occurrence": 0, + "line": method_text, + }) + return len(member_idxs), dex_rows + + rows = [] + total_hits = 0 + workers = get_effective_search_workers(store) + if len(dex_names) <= 1 or workers <= 1: + results = [search_dex(dex_name) for dex_name in dex_names] + else: + with ThreadPoolExecutor(max_workers=workers) as ex: + futures = [ex.submit(search_dex, dex_name) for dex_name in dex_names] + # Resolve in submission order so rows follow the loaded DEX order, + # even though each DEX locator runs concurrently. + results = [fut.result() for fut in futures] + + for hit_count, dex_rows in results: + total_hits += hit_count + if len(rows) < limit: + rows.extend(dex_rows[:limit - len(rows)]) + + return { + "find_type": find_type, + "query_value": value, + "results": rows, + "total_hits": total_hits, + "workers": workers, + "backend": "locator", + } + + +def _search_with_process_pool( + store, executor, workers, find_type, find, progress_callback, + result_callback = None, +): + total = len(store.entries) + hit_count = 0 + shown = [] + # Keep process submission outside decompiler module mutation. Completion + # handling no longer imports worker state and does not need the lock. + with _GUI_MP_LOCK: + _debug_log( + store.debug, + "runtime", + f"search process start dex={total} workers={workers} " + f"{_multiprocessing_state()} executor={id(executor)}", + ) + + futures = {} + for entry in store.entries: + try: + _debug_log(store.debug, "runtime", f"submit dex={entry[0]}") + fut = executor.submit( + _findrefs_worker, + store.apk_path, + entry, + find_type, + find, + False, + ) + except Exception as e: + _debug_log( + store.debug, + "runtime", + f"submit failed dex={entry[0]} err={type(e).__name__}: {e} " + f"{_multiprocessing_state()}", + ) + traceback.print_exc() + for pending in futures: + pending.cancel() + raise + futures[fut] = entry[0] + + done = 0 + try: + for fut in as_completed(futures): + dex_name = futures.pop(fut) + try: + dex_name, lines, _inflate_us, _process_us, _pid = fut.result() + except Exception as e: + _debug_log( + store.debug, + "runtime", + f"result failed dex={dex_name} err={type(e).__name__}: {e} " + f"{_multiprocessing_state()}", + ) + traceback.print_exc() + raise + done += 1 + # total_hits counts every match, while shown is capped to protect Tk + # from creating an unbounded result tree. + hit_count += len(lines) + _debug_log( + store.debug, + "runtime", + f"result ok dex={dex_name} pid={_pid} hits={len(lines)} done={done}/{total}", + ) + batch_rows = [] + if len(shown) < _MAX_UI_RESULTS: + remain = _MAX_UI_RESULTS - len(shown) + for line in lines[:remain]: + row = parse_result_line(line, find_type) + shown.append(row) + batch_rows.append(row) + if batch_rows and result_callback is not None: + result_callback(batch_rows, done, total, hit_count) + if progress_callback is not None: + progress_callback(done, total, hit_count) + except Exception: + for pending in futures: + pending.cancel() + raise + + return hit_count, shown + + +def _unlink_result(path): + try: + os.unlink(path) + except OSError: + pass + + +def _close_pipe(pipe): + if pipe is None: + return + try: + pipe.close() + except OSError: + pass + + +def _stop_worker(proc): + if proc.poll() is not None: + return + proc.terminate() + try: + proc.wait(timeout=1) + except (subprocess.TimeoutExpired, TimeoutError): + proc.kill() + proc.wait(timeout=1) + + +def _cleanup_worker(proc, result_path, stop=False): + if stop: + try: + _stop_worker(proc) + except OSError: + pass + _close_pipe(getattr(proc, "stdin", None)) + _close_pipe(getattr(proc, "stderr", None)) + _unlink_result(result_path) + + +def _launch_subprocess_worker(store, entry, find_type, find): + # stdout is reserved for launcher/debug output. A temporary JSON file keeps + # large result payloads out of pipe buffers and avoids child deadlocks. + result_file = tempfile.NamedTemporaryFile( + prefix="asc_gui_find_", + suffix=".json", + delete=False, + ) + result_path = result_file.name + result_file.close() + + payload = { + "apk_path": store.apk_path, + "entry": list(entry), + "find_type": find_type, + "find": find, + "result_path": result_path, + } + cmd = [sys.executable, "-m", "droidasc.asc_client.gui.backend.worker"] + creationflags = 0 + if os.name == "nt": + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + + proc = None + try: + proc = subprocess.Popen( + cmd, + cwd=os.getcwd(), + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + creationflags=creationflags, + ) + proc.stdin.write(json.dumps(payload)) + proc.stdin.close() + return proc, result_path + except Exception: + if proc is not None: + _cleanup_worker(proc, result_path, stop=True) + else: + _unlink_result(result_path) + raise + + +def _search_with_subprocess_pool( + store, workers, find_type, find, progress_callback, result_callback = None, +): + total = len(store.entries) + hit_count = 0 + shown = [] + done = 0 + idx = 0 + running = [] + + try: + while idx < len(store.entries) or running: + while idx < len(store.entries) and len(running) < workers: + entry = store.entries[idx] + idx += 1 + proc, result_path = _launch_subprocess_worker( + store, + entry, + find_type, + find, + ) + running.append((proc, result_path, entry[0])) + + if not running: + break + + completed = False + next_running = [] + for proc, result_path, dex_name in running: + if proc.poll() is None: + next_running.append((proc, result_path, dex_name)) + continue + + completed = True + stderr_text = proc.stderr.read() if proc.stderr is not None else "" + if proc.returncode != 0: + raise RuntimeError( + f"Search worker failed for {dex_name}: " + f"{stderr_text.strip() or proc.returncode}" + ) + + with open(result_path, "r", encoding="utf-8") as fp: + payload = json.load(fp) + _cleanup_worker(proc, result_path) + + lines = payload["lines"] + done += 1 + hit_count += len(lines) + batch_rows = [] + if len(shown) < _MAX_UI_RESULTS: + remain = _MAX_UI_RESULTS - len(shown) + for line in lines[:remain]: + row = parse_result_line(line, find_type) + shown.append(row) + batch_rows.append(row) + if batch_rows and result_callback is not None: + result_callback(batch_rows, done, total, hit_count) + if progress_callback is not None: + progress_callback(done, total, hit_count) + + running = next_running + if running and not completed: + # Poll all children without busy-spinning while still allowing + # batches from faster DEX files to stream to the UI. + time.sleep(0.01) + finally: + for proc, result_path, _dex_name in running: + _cleanup_worker(proc, result_path, stop=True) + + return hit_count, shown + + +def search( + store, + find_type : str, + value : str, + class_name = None, + fuzzy_class : bool = False, + exact_member : bool = False, + max_workers = None, + progress_callback = None, + result_callback = None, +): + find_type, find = build_find_query( + find_type, + value, + class_name, + fuzzy_class, + exact_member, + ) + workers = get_effective_search_workers(store, max_workers) + _debug_log( + store.debug, + "runtime", + f"search request type={find_type} value={value!r} class={class_name!r} " + f"fuzzy={fuzzy_class} workers={workers}", + ) + # The app-owned ProcessPoolExecutor is faster when its import state is safe. + # The isolated subprocess protocol is the fallback for hostile module state. + if store.search_executor is not None: + backend = "process" + hit_count, shown = _search_with_process_pool( + store, + store.search_executor, + workers, + find_type, + find, + progress_callback, + result_callback, + ) + else: + backend = "subprocess" + hit_count, shown = _search_with_subprocess_pool( + store, + workers, + find_type, + find, + progress_callback, + result_callback, + ) + + return { + "find_type": find_type, + "query_value": value, + "results": shown, + "total_hits": hit_count, + "workers": workers, + "backend": backend, + } diff --git a/droidasc/asc_client/gui/backend/store.py b/droidasc/asc_client/gui/backend/store.py new file mode 100644 index 0000000..7413b93 --- /dev/null +++ b/droidasc/asc_client/gui/backend/store.py @@ -0,0 +1,257 @@ +import os +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed + +from droidasc.asc_client.apk.loader import _inflate_dex, _parse_cd_dex_entries +from droidasc.asc_client.dex_container import iter_logical_dex_buffers +from droidasc.asc_core.findrefs.findrefs_manager import FindRefManager +from droidasc.asc_core.utils.tinydex import DEX + +from .search import ( + _GUI_MP_LOCK, + _debug_log, + _multiprocessing_state, + _restore_sys_modules, + _snapshot_sys_modules, + dalvik_to_dot, + get_effective_search_workers, + search, + search_members, +) + + +class GuiDexStore: + def __init__( + self, + apk_path : str, + max_workers : int = 20, + debug : bool = False, + search_executor = None, + ): + self.apk_path = apk_path + self.max_workers = max_workers + self.debug = debug + self.search_executor = search_executor + + self.entries = [] + self.dex_buffers = {} + self.class_to_dex = {} + self.class_names = [] + self.package_children = {} + self.package_classes = {} + self.findref_managers = {} + self.source_cache = {} + self._source_lock = threading.Lock() + + def _open_apk(self): + import mmap + + fp = open(self.apk_path, "rb") + mm = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ) + return fp, mm + + def _load_worker_count(self, total : int): + workers = self.max_workers + if workers is None or workers <= 0: + workers = os.cpu_count() or 4 + return min(total, workers) + + def load(self, progress_callback = None): + fp, mm = self._open_apk() + try: + entries = _parse_cd_dex_entries(mm) + entries.sort(key=lambda item: item[2]) + self.entries = entries + total = len(entries) + _debug_log( + self.debug, + "runtime", + f"load start apk={self.apk_path} dex={total}", + ) + if total == 0: + self.class_names = [] + return + + def load_entry(entry): + # mmap is read-only and each task copies/decompresses only its + # own ZIP range, so DEX entries can load concurrently. + data = _inflate_dex(mm, entry) + logical = [] + # One physical DEX 041 entry may expose several logical DEXes; + # index each one under its synthetic stable name. + for dex_name, dex_buf in iter_logical_dex_buffers(entry[0], data): + dex = DEX.parse(memoryview(dex_buf), dex_name) + classes = [ + dex.classes[class_idx].fullname + for class_idx in range(len(dex.classes)) + ] + logical.append((dex_name, dex_buf, dex, classes)) + return entry, logical + + done = 0 + class_count = 0 + workers = self._load_worker_count(total) + with ThreadPoolExecutor(max_workers=workers) as executor: + futures = [executor.submit(load_entry, entry) for entry in entries] + for future in as_completed(futures): + entry, logical = future.result() + for dex_name, dex_buf, dex, classes in logical: + self.dex_buffers[dex_name] = dex_buf + self.findref_managers[dex_name] = FindRefManager( + dex, + self.debug, + ) + for class_name in classes: + self.class_to_dex.setdefault(class_name, dex_name) + class_count += len(classes) + done += 1 + if progress_callback is not None: + progress_callback(done, total, entry[0], class_count) + + self.class_names = sorted(self.class_to_dex) + self._build_package_index() + _debug_log( + self.debug, + "runtime", + f"load done dex={len(self.entries)} classes={len(self.class_names)}", + ) + finally: + mm.close() + fp.close() + + def _build_package_index(self): + package_children = {"": set()} + package_classes = {} + + # Build both adjacency and class buckets once. Tree expansion can then + # stay lazy without rescanning every class for each opened package. + for class_name in self.class_names: + if class_name.startswith("L") and class_name.endswith(";"): + body = class_name[1:-1] + else: + body = class_name + parts = body.split("/") + pkg_path = "" + + for part in parts[:-1]: + next_path = f"{pkg_path}/{part}" if pkg_path else part + package_children.setdefault(pkg_path, set()).add(next_path) + package_children.setdefault(next_path, set()) + pkg_path = next_path + + package_classes.setdefault(pkg_path, []).append(class_name) + + self.package_children = { + pkg: sorted(children, key=lambda item: item.lower()) + for pkg, children in package_children.items() + } + self.package_classes = {} + for pkg_path, classes in package_classes.items(): + classes.sort( + key=lambda item: item.split("/")[-1].rstrip(";").lower() + ) + self.package_classes[pkg_path] = classes + + def iter_root_packages(self): + return self.package_children.get("", []) + + def iter_child_packages(self, pkg_path : str): + return self.package_children.get(pkg_path, []) + + def iter_package_classes(self, pkg_path : str): + return self.package_classes.get(pkg_path, []) + + def iter_filtered_classes(self, keyword : str, limit : int): + keyword = (keyword or "").strip().lower() + if not keyword: + return [] + + ret = [] + for class_name in self.class_names: + if keyword not in dalvik_to_dot(class_name).lower(): + continue + ret.append(class_name) + if len(ret) >= limit: + break + return ret + + def get_source(self, dalvik_class : str): + dex_name, source, _references = self.get_source_with_metadata(dalvik_class) + return dex_name, source + + def get_source_with_metadata(self, dalvik_class : str): + # Cache lookup and publication are protected separately from the slow + # decompile so unrelated cached classes are never blocked. + with self._source_lock: + old = self.source_cache.get(dalvik_class) + if old is not None: + _debug_log( + self.debug, + "runtime", + f"source cache hit class={dalvik_class}", + ) + return old + + dex_name = self.class_to_dex.get(dalvik_class) + if dex_name is None: + raise ValueError(f"class not indexed: {dalvik_class}") + + dex_buf = self.dex_buffers[dex_name] + _debug_log( + self.debug, + "runtime", + f"source start class={dalvik_class} dex={dex_name} " + f"{_multiprocessing_state()}", + ) + from droidasc.asc_client.asc_handler import AscHandler + + # AscHandler imports optional decompiler dependencies which can replace + # sys.modules entries with stubs. Process spawning must not observe that + # transient graph, hence the shared lock and exact snapshot restoration. + with _GUI_MP_LOCK: + snapshot = _snapshot_sys_modules() + try: + source, references = AscHandler( + self.debug + ).getclass_with_metadata(dex_buf, dalvik_class) + finally: + _restore_sys_modules(snapshot) + ret = (dex_name, source, references) + with self._source_lock: + self.source_cache[dalvik_class] = ret + _debug_log( + self.debug, + "runtime", + f"source done class={dalvik_class} dex={dex_name} " + f"{_multiprocessing_state()}", + ) + return ret + + def search_members(self, find_type : str, value : str, limit : int = 5000): + return search_members(self, find_type, value, limit) + + def get_effective_search_workers(self, requested = None): + return get_effective_search_workers(self, requested) + + def search( + self, + find_type : str, + value : str, + class_name = None, + fuzzy_class : bool = False, + exact_member : bool = False, + max_workers = None, + progress_callback = None, + result_callback = None, + ): + return search( + self, + find_type, + value, + class_name, + fuzzy_class, + exact_member, + max_workers, + progress_callback, + result_callback, + ) diff --git a/droidasc/asc_client/gui/search_worker.py b/droidasc/asc_client/gui/backend/worker.py similarity index 77% rename from droidasc/asc_client/gui/search_worker.py rename to droidasc/asc_client/gui/backend/worker.py index acf0b39..dcc4c04 100644 --- a/droidasc/asc_client/gui/search_worker.py +++ b/droidasc/asc_client/gui/backend/worker.py @@ -1,11 +1,12 @@ import json -import os import sys -from droidasc.asc_client.apk_handler import _findrefs_worker +from droidasc.asc_client.apk.worker import _findrefs_worker def main(): + # The parent sends a small request through stdin; potentially large result + # rows go to a file so neither side can block on a full stdout pipe. payload = json.load(sys.stdin) entry = tuple(payload["entry"]) dex_name, lines, inflate_us, process_us, pid = _findrefs_worker( @@ -15,7 +16,6 @@ def main(): payload["find"], aggregate=False, ) - result = { "dex_name": dex_name, "lines": lines, @@ -23,7 +23,6 @@ def main(): "process_us": process_us, "pid": pid, } - with open(payload["result_path"], "w", encoding="utf-8") as fp: json.dump(result, fp, ensure_ascii=False) diff --git a/droidasc/asc_client/gui/editor/__init__.py b/droidasc/asc_client/gui/editor/__init__.py new file mode 100644 index 0000000..87be27c --- /dev/null +++ b/droidasc/asc_client/gui/editor/__init__.py @@ -0,0 +1,5 @@ +from .controller import EditorController +from .view import EditorView + + +__all__ = ["EditorController", "EditorView"] diff --git a/droidasc/asc_client/gui/editor/controller.py b/droidasc/asc_client/gui/editor/controller.py new file mode 100644 index 0000000..95028e0 --- /dev/null +++ b/droidasc/asc_client/gui/editor/controller.py @@ -0,0 +1,536 @@ +import threading +import traceback +import tkinter as tk + +from droidasc.asc_client.gui.backend import dalvik_to_dot +from droidasc.asc_client.gui.editor.source import ( + decode_java_unicode_escapes_with_ranges, + find_member_declaration, + find_method_range, + find_search_reference, + identifier_occurrences_in_range, + index_to_offset, + is_identifier, + remap_ranges_after_replacements, + rename_identifier_in_range, + token_at_offset, +) +from droidasc.asc_client.gui.state import EditorTab +from droidasc.asc_client.gui.ui.dialogs import LineCommentDialog, RenameDialog + + +_SOURCE_NAV_KEYS = { + "Left", "Right", "Up", "Down", "Home", "End", "Prior", "Next", + "Control_L", "Control_R", "Shift_L", "Shift_R", "Alt_L", "Alt_R", +} + + +def _class_label(dalvik_class : str): + return dalvik_class.split("/")[-1].rstrip(";") + + +class EditorController: + def __init__( + self, + root, + view, + get_store, + emit_event, + set_status, + select_class, + start_reference_search, + apply_theme_to_window, + debug : bool = False, + debug_log = None, + ): + self.root = root + self.view = view + self.get_store = get_store + self.emit_event = emit_event + self.set_status = set_status + self.select_class = select_class + self.start_reference_search = start_reference_search + self.apply_theme_to_window = apply_theme_to_window + self.debug = debug + self.debug_log = debug_log or (lambda _scope, _message: None) + self.tabs = [] + self.active_tab_idx = -1 + # Declaration jumps store four fields; search-result jumps store the + # containing method, target metadata and occurrence until source loads. + self.pending_navigation = None + self.tab_bar = None + + def set_tab_bar(self, tab_bar): + self.tab_bar = tab_bar + + def bind_shortcuts(self): + self.view.bind_find_shortcuts() + self.root.bind("", self.close_active_tab) + self.root.bind("", self.close_active_tab) + self.root.bind("", self.next_tab) + self.root.bind("", self.prev_tab) + self.view.text.bind("", self._set_cursor_from_click) + self.view.text.bind("", self._open_member_from_click) + self.view.text.bind("", self.view.hide_member_links, add="+") + self.root.bind("", self.show_member_links) + self.root.bind("", self.show_member_links) + self.root.bind("", self.view.hide_member_links) + self.root.bind("", self.view.hide_member_links) + self.view.text.bind("", self._on_key_press) + self.view.text.bind("", self._on_key_release) + self.view.text.bind("<>", lambda _event: "break") + self.view.text.bind("<>", lambda _event: "break") + self.view.text.bind("<>", lambda _event: "break") + + def _store(self): + return self.get_store() + + def _available_classes(self): + store = self._store() + return store.class_to_dex if store is not None else () + + def _redraw_tabs(self, ensure_index = None): + if self.tab_bar is None: + return + if ensure_index is not None: + self.tab_bar.ensure_visible(ensure_index) + self.tab_bar.redraw() + + def active_tab(self): + if self.active_tab_idx < 0 or self.active_tab_idx >= len(self.tabs): + return None + return self.tabs[self.active_tab_idx] + + def find_tab_index(self, dalvik_class : str): + for idx, tab in enumerate(self.tabs): + if tab.dalvik_class == dalvik_class: + return idx + return None + + def open_text_tab(self, tab_id : str, title : str, source : str, dex_name : str = ""): + idx = self.find_tab_index(tab_id) + if idx is None: + self.view.remember(self.active_tab()) + self.tabs.append(EditorTab(dalvik_class=tab_id, title=title, kind="text")) + idx = len(self.tabs) - 1 + tab = self.tabs[idx] + tab.dex_name = dex_name + tab.source = source + tab.edited_source = "" + tab.loading = False + tab.error = "" + self.activate_tab(idx) + + def activate_tab(self, index : int): + if index < 0 or index >= len(self.tabs): + return + if index == self.active_tab_idx: + self._redraw_tabs(index) + self.complete_pending_navigation(self.tabs[index].dalvik_class) + return + self.view.remember(self.active_tab()) + self.active_tab_idx = index + tab = self.tabs[index] + self.view.show_tab(tab, self._available_classes()) + self._redraw_tabs(index) + if tab.loading: + self.set_status(f"Decompiling {dalvik_to_dot(tab.dalvik_class)}...") + elif tab.error: + self.set_status(tab.error) + elif tab.kind == "class": + self.set_status(f"Opened {dalvik_to_dot(tab.dalvik_class)} from {tab.dex_name}") + else: + self.set_status(f"Opened {tab.title}") + if tab.kind == "class": + self.select_class(tab.dalvik_class) + self.complete_pending_navigation(tab.dalvik_class) + + def close_tab(self, index : int): + if index < 0 or index >= len(self.tabs): + return + self.view.remember(self.active_tab()) + closing_tab = self.tabs[index] + was_active = index == self.active_tab_idx + self.tabs.pop(index) + if self.pending_navigation is not None and self.pending_navigation[0] == closing_tab.dalvik_class: + self.pending_navigation = None + if not self.tabs: + self.active_tab_idx = -1 + if self.tab_bar is not None: + self.tab_bar.tab_scroll_index = 0 + self._redraw_tabs() + self.view.show_empty() + self.set_status("No class tab open") + return + if self.active_tab_idx > index: + # Removing a tab before the active one shifts the active index but + # does not require rerendering the still-visible source. + self.active_tab_idx -= 1 + elif was_active: + self.active_tab_idx = min(index, len(self.tabs) - 1) + self.view.show_tab(self.tabs[self.active_tab_idx], self._available_classes()) + self._redraw_tabs(self.active_tab_idx) + + def clear_tabs(self): + if not self.tabs: + return + self.tabs = [] + self.active_tab_idx = -1 + self.pending_navigation = None + if self.tab_bar is not None: + self.tab_bar.tab_scroll_index = 0 + self._redraw_tabs() + self.view.show_empty() + self.set_status("No class tab open") + + def close_active_tab(self, _event = None): + self.close_tab(self.active_tab_idx) + return "break" + + def _cycle_tab(self, step : int): + if not self.tabs: + return "break" + self.activate_tab((self.active_tab_idx + step) % len(self.tabs)) + return "break" + + def next_tab(self, _event = None): + return self._cycle_tab(1) + + def prev_tab(self, _event = None): + return self._cycle_tab(-1) + + def finish_open_tab(self, dalvik_class : str, dex_name : str, source : str, member_references): + idx = self.find_tab_index(dalvik_class) + if idx is None: + # The user may close a loading tab before its worker event arrives. + return + tab = self.tabs[idx] + tab.dex_name = dex_name + tab.source, tab.member_references = decode_java_unicode_escapes_with_ranges( + source, member_references + ) + tab.edited_source = "" + tab.loading = False + tab.error = "" + if idx == self.active_tab_idx: + self.view.show_tab(tab, self._available_classes()) + self._redraw_tabs(idx if idx == self.active_tab_idx else self.active_tab_idx) + self.set_status(f"Opened {dalvik_to_dot(dalvik_class)} from {dex_name}") + if idx == self.active_tab_idx: + self.complete_pending_navigation(dalvik_class) + + def fail_open_tab(self, dalvik_class : str, message : str): + idx = self.find_tab_index(dalvik_class) + if idx is None: + self.set_status(message) + return + tab = self.tabs[idx] + tab.loading = False + tab.error = message + if self.pending_navigation is not None and self.pending_navigation[0] == dalvik_class: + self.pending_navigation = None + if idx == self.active_tab_idx: + self.view.show_tab(tab, self._available_classes()) + self._redraw_tabs() + self.set_status(message) + + def open_class(self, dalvik_class : str): + store = self._store() + if dalvik_class is None or store is None: + return + idx = self.find_tab_index(dalvik_class) + created = idx is None + if idx is None: + self.view.remember(self.active_tab()) + self.tabs.append(EditorTab(dalvik_class=dalvik_class, title=_class_label(dalvik_class))) + idx = len(self.tabs) - 1 + self.activate_tab(idx) + tab = self.tabs[idx] + if tab.loading and not created: + return + if not tab.error and tab.source: + return + tab.loading = True + tab.error = "" + tab.source = "" + self.view.show_tab(tab, self._available_classes()) + self._redraw_tabs() + self.set_status(f"Decompiling {dalvik_to_dot(dalvik_class)}...") + self.debug_log("app", f"open class request class={dalvik_class}") + + def worker(): + try: + self.debug_log("app", f"open class worker start class={dalvik_class}") + dex_name, source, member_references = store.get_source_with_metadata(dalvik_class) + # Tk must only be touched by the main event drain. + self.emit_event(( + "source_done", dalvik_class, dex_name, source, member_references + )) + self.debug_log( + "app", + f"open class worker done class={dalvik_class} dex={dex_name}", + ) + except Exception as e: + if self.debug: + traceback.print_exc() + self.emit_event(("source_error", dalvik_class, f"Decompile failed: {e}")) + + threading.Thread(target=worker, daemon=True).start() + + def open_search_result(self, row, find_type : str): + class_name = row["class_name"] + target_type = row.get("target_type", "") + if find_type in ("method", "field"): + # Direct member rows jump to a declaration by exact descriptor. + self.pending_navigation = ( + class_name, target_type, row.get("target_name", ""), + row.get("target_descriptor", ""), + ) + else: + # Reference rows first locate their containing method, then select + # the numbered target occurrence inside that method. + self.pending_navigation = ( + class_name, "search", row.get("method_name", ""), + row.get("method_descriptor", ""), target_type, + row.get("target_class", ""), row.get("target_name", ""), + row.get("target_descriptor", ""), row.get("occurrence", 0), + ) + self.open_class(class_name) + self.complete_pending_navigation(class_name) + + def complete_pending_navigation(self, dalvik_class : str): + target = self.pending_navigation + if target is None or target[0] != dalvik_class: + return + idx = self.find_tab_index(dalvik_class) + if idx is None: + return + tab = self.tabs[idx] + # Keep the request pending while an existing/new tab is still loading. + if tab.loading or tab.error or not tab.source: + return + if target[1] == "search": + self._complete_search_navigation(tab, target) + return + _class_name, member_type, member_name, descriptor = target + reference = find_member_declaration( + tab.rendered_member_references, member_type, member_name, descriptor + ) + self.pending_navigation = None + if reference is None: + self.set_status(f"Declaration not found: {dalvik_to_dot(dalvik_class)}->{member_name}") + return + self._move_to_reference(tab, reference) + if member_type == "class": + self.set_status(f"Opened class {dalvik_to_dot(dalvik_class)}") + else: + self.set_status(f"Opened declaration {dalvik_to_dot(dalvik_class)}->{member_name}") + + def _complete_search_navigation(self, tab, target): + ( + dalvik_class, _kind, method_name, method_descriptor, + target_type, target_class, target_name, target_descriptor, occurrence, + ) = target + source = tab.edited_source or tab.source + references = tab.rendered_member_references + if tab.comments: + source, _comment_spans = self.view.render_source(tab) + references = tab.rendered_member_references + reference = find_search_reference( + source, references, method_name, method_descriptor, + target_type, target_class, target_name, target_descriptor, occurrence, + ) + exact = reference is not None + # Older/aggregated worker rows may not identify the exact expression. + # In that case the containing method declaration remains a useful jump. + if reference is None: + reference = find_member_declaration( + references, "method", method_name, method_descriptor + ) + if reference is None: + reference = find_member_declaration(references, "method", method_name, "") + self.pending_navigation = None + if reference is None: + self.set_status(f"Search location not found: {dalvik_to_dot(dalvik_class)}->{method_name}") + return + self._move_to_reference(tab, reference) + suffix = "" if exact else " (exact expression unavailable)" + self.set_status(f"Opened search hit {dalvik_to_dot(dalvik_class)}->{method_name}{suffix}") + + def _move_to_reference(self, tab, reference): + index = f"1.0+{reference[0]}c" + self.view.text.mark_set("insert", index) + self.view.text.see(index) + tab.insert_index = self.view.text.index("insert") + tab.yview = self.view.text.yview() + self.view.highlight_active_line() + self.view.highlight_related_identifier(tab, tab.insert_index) + + def _set_cursor_from_click(self, event): + try: + self.view.text.focus_set() + index = self.view.text.index(f"@{event.x},{event.y}") + self.view.text.mark_set("insert", index) + self.view.highlight_active_line() + self.view.highlight_related_identifier(self.active_tab(), index) + except tk.TclError: + pass + + def show_member_links(self, _event = None): + return self.view.show_member_links(self.active_tab(), self._available_classes()) + + def _open_member_from_click(self, event): + try: + index = self.view.text.index(f"@{event.x},{event.y}") + except tk.TclError: + return "break" + reference = self.view.member_at_index(self.active_tab(), index) + if reference is None: + return "break" + _start, _end, member_type, class_name, member_name, descriptor, _declaration = reference + store = self._store() + if store is None or class_name not in store.class_to_dex: + self.set_status( + f"Declaration class is not present in this APK: {dalvik_to_dot(class_name)}" + ) + return "break" + self.pending_navigation = (class_name, member_type, member_name, descriptor) + self.open_class(class_name) + self.complete_pending_navigation(class_name) + return "break" + + def _on_key_press(self, event): + if event.state & 0x0004: + return None + if event.keysym in ("x", "X"): + return self.find_current_member_references() + if event.keysym == "n": + return self.show_rename_dialog() + if event.char == ";": + return self.show_line_comment_dialog() + if event.keysym in _SOURCE_NAV_KEYS: + return None + return "break" + + def _on_key_release(self, event): + if event.keysym in _SOURCE_NAV_KEYS: + self.view.highlight_active_line() + self.view.highlight_related_identifier( + self.active_tab(), + self.view.text.index("insert"), + ) + return None + + def find_current_member_references(self, _event = None): + tab = self.active_tab() + if tab is None or tab.kind != "class" or tab.loading or tab.error or not tab.source: + return "break" + try: + reference = self.view.member_at_index(tab, self.view.text.index("insert")) + except tk.TclError: + return "break" + if reference is None or reference[2] not in ("method", "field"): + self.set_status("Place the cursor on a method or field name to find its references") + return "break" + _start, _end, member_type, class_name, member_name, *_metadata = reference + self.start_reference_search(member_type, member_name, class_name) + return "break" + + def show_line_comment_dialog(self, _event = None): + tab = self.active_tab() + if tab is None or tab.kind != "class" or tab.loading or tab.error or not tab.source: + return "break" + line_no = self.view.line_from_index(self.view.text.index("insert")) + if line_no is None: + return "break" + source_line = self._original_line_text(tab, line_no) + if source_line == "": + return "break" + + LineCommentDialog( + self.root, + line_no, + source_line, + tab.comments.get(line_no, ""), + save_comment=lambda comment: self.set_line_comment( + tab, line_no, comment + ), + apply_theme=self.apply_theme_to_window, + ) + return "break" + + def set_line_comment(self, tab, line_no : int, comment : str): + if comment: + tab.comments[line_no] = comment + else: + tab.comments.pop(line_no, None) + # render_source inserts comments without mutating decompiled source and + # remaps member ranges for the displayed text. + self.view.remember(tab) + tab.insert_index = f"{line_no}.0" + self.view.show_tab(tab, self._available_classes()) + self.set_status(f"Updated comment on line {line_no}") + + def show_rename_dialog(self, _event = None): + tab = self.active_tab() + if tab is None or tab.kind != "class" or tab.loading or tab.error or not tab.source: + return "break" + source = tab.edited_source or tab.source + try: + offset = index_to_offset(source, self.view.text.index("insert")) + except tk.TclError: + return "break" + token = token_at_offset(source, offset) + if token is None: + self.set_status("No identifier at cursor") + return "break" + start, _end, old_name = token + method_range = find_method_range(source, start) + if method_range is None: + self.set_status("Rename is only available inside a method body") + return "break" + + def rename(new_name): + if not is_identifier(new_name): + self.set_status("Invalid identifier") + return False + self.rename_identifier(tab, method_range, old_name, new_name) + return True + + RenameDialog( + self.root, + old_name, + rename, + self.apply_theme_to_window, + ) + return "break" + + def rename_identifier(self, tab, method_range, old_name : str, new_name : str): + if old_name == new_name: + return + source = tab.edited_source or tab.source + renamed_spans = identifier_occurrences_in_range( + source, method_range[0], method_range[1], old_name + ) + new_source, count = rename_identifier_in_range( + source, method_range[0], method_range[1], old_name, new_name + ) + if count == 0: + self.set_status(f"No occurrences of {old_name} in current method") + return + tab.edited_source = new_source + # Navigation metadata is based on source offsets. Shift later ranges by + # each identifier length delta before rerendering. + tab.member_references = remap_ranges_after_replacements( + tab.member_references, renamed_spans, len(new_name) + ) + self.view.remember(tab) + self.view.show_tab(tab, self._available_classes()) + self.set_status(f"Renamed {count} occurrence(s) in current method") + + @staticmethod + def _original_line_text(tab, line_no : int): + lines = (tab.edited_source or tab.source).splitlines() + if line_no < 1 or line_no > len(lines): + return "" + return lines[line_no - 1] diff --git a/droidasc/asc_client/gui/editor/source.py b/droidasc/asc_client/gui/editor/source.py new file mode 100644 index 0000000..28f3768 --- /dev/null +++ b/droidasc/asc_client/gui/editor/source.py @@ -0,0 +1,532 @@ +_IDENT_START_EXTRA = "_$" +_IDENT_EXTRA = "_$" +_BLOCK_PREFIXES = ( + "if ", "if(", "for ", "for(", "while ", "while(", "switch ", "switch(", + "catch ", "catch(", "else", "try", "do", +) +_JAVA_KEYWORDS = { + "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", "class", + "const", "continue", "default", "do", "double", "else", "enum", "extends", "final", + "finally", "float", "for", "goto", "if", "implements", "import", "instanceof", "int", + "interface", "long", "native", "new", "package", "private", "protected", "public", + "return", "short", "static", "strictfp", "super", "switch", "synchronized", "this", + "throw", "throws", "transient", "try", "void", "volatile", "while", "true", "false", + "null", +} + + +def _is_ident_start(ch : str): + return ch.isalpha() or ch in _IDENT_START_EXTRA + + +def _is_ident(ch : str): + return ch.isalnum() or ch in _IDENT_EXTRA + + +def _iter_java_code(text : str, start : int = 0, end = None): + # Yield identifiers and structural symbols only. Rename/brace matching must + # never treat text inside comments or literals as executable Java. + if end is None: + end = len(text) + i = start + while i < end: + ch = text[i] + nxt = text[i + 1] if i + 1 < end else "" + if ch == "/" and nxt == "/": + newline = text.find("\n", i + 2, end) + i = end if newline < 0 else newline + 1 + continue + if ch == "/" and nxt == "*": + close = text.find("*/", i + 2, end) + i = end if close < 0 else close + 2 + continue + if ch in "\"'": + quote = ch + i += 1 + while i < end: + if text[i] == "\\": + i += 2 + continue + if text[i] == quote: + i += 1 + break + i += 1 + continue + if _is_ident_start(ch): + token_start = i + i += 1 + while i < end and _is_ident(text[i]): + i += 1 + yield "identifier", token_start, i + continue + yield "symbol", i, i + 1 + i += 1 + + +def java_highlight_spans(text : str): + # Highlighting needs the ranges which _iter_java_code deliberately skips, + # so it uses a separate single-pass scanner with the same escape rules. + spans = { + "java_keyword": [], + "java_comment": [], + "java_string": [], + "java_annotation": [], + } + n = len(text) + i = 0 + while i < n: + ch = text[i] + if ch == "/" and i + 1 < n: + nxt = text[i + 1] + if nxt == "/": + start = i + i += 2 + while i < n and text[i] != "\n": + i += 1 + spans["java_comment"].append((start, i)) + continue + if nxt == "*": + start = i + i += 2 + while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"): + i += 1 + i = min(i + 2, n) + spans["java_comment"].append((start, i)) + continue + if ch in "\"'": + quote = ch + start = i + i += 1 + while i < n: + if text[i] == "\\": + i += 2 + continue + if text[i] == quote: + i += 1 + break + i += 1 + spans["java_string"].append((start, i)) + continue + if ch == "@": + start = i + i += 1 + while i < n and (text[i].isalnum() or text[i] in "._$"): + i += 1 + spans["java_annotation"].append((start, i)) + continue + if ch.isalpha() or ch == "_": + start = i + i += 1 + while i < n and (text[i].isalnum() or text[i] in "_$"): + i += 1 + if text[start:i] in _JAVA_KEYWORDS: + spans["java_keyword"].append((start, i)) + continue + i += 1 + return spans + + +def offset_spans_to_indexes(text : str, spans): + # Tk indexes are line.column strings. Build newline offsets once, then map + # every highlight endpoint with binary search instead of rescanning text. + line_starts = [0] + pos = 0 + while True: + pos = text.find("\n", pos) + if pos < 0: + break + pos += 1 + line_starts.append(pos) + + def to_index(offset : int): + left = 0 + right = len(line_starts) + while left < right: + mid = (left + right) >> 1 + if line_starts[mid] <= offset: + left = mid + 1 + else: + right = mid + line_idx = left - 1 + return f"{line_idx + 1}.{offset - line_starts[line_idx]}" + + return { + tag_name: [(to_index(start), to_index(end)) for start, end in tag_spans] + for tag_name, tag_spans in spans.items() + } + + +def index_to_offset(text : str, index : str): + line_s, col_s = str(index).split(".", 1) + line_no = int(line_s) + col = int(col_s) + if line_no <= 1: + return min(col, len(text)) + off = 0 + cur_line = 1 + while cur_line < line_no and off < len(text): + nl = text.find("\n", off) + if nl < 0: + return len(text) + off = nl + 1 + cur_line += 1 + return min(off + col, len(text)) + + +def token_at_offset(text : str, offset : int): + if not text: + return None + offset = max(0, min(offset, len(text) - 1)) + if not _is_ident(text[offset]): + # Tk places the cursor after the clicked character at token boundaries. + # Accept that trailing edge, but never cross arbitrary punctuation. + if offset > 0 and _is_ident(text[offset - 1]): + offset -= 1 + else: + return None + start = offset + while start > 0 and _is_ident(text[start - 1]): + start -= 1 + end = offset + 1 + while end < len(text) and _is_ident(text[end]): + end += 1 + token = text[start:end] + if not token or not _is_ident_start(token[0]): + return None + return start, end, token + + +def is_identifier(text : str): + if not text or not _is_ident_start(text[0]): + return False + return all(_is_ident(ch) for ch in text[1:]) + + +def find_method_range(text : str, offset : int): + offset = max(0, min(offset, len(text))) + # Decompiled Java commonly puts "{" on the next line. Check the current + # signature first, then walk outward through enclosing braces. + signature_range = _method_range_from_signature_line(text, offset) + if signature_range is not None: + return signature_range + + brace = text.rfind("{", 0, offset + 1) + while brace >= 0: + line_start, prefix = _prefix_before_brace(text, brace) + if _looks_like_method_prefix(prefix): + end = _matching_brace(text, brace) + if end is not None and brace <= offset <= end: + return line_start, end + 1 + brace = text.rfind("{", 0, brace) + return None + + +def _prefix_before_brace(text : str, brace : int): + line_start = text.rfind("\n", 0, brace) + 1 + prefix = text[line_start:brace].strip() + if prefix: + return line_start, prefix + + prev_end = line_start - 1 + if prev_end < 0 or text[prev_end:prev_end + 1] != "\n": + return line_start, prefix + prev_start = text.rfind("\n", 0, prev_end) + 1 + return prev_start, text[prev_start:prev_end].strip() + + +def _method_range_from_signature_line(text : str, offset : int): + line_start = text.rfind("\n", 0, offset) + 1 + line_end_pos = text.find("\n", offset) + if line_end_pos < 0: + line_end_pos = len(text) + current_line = text[line_start:line_end_pos] + if "(" not in current_line or ")" not in current_line: + return None + + search_end = min(len(text), line_end_pos + 1) + open_brace = text.find("{", line_start, search_end) + if open_brace < 0: + # Dadguard formats some declarations as a signature line followed by a + # line containing only "{". Do not accept other intervening text. + next_line_end = text.find("\n", line_end_pos + 1) + if next_line_end < 0: + next_line_end = len(text) + between = text[line_end_pos + 1:next_line_end].strip() + if between != "{": + return None + open_brace = text.find("{", line_end_pos + 1, next_line_end + 1) + + prefix = text[line_start:open_brace].strip() + if not _looks_like_method_prefix(prefix): + return None + end = _matching_brace(text, open_brace) + if end is None: + return None + return line_start, end + 1 + + +def _looks_like_method_prefix(prefix : str): + if not prefix or prefix.startswith(_BLOCK_PREFIXES): + return False + if "(" not in prefix or ")" not in prefix: + return False + if prefix.endswith(("=", ".", ",")): + return False + return True + + +def _matching_brace(text : str, open_pos : int): + depth = 0 + # Reuse the lexical scanner so braces in strings and comments do not close + # the method early. + for kind, start, _end in _iter_java_code(text, open_pos): + if kind != "symbol": + continue + if text[start] == "{": + depth += 1 + elif text[start] == "}": + depth -= 1 + if depth == 0: + return start + return None + + +def _identifier_spans(text : str, start : int, end : int, name : str): + return [ + (token_start, token_end) + for kind, token_start, token_end in _iter_java_code(text, start, end) + if kind == "identifier" and text[token_start:token_end] == name + ] + + +def rename_identifier_in_range( + text : str, start : int, end : int, old : str, new : str, +): + spans = _identifier_spans(text, start, end, old) + if not spans: + return text, 0 + + # Rebuild once from left to right; repeated str.replace would also modify + # comments, literals, larger identifiers, and text outside the method. + out = [text[:start]] + last = start + for token_start, token_end in spans: + out.append(text[last:token_start]) + out.append(new) + last = token_end + out.append(text[last:]) + return "".join(out), len(spans) + + +def remap_ranges_after_replacements(ranges, spans, replacement_length : int): + def remap(point): + shift = 0 + # Spans are sorted. A point shifts only for replacements ending before + # it; points inside the current replaced token keep their local range. + for start, end in spans: + if point < end: + break + shift += replacement_length - (end - start) + return point + shift + + return [ + (remap(item[0]), remap(item[1]), *item[2:]) + for item in ranges + ] + + +def member_reference_at_offset(references, offset : int): + return next( + (item for item in references if item[0] <= offset < item[1]), + None, + ) + + +def find_member_declaration( + references, member_type : str, member_name : str, descriptor : str, +): + declarations = [ + item for item in references + if item[2] == member_type and item[4] == member_name and item[6] + ] + exact = next( + (item for item in declarations if item[5] == descriptor), + None, + ) + if exact is not None: + return exact + if not descriptor and declarations: + return declarations[0] + return None + + +def find_search_reference( + source : str, references, method_name : str, method_descriptor : str, + target_type : str, target_class : str, target_name : str, + target_descriptor : str, occurrence : int = 0, +): + declaration = find_member_declaration( + references, + "method", + method_name, + method_descriptor, + ) + if declaration is None: + declaration = find_member_declaration( + references, + "method", + method_name, + "", + ) + if declaration is None: + return None + method_range = find_method_range(source, declaration[0]) + if method_range is None: + return None + + def matches(item): + # Search rows identify the containing method plus an occurrence number. + # Filter metadata inside that exact method before applying occurrence. + if not (method_range[0] <= item[0] < method_range[1]) or item[6]: + return False + if item[2] != target_type: + return False + if target_type == "string": + return item[4] == target_name + if target_type == "class": + return item[3] == target_class + return ( + item[3] == target_class + and item[4] == target_name + and (not target_descriptor or item[5] == target_descriptor) + ) + + matches_in_method = [item for item in references if matches(item)] + if occurrence < 0 or occurrence >= len(matches_in_method): + return None + return matches_in_method[occurrence] + + +def linkable_member_spans(references, available_classes): + return [ + (item[0], item[1]) + for item in references + if item[3] in available_classes + ] + + +def identifier_occurrences_in_range( + text : str, start : int, end : int, name : str, +): + return _identifier_spans(text, start, end, name) + + +def decode_java_unicode_escapes(text : str): + if "\\u" not in text: + return text + + out = [] + pending_high = None + pending_high_raw = None + i = 0 + n = len(text) + # Java accepts repeated "u" markers and encodes non-BMP characters as two + # UTF-16 escapes. Preserve malformed/replacement escapes verbatim. + while i < n: + ch = text[i] + if ch != "\\" or i + 1 >= n or text[i + 1] != "u": + if pending_high is not None: + out.append(pending_high_raw) + pending_high = None + pending_high_raw = None + out.append(ch) + i += 1 + continue + + escape_start = i + j = i + 1 + while j < n and text[j] == "u": + j += 1 + if j + 4 > n: + if pending_high is not None: + out.append(pending_high_raw) + pending_high = None + pending_high_raw = None + out.append(ch) + i += 1 + continue + + hex_part = text[j:j + 4] + try: + code_unit = int(hex_part, 16) + except ValueError: + if pending_high is not None: + out.append(pending_high_raw) + pending_high = None + pending_high_raw = None + out.append(ch) + i += 1 + continue + + i = j + 4 + raw_escape = text[escape_start:i] + if code_unit == 0xFFFD: + if pending_high is not None: + out.append(pending_high_raw) + pending_high = None + pending_high_raw = None + out.append(raw_escape) + continue + if 0xD800 <= code_unit <= 0xDBFF: + if pending_high is not None: + out.append(pending_high_raw) + pending_high = code_unit + pending_high_raw = raw_escape + continue + + if pending_high is not None: + if 0xDC00 <= code_unit <= 0xDFFF: + code_point = ( + 0x10000 + + ((pending_high - 0xD800) << 10) + + (code_unit - 0xDC00) + ) + out.append(chr(code_point)) + pending_high = None + pending_high_raw = None + continue + out.append(pending_high_raw) + pending_high = None + pending_high_raw = None + + if 0xDC00 <= code_unit <= 0xDFFF: + out.append(raw_escape) + continue + out.append(chr(code_unit)) + + if pending_high is not None: + out.append(pending_high_raw) + return "".join(out) + + +def decode_java_unicode_escapes_with_ranges(text : str, ranges): + if not ranges or "\\u" not in text: + return decode_java_unicode_escapes(text), list(ranges) + + # Decode only prefixes ending at metadata boundaries to build an old->new + # offset map without allocating a mapping entry for every source character. + points = sorted({point for item in ranges for point in item[:2]}) + mapped = {} + source_pos = 0 + decoded_pos = 0 + for point in points: + decoded_pos += len(decode_java_unicode_escapes(text[source_pos:point])) + mapped[point] = decoded_pos + source_pos = point + + decoded = decode_java_unicode_escapes(text) + adjusted = [ + (mapped[item[0]], mapped[item[1]], *item[2:]) + for item in ranges + ] + return decoded, adjusted diff --git a/droidasc/asc_client/gui/editor/view.py b/droidasc/asc_client/gui/editor/view.py new file mode 100644 index 0000000..0d6fb6f --- /dev/null +++ b/droidasc/asc_client/gui/editor/view.py @@ -0,0 +1,512 @@ +import threading +import tkinter as tk +from tkinter import ttk + +from droidasc.asc_client.gui.backend import dalvik_to_dot +from droidasc.asc_client.gui.editor.source import ( + find_method_range, + identifier_occurrences_in_range, + index_to_offset, + java_highlight_spans, + linkable_member_spans, + member_reference_at_offset, + offset_spans_to_indexes, + token_at_offset, +) + + +_COMMENT_GAP = " " +_SOURCE_TAGS = ( + "java_keyword", "java_comment", "java_string", "java_annotation", "manifest_string", +) +_TRANSIENT_TAGS = ( + "user_comment", "active_line", "symbol_match", "symbol_current", + "find_match", "find_current", "member_link", +) + + +class EditorView(ttk.Frame): + def __init__(self, master, root, emit_event, theme_manager): + super().__init__(master) + self.root = root + self.emit_event = emit_event + self.theme_manager = theme_manager + self.find_var = tk.StringVar() + self.find_status_var = tk.StringVar(value="") + self._last_find_text = "" + self._highlight_generation = 0 + self._highlight_apply_batch = 400 + self._highlight_apply_job = None + self.member_links_visible = False + + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=1) + self._build_find_bar() + + self.text = tk.Text(self, wrap="none") + self.text.grid(row=1, column=0, sticky="nsew") + ybar = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self.text.yview) + ybar.grid(row=1, column=1, sticky="ns") + xbar = ttk.Scrollbar(self, orient=tk.HORIZONTAL, command=self.text.xview) + xbar.grid(row=2, column=0, sticky="ew") + self.text.config(yscrollcommand=ybar.set, xscrollcommand=xbar.set) + self.find_frame.grid_remove() + + def _build_find_bar(self): + self.find_frame = ttk.Frame(self) + self.find_frame.grid(row=0, column=0, sticky="ew", pady=(6, 6)) + self.find_frame.columnconfigure(1, weight=1) + ttk.Label(self.find_frame, text="Find").grid(row=0, column=0, sticky="w") + self.find_entry = ttk.Entry(self.find_frame, textvariable=self.find_var) + self.find_entry.grid(row=0, column=1, sticky="ew", padx=(8, 8)) + self.find_entry.bind("", self._on_find_return) + self.find_entry.bind("", self._on_find_return) + self.find_entry.bind("", self.find_prev) + self.find_entry.bind("", self.find_prev) + self.find_entry.bind("", self.hide_find) + self.find_entry.bind("", self._on_find_changed) + ttk.Button(self.find_frame, text="Next", command=self.find_next).grid( + row=0, column=2, sticky="e" + ) + ttk.Button(self.find_frame, text="Prev", command=self.find_prev).grid( + row=0, column=3, sticky="e", padx=(6, 0) + ) + ttk.Button(self.find_frame, text="Close", command=self.hide_find).grid( + row=0, column=4, sticky="e", padx=(6, 0) + ) + ttk.Label(self.find_frame, textvariable=self.find_status_var).grid( + row=0, column=5, sticky="e", padx=(12, 0) + ) + + def configure_tags(self, theme): + self.theme_manager.apply_text(self.text) + self.text.tag_configure("java_keyword", foreground=theme["java_keyword"]) + self.text.tag_configure("java_comment", foreground=theme["java_comment"]) + self.text.tag_configure("java_string", foreground=theme["java_string"]) + self.text.tag_configure("java_annotation", foreground=theme["java_annotation"]) + self.text.tag_configure("manifest_string", foreground=theme["java_string"]) + self.text.tag_configure( + "user_comment", + foreground=theme["java_comment"], + font=("Consolas", 10, "italic"), + ) + self.text.tag_configure("active_line", background=theme["active_line"]) + self.text.tag_configure("symbol_match", background=theme["symbol_match"]) + self.text.tag_configure("symbol_current", background=theme["symbol_current"]) + self.text.tag_configure( + "find_match", + background=theme["find_match"], + foreground=theme["editor_fg"], + ) + self.text.tag_configure( + "find_current", + background=theme["find_current"], + foreground=theme["editor_fg"], + ) + self.text.tag_configure( + "member_link", + foreground=theme["accent"], + underline=True, + ) + + def bind_find_shortcuts(self): + self.root.bind("", self.show_find) + self.root.bind("", self.show_find) + self.text.bind("", self.hide_find) + + def remember(self, tab): + if tab is None: + return + try: + tab.yview = self.text.yview() + tab.xview = self.text.xview() + tab.insert_index = self.text.index("insert") + except tk.TclError: + pass + + def set_text(self, text : str, reset_view : bool = True): + self.text.config(state=tk.NORMAL) + self.text.delete("1.0", tk.END) + self.text.insert("1.0", text) + if reset_view: + self.text.mark_set("insert", "1.0") + self.text.xview_moveto(0.0) + self.text.yview_moveto(0.0) + + def render_source(self, tab): + source = tab.edited_source or tab.source + if not tab.comments: + tab.rendered_member_references = list(tab.member_references) + return source, [] + + out = [] + comment_spans = [] + insertions = [] + offset = 0 + source_offset = 0 + lines = source.splitlines(keepends=True) + # User comments are presentation-only line suffixes. Record each + # insertion so source metadata can be translated to rendered offsets. + for line_no, line in enumerate(lines, 1): + newline = "" + body = line + if line.endswith("\r\n"): + body = line[:-2] + newline = "\r\n" + elif line.endswith("\n"): + body = line[:-1] + newline = "\n" + + comment = tab.comments.get(line_no) + if not comment: + rendered = line + else: + comment_text = f"{_COMMENT_GAP}// {comment}" + insertions.append((source_offset + len(body), len(comment_text))) + start = offset + len(body) + len(_COMMENT_GAP) + rendered = f"{body}{comment_text}{newline}" + comment_spans.append((start, start + len(comment_text) - len(_COMMENT_GAP))) + out.append(rendered) + offset += len(rendered) + source_offset += len(line) + + if not lines and tab.comments: + rendered = f"// {tab.comments.get(1, '')}" + return rendered, [(0, len(rendered))] + + def shift_start(point): + # A reference starting exactly at an insertion moves after it. + return point + sum(length for position, length in insertions if position <= point) + + def shift_end(point): + # A reference ending exactly at an insertion remains before it. + return point + sum(length for position, length in insertions if position < point) + + tab.rendered_member_references = [ + (shift_start(item[0]), shift_end(item[1]), *item[2:]) + for item in tab.member_references + ] + return "".join(out), comment_spans + + def show_empty(self): + # Incrementing invalidates highlight results still queued by old tabs. + self._highlight_generation += 1 + self._clear_tags() + self.set_text("Open a class from the package tree or search results.", reset_view=True) + self.find_status_var.set("") + + def show_tab(self, tab, available_classes=()): + self._highlight_generation += 1 + self._clear_tags() + if tab.loading: + self.set_text(f"Decompiling {dalvik_to_dot(tab.dalvik_class)}...", reset_view=True) + return + if tab.error: + self.set_text(tab.error, reset_view=True) + return + + rendered, comment_spans = self.render_source(tab) + self.set_text(rendered, reset_view=False) + try: + self.text.mark_set("insert", tab.insert_index) + self.text.xview_moveto(tab.xview[0]) + self.text.yview_moveto(tab.yview[0]) + except tk.TclError: + self.text.mark_set("insert", "1.0") + for start, end in comment_spans: + self.tag_range("user_comment", start, end) + if tab.kind == "class": + self._start_async_highlight(rendered) + else: + self._highlight_quoted_strings(rendered) + self.refresh_find_marks(reset_cursor=False) + self.highlight_active_line() + if self.member_links_visible: + self.member_links_visible = False + self.show_member_links(tab, available_classes) + + def _clear_tags(self): + for tag_name in _SOURCE_TAGS + _TRANSIENT_TAGS: + self.text.tag_remove(tag_name, "1.0", tk.END) + + @staticmethod + def line_from_index(index : str): + try: + return int(str(index).split(".", 1)[0]) + except (TypeError, ValueError): + return None + + def highlight_active_line(self): + self.text.tag_remove("active_line", "1.0", tk.END) + try: + line_no = self.line_from_index(self.text.index("insert")) + except tk.TclError: + line_no = None + if line_no is None: + return + self.text.tag_add("active_line", f"{line_no}.0", f"{line_no}.end+1c") + # Syntax and selection colors must remain visible over the line tint. + self.text.tag_lower("active_line") + + def highlight_related_identifier(self, tab, index : str): + self.text.tag_remove("symbol_match", "1.0", tk.END) + self.text.tag_remove("symbol_current", "1.0", tk.END) + if tab is None or tab.kind != "class" or tab.loading or tab.error: + return + source = tab.edited_source or tab.source + if tab.comments: + source, _comment_spans = self.render_source(tab) + if not source: + return + token = token_at_offset(source, index_to_offset(source, index)) + if token is None: + return + start, end, name = token + method_range = find_method_range(source, start) or (0, len(source)) + spans = identifier_occurrences_in_range(source, method_range[0], method_range[1], name) + if len(spans) <= 1: + return + for span_start, span_end in spans: + tag = "symbol_current" if (span_start, span_end) == (start, end) else "symbol_match" + self.tag_range(tag, span_start, span_end) + self.text.tag_raise("symbol_match") + self.text.tag_raise("symbol_current") + + def show_member_links(self, tab, available_classes): + if self.member_links_visible: + return None + self.member_links_visible = True + self.text.tag_remove("member_link", "1.0", tk.END) + if tab is not None and tab.kind == "class" and not tab.loading and not tab.error: + # External framework members have no declaration tab in this APK, + # so underline only references whose owner class is indexed. + self.tag_ranges( + "member_link", + linkable_member_spans(tab.rendered_member_references, available_classes), + ) + self.text.tag_raise("member_link") + return None + + def hide_member_links(self, _event = None): + self.member_links_visible = False + self.text.tag_remove("member_link", "1.0", tk.END) + return None + + def member_at_index(self, tab, index : str): + if tab is None or tab.kind != "class" or tab.loading or tab.error: + return None + rendered = tab.edited_source or tab.source + references = tab.rendered_member_references or tab.member_references + if tab.comments: + rendered, _comment_spans = self.render_source(tab) + references = tab.rendered_member_references + return member_reference_at_offset(references, index_to_offset(rendered, index)) + + def tag_range(self, tag_name : str, start : int, end : int): + if end > start: + self.text.tag_add(tag_name, f"1.0+{start}c", f"1.0+{end}c") + + def tag_ranges(self, tag_name : str, spans, batch_size : int = 512): + # Tk accepts many start/end pairs in one call; batching avoids thousands + # of interpreter crossings without creating an oversized Tcl command. + for offset in range(0, len(spans), batch_size): + args = [] + for start, end in spans[offset:offset + batch_size]: + if end > start: + args.extend((f"1.0+{start}c", f"1.0+{end}c")) + if args: + self.text.tag_add(tag_name, *args) + + def _tag_index_ranges(self, tag_name : str, spans, batch_size : int = 512): + for offset in range(0, len(spans), batch_size): + args = [] + for start, end in spans[offset:offset + batch_size]: + args.extend((start, end)) + if args: + self.text.tag_add(tag_name, *args) + + def _highlight_quoted_strings(self, text : str): + spans = [] + start = None + escape = False + line = 1 + col = 0 + for ch in text: + if start is None: + if ch == '"': + start = f"{line}.{col}" + escape = False + elif escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + spans.append((start, f"{line}.{col + 1}")) + start = None + if ch == "\n": + line += 1 + col = 0 + else: + col += 1 + self._tag_index_ranges("manifest_string", spans) + + def _start_async_highlight(self, text : str): + self._highlight_generation += 1 + generation = self._highlight_generation + if self._highlight_apply_job is not None: + try: + self.root.after_cancel(self._highlight_apply_job) + except tk.TclError: + pass + self._highlight_apply_job = None + for tag_name in _SOURCE_TAGS: + self.text.tag_remove(tag_name, "1.0", tk.END) + + def worker(): + # The worker computes plain strings only. Tk tags are applied later + # by the main-thread event drain. + spans = java_highlight_spans(text) + self.emit_event(( + "highlight_ready", + generation, + offset_spans_to_indexes(text, spans), + )) + + threading.Thread(target=worker, daemon=True).start() + + def apply_highlight_spans(self, generation : int, spans): + # A tab switch may happen while scanning. Never paint an old result into + # the newly displayed document. + if generation != self._highlight_generation: + return + work_items = [] + for tag_name in ("java_comment", "java_string", "java_annotation", "java_keyword"): + for start, end in spans[tag_name]: + work_items.append((tag_name, start, end)) + if not work_items: + return + + def apply_chunk(offset : int = 0): + if generation != self._highlight_generation: + self._highlight_apply_job = None + return + end_offset = min(offset + self._highlight_apply_batch, len(work_items)) + for idx in range(offset, end_offset): + tag_name, start, end = work_items[idx] + self.text.tag_add(tag_name, start, end) + if end_offset < len(work_items): + # Yield between chunks so scrolling and tab switches remain + # responsive on large decompiled classes. + self._highlight_apply_job = self.root.after(1, apply_chunk, end_offset) + else: + self.text.tag_raise("user_comment") + self.text.tag_lower("active_line") + self._highlight_apply_job = None + + apply_chunk() + + def show_find(self, _event = None): + self.hide_member_links() + self.find_frame.grid() + self.find_entry.focus_set() + self.find_entry.selection_range(0, tk.END) + self._last_find_text = self.find_var.get() + self.refresh_find_marks(reset_cursor=True) + return "break" + + def hide_find(self, _event = None): + self.find_frame.grid_remove() + self.text.tag_remove("find_match", "1.0", tk.END) + self.text.tag_remove("find_current", "1.0", tk.END) + self.find_status_var.set("") + self.text.focus_set() + return "break" + + def refresh_find_marks(self, reset_cursor : bool = False): + needle = self.find_var.get() + self.text.tag_remove("find_match", "1.0", tk.END) + self.text.tag_remove("find_current", "1.0", tk.END) + if reset_cursor: + self.text.mark_set("insert", "1.0") + if not needle: + self.find_status_var.set("") + return 0 + + count = tk.IntVar() + pos = "1.0" + hits = 0 + while True: + idx = self.text.search(needle, pos, stopindex=tk.END, nocase=True, count=count) + if not idx or count.get() <= 0: + break + end = f"{idx}+{count.get()}c" + self.text.tag_add("find_match", idx, end) + hits += 1 + pos = end + self.find_status_var.set(f"{hits} matches" if hits else "No match") + return hits + + def _find_step(self, backwards : bool): + needle = self.find_var.get() + if not needle: + self.find_status_var.set("Empty query") + return "break" + + current_ranges = self.text.tag_ranges("find_current") + current_start = str(current_ranges[0]) if current_ranges else None + current_end = str(current_ranges[1]) if current_ranges else None + self.refresh_find_marks(reset_cursor=False) + count = tk.IntVar() + insert_idx = self.text.index("insert") + if backwards: + start_idx = current_start or insert_idx + idx = self.text.search( + needle, start_idx, stopindex="1.0", backwards=True, nocase=True, count=count + ) + if not idx: + # No previous match: wrap from the end of the document. + idx = self.text.search( + needle, tk.END, stopindex="1.0", backwards=True, nocase=True, count=count + ) + else: + start_idx = current_end or insert_idx + idx = self.text.search(needle, start_idx, stopindex=tk.END, nocase=True, count=count) + if not idx: + # No following match: wrap from the beginning. + idx = self.text.search( + needle, "1.0", stopindex=tk.END, nocase=True, count=count + ) + if not idx or count.get() <= 0: + self.find_status_var.set("No match") + return "break" + + end = f"{idx}+{count.get()}c" + self.text.tag_remove("find_current", "1.0", tk.END) + self.text.tag_add("find_current", idx, end) + self.text.mark_set("insert", idx if backwards else end) + self.text.see(idx) + self.find_status_var.set(f"Match at {idx}") + return "break" + + def find_next(self, _event = None): + return self._find_step(False) + + def find_prev(self, _event = None): + return self._find_step(True) + + def _on_find_return(self, event = None): + if event is not None and (event.state & 0x0001): + return self.find_prev() + return self.find_next() + + def _on_find_changed(self, event = None): + if event is not None and event.keysym in ( + "Return", "KP_Enter", "Shift_L", "Shift_R", + "Control_L", "Control_R", "Alt_L", "Alt_R", + ): + return "break" + current = self.find_var.get() + if current == self._last_find_text: + return + self._last_find_text = current + self.refresh_find_marks(reset_cursor=True) diff --git a/droidasc/asc_client/gui/runtime.py b/droidasc/asc_client/gui/runtime.py deleted file mode 100644 index f0262c3..0000000 --- a/droidasc/asc_client/gui/runtime.py +++ /dev/null @@ -1,605 +0,0 @@ -import json -import os -import subprocess -import sys -import tempfile -import threading -import time -import traceback -from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed - -from droidasc.asc_client.apk_handler import _findrefs_worker, _inflate_dex, _parse_cd_dex_entries -from droidasc.asc_client.dex_container import iter_logical_dex_buffers -from droidasc.asc_core.findrefs.findrefs_manager import FindRefManager -from droidasc.asc_core.utils.tinydex import DEX - - -_MAX_UI_RESULTS = 5000 -_GUI_MP_LOCK = threading.Lock() -_REF_SEARCH_TYPES = { - "string refs": "string", - "type refs": "type", - "method refs": "method", - "field refs": "field", -} - - -def _debug_log(enabled : bool, scope : str, msg : str): - if not enabled: - return - tid = threading.get_ident() & 0xFFFF - now = time.perf_counter() - print(f"[GUI DEBUG] [{scope}] [T{tid:04x}] {now:.6f} {msg}") - - -def _multiprocessing_state() -> str: - mp_mod = sys.modules.get("multiprocessing") - red_mod = sys.modules.get("multiprocessing.reduction") - return ( - f"multiprocessing={type(mp_mod).__name__}" - f" reduction={type(red_mod).__name__}" - ) - - -def _snapshot_sys_modules(): - return dict(sys.modules) - - -def _restore_sys_modules(snapshot): - for name in tuple(sys.modules.keys()): - if name not in snapshot: - sys.modules.pop(name, None) - sys.modules.update(snapshot) - - -def format_class_name(name : str) -> str: - if not name: - raise ValueError("Class name cannot be empty") - if name.startswith("L") and name.endswith(";") and "/" in name: - return name - name = name.replace(".", "/") - if not name.startswith("L"): - name = f"L{name}" - if not name.endswith(";"): - name = f"{name};" - return name - - -def dalvik_to_dot(name : str) -> str: - if name.startswith("L") and name.endswith(";"): - return name[1:-1].replace("/", ".") - return name - - -def _normalize_class_query(name : str, fuzzy : bool): - if name is None or name == "": - return None - if fuzzy: - if "." in name and "/" not in name: - return name.replace(".", "/") - return name - return format_class_name(name) - - -def build_find_query( - find_type : str, value : str, class_name = None, fuzzy_class : bool = False, - exact_member : bool = False, -): - find_type = _REF_SEARCH_TYPES.get(find_type, find_type) - if find_type == "string": - return "string", {"string": value} - if find_type == "type": - return "type", {"type": value} - - class_name = _normalize_class_query(class_name, fuzzy_class) - if class_name is None and not value: - raise ValueError(f"{find_type} query needs at least one of class or {find_type} name") - - member = [value, True] if exact_member and value else value or None - if class_name is None: - return find_type, {find_type: {"class": None, find_type: member}} - return find_type, {find_type: {"class": [class_name, not fuzzy_class], find_type: member}} - - -def _split_member_text(text : str, member_type : str): - class_name, member_text = text.split("->", 1) - if member_type == "method": - split_at = member_text.find("(") - else: - split_at = member_text.rfind(":") - if split_at < 0: - return class_name, member_text, "" - if member_type == "method": - return class_name, member_text[:split_at], member_text[split_at:] - return class_name, member_text[:split_at], member_text[split_at + 1:] - - -def parse_result_line(line : str, find_type : str = ""): - dex_name, method_text, matched_text = line.split(" | ", 2) - matched_text, sep, occurrence_text = matched_text.rpartition(" | occurrence=") - occurrence = int(occurrence_text) if sep and occurrence_text.isdigit() else 0 - if not sep: - matched_text = occurrence_text - class_name, method_name, method_descriptor = _split_member_text(method_text, "method") - matched_value = matched_text[len("matched=("):-1] if matched_text.startswith("matched=(") else matched_text - target_class = "" - target_name = matched_value - target_descriptor = "" - if find_type in ("method", "field"): - target_class, target_name, target_descriptor = _split_member_text(matched_value, find_type) - elif find_type == "type": - target_class = matched_value - return { - "dex_name": dex_name, - "class_name": class_name, - "class_display": dalvik_to_dot(class_name), - "method_text": method_text, - "method_name": method_name, - "method_descriptor": method_descriptor, - "matched_text": matched_text, - "target_type": "class" if find_type == "type" else find_type, - "target_class": target_class, - "target_name": target_name, - "target_descriptor": target_descriptor, - "occurrence": occurrence, - "line": line, - } - - -def is_member_search(find_type : str) -> bool: - return find_type in ("method", "field") - - -class GuiDexStore: - def __init__(self, apk_path : str, max_workers : int = 20, debug : bool = False, search_executor = None): - self.apk_path = apk_path - self.max_workers = max_workers - self.debug = debug - self.search_executor = search_executor - - self.entries = [] - self.dex_buffers = {} - self.class_to_dex = {} - self.class_names = [] - self.package_children = {} - self.package_classes = {} - self.findref_managers = {} - self.source_cache = {} - self._source_lock = threading.Lock() - - def _open_apk(self): - import mmap - - fp = open(self.apk_path, "rb") - mm = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ) - return fp, mm - - def load(self, progress_callback = None): - fp, mm = self._open_apk() - try: - entries = _parse_cd_dex_entries(mm) - entries.sort(key=lambda x: x[2]) - self.entries = entries - total = len(entries) - _debug_log(self.debug, "runtime", f"load start apk={self.apk_path} dex={total}") - if total == 0: - self.class_names = [] - return - - def load_entry(entry): - data = _inflate_dex(mm, entry) - logical = [] - for dex_name, dex_buf in iter_logical_dex_buffers(entry[0], data): - dex = DEX.parse(memoryview(dex_buf), dex_name) - classes = [] - for class_idx in range(len(dex.classes)): - classes.append(dex.classes[class_idx].fullname) - logical.append((dex_name, dex_buf, dex, classes)) - return entry, logical - - done = 0 - class_count = 0 - with ThreadPoolExecutor(max_workers=self.max_workers) as ex: - futures = [ex.submit(load_entry, entry) for entry in entries] - for fut in as_completed(futures): - entry, logical = fut.result() - for dex_name, dex_buf, dex, classes in logical: - self.dex_buffers[dex_name] = dex_buf - self.findref_managers[dex_name] = FindRefManager(dex, self.debug) - for class_name in classes: - self.class_to_dex.setdefault(class_name, dex_name) - class_count += len(classes) - done += 1 - if progress_callback is not None: - progress_callback(done, total, entry[0], class_count) - - self.class_names = sorted(self.class_to_dex.keys()) - self._build_package_index() - _debug_log( - self.debug, - "runtime", - f"load done dex={len(self.entries)} classes={len(self.class_names)}", - ) - finally: - mm.close() - fp.close() - - def _build_package_index(self): - package_children = {"": set()} - package_classes = {} - - for class_name in self.class_names: - body = class_name[1:-1] if class_name.startswith("L") and class_name.endswith(";") else class_name - parts = body.split("/") - pkg_path = "" - - for part in parts[:-1]: - next_path = f"{pkg_path}/{part}" if pkg_path else part - package_children.setdefault(pkg_path, set()).add(next_path) - package_children.setdefault(next_path, set()) - pkg_path = next_path - - package_classes.setdefault(pkg_path, []).append(class_name) - - self.package_children = { - pkg: sorted(children, key=lambda item: item.lower()) - for pkg, children in package_children.items() - } - self.package_classes = {} - for pkg_path, classes in package_classes.items(): - classes.sort(key=lambda item: item.split("/")[-1].rstrip(";").lower()) - self.package_classes[pkg_path] = classes - - def iter_root_packages(self): - return self.package_children.get("", []) - - def iter_child_packages(self, pkg_path : str): - return self.package_children.get(pkg_path, []) - - def iter_package_classes(self, pkg_path : str): - return self.package_classes.get(pkg_path, []) - - def iter_filtered_classes(self, keyword : str, limit : int): - keyword = (keyword or "").strip().lower() - if not keyword: - return [] - - ret = [] - for class_name in self.class_names: - if keyword not in dalvik_to_dot(class_name).lower(): - continue - ret.append(class_name) - if len(ret) >= limit: - break - return ret - - def get_source(self, dalvik_class : str): - dex_name, source, _references = self.get_source_with_metadata(dalvik_class) - return dex_name, source - - def get_source_with_metadata(self, dalvik_class : str): - with self._source_lock: - old = self.source_cache.get(dalvik_class) - if old is not None: - _debug_log(self.debug, "runtime", f"source cache hit class={dalvik_class}") - return old - - dex_name = self.class_to_dex.get(dalvik_class) - if dex_name is None: - raise ValueError(f"class not indexed: {dalvik_class}") - - dex_buf = self.dex_buffers[dex_name] - _debug_log( - self.debug, - "runtime", - f"source start class={dalvik_class} dex={dex_name} {_multiprocessing_state()}", - ) - from droidasc.asc_client.asc_handler import AscHandler - - with _GUI_MP_LOCK: - snapshot = _snapshot_sys_modules() - try: - source, references = AscHandler(self.debug).getclass_with_metadata(dex_buf, dalvik_class) - finally: - _restore_sys_modules(snapshot) - ret = (dex_name, source, references) - with self._source_lock: - self.source_cache[dalvik_class] = ret - _debug_log( - self.debug, - "runtime", - f"source done class={dalvik_class} dex={dex_name} {_multiprocessing_state()}", - ) - return ret - - def search_members(self, find_type : str, value : str, limit : int = _MAX_UI_RESULTS): - keyword = (value or "").strip() - dex_names = [entry[0] for entry in self.entries] - - def search_dex(dex_name): - manager = self.findref_managers.get(dex_name) - if manager is None: - return 0, [] - dex = manager.dex - dex_rows = [] - if find_type == "method": - locator = manager._get_method_locator(True) - member_idxs = locator.locate({"class": None, "method": keyword}) - for midx in sorted(member_idxs): - method = dex.methods[midx] - class_name = method.cls.fullname - proto = method.prototype - descriptor = "(" + "".join(item.descriptor for item in proto.parameters_type) + ")" - descriptor += dex.get_type(proto.return_type_idx).descriptor - method_text = f"{class_name}->{method.name}{descriptor}" - dex_rows.append({ - "dex_name": dex_name, - "class_name": class_name, - "class_display": dalvik_to_dot(class_name), - "method_text": method_text, - "method_name": method.name, - "method_descriptor": descriptor, - "matched_text": f"method=({method.name})", - "target_type": "method", - "target_class": class_name, - "target_name": method.name, - "target_descriptor": descriptor, - "occurrence": 0, - "line": method_text, - }) - return len(member_idxs), dex_rows - - locator = manager._get_field_locator(True) - member_idxs = locator.locate({"class": None, "field": keyword}) - for fidx in sorted(member_idxs): - field = dex.fields[fidx] - class_name = field.cls.fullname - method_text = f"{class_name}->{field.name}" - dex_rows.append({ - "dex_name": dex_name, - "class_name": class_name, - "class_display": dalvik_to_dot(class_name), - "method_text": method_text, - "method_name": field.name, - "method_descriptor": "", - "matched_text": f"field=({field.name})", - "target_type": "field", - "target_class": class_name, - "target_name": field.name, - "target_descriptor": field.type.descriptor, - "occurrence": 0, - "line": method_text, - }) - return len(member_idxs), dex_rows - - rows = [] - total_hits = 0 - workers = self.get_effective_search_workers() - if len(dex_names) <= 1 or workers <= 1: - results = [search_dex(dex_name) for dex_name in dex_names] - else: - with ThreadPoolExecutor(max_workers=workers) as ex: - futures = [ex.submit(search_dex, dex_name) for dex_name in dex_names] - results = [fut.result() for fut in futures] - - for hit_count, dex_rows in results: - total_hits += hit_count - if len(rows) < limit: - rows.extend(dex_rows[:limit - len(rows)]) - - return { - "find_type": find_type, - "query_value": value, - "results": rows, - "total_hits": total_hits, - "workers": workers, - "backend": "locator", - } - - def get_effective_search_workers(self, requested = None): - cpu = os.cpu_count() or 4 - workers = requested if requested is not None else self.max_workers - if workers is None or workers <= 0: - workers = cpu - if len(self.entries) > 1: - workers = max(2, workers) - return min(len(self.entries), workers) - - def _search_with_process_pool(self, executor, workers, find_type, find, progress_callback, result_callback = None): - total = len(self.entries) - hit_count = 0 - shown = [] - with _GUI_MP_LOCK: - _debug_log( - self.debug, - "runtime", - f"search process start dex={total} workers={workers} {_multiprocessing_state()} executor={id(executor)}", - ) - - futures = {} - for entry in self.entries: - try: - _debug_log(self.debug, "runtime", f"submit dex={entry[0]}") - fut = executor.submit(_findrefs_worker, self.apk_path, entry, find_type, find, False) - except Exception as e: - _debug_log( - self.debug, - "runtime", - f"submit failed dex={entry[0]} err={type(e).__name__}: {e} {_multiprocessing_state()}", - ) - traceback.print_exc() - raise - futures[fut] = entry[0] - - done = 0 - for fut in as_completed(futures): - dex_name = futures.pop(fut) - try: - dex_name, lines, _inflate_us, _process_us, _pid = fut.result() - except Exception as e: - _debug_log( - self.debug, - "runtime", - f"result failed dex={dex_name} err={type(e).__name__}: {e} {_multiprocessing_state()}", - ) - traceback.print_exc() - raise - done += 1 - hit_count += len(lines) - _debug_log( - self.debug, - "runtime", - f"result ok dex={dex_name} pid={_pid} hits={len(lines)} done={done}/{total}", - ) - batch_rows = [] - if len(shown) < _MAX_UI_RESULTS: - remain = _MAX_UI_RESULTS - len(shown) - for line in lines[:remain]: - row = parse_result_line(line, find_type) - shown.append(row) - batch_rows.append(row) - if batch_rows and result_callback is not None: - result_callback(batch_rows, done, total, hit_count) - if progress_callback is not None: - progress_callback(done, total, hit_count) - - return hit_count, shown - - def _launch_subprocess_worker(self, entry, find_type, find): - result_file = tempfile.NamedTemporaryFile(prefix="asc_gui_find_", suffix=".json", delete=False) - result_path = result_file.name - result_file.close() - - payload = { - "apk_path": self.apk_path, - "entry": list(entry), - "find_type": find_type, - "find": find, - "result_path": result_path, - } - - cmd = [sys.executable, "-m", "droidasc.asc_client.gui.search_worker"] - creationflags = 0 - if os.name == "nt": - creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) - proc = subprocess.Popen( - cmd, - cwd=os.getcwd(), - stdin=subprocess.PIPE, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - text=True, - creationflags=creationflags, - ) - proc.stdin.write(json.dumps(payload)) - proc.stdin.close() - return proc, result_path - - def _search_with_subprocess_pool(self, workers, find_type, find, progress_callback, result_callback = None): - total = len(self.entries) - hit_count = 0 - shown = [] - done = 0 - idx = 0 - running = [] - - while idx < len(self.entries) or running: - while idx < len(self.entries) and len(running) < workers: - entry = self.entries[idx] - idx += 1 - proc, result_path = self._launch_subprocess_worker(entry, find_type, find) - running.append((proc, result_path, entry[0])) - - if not running: - break - - # time.sleep(0.01) - next_running = [] - for proc, result_path, dex_name in running: - if proc.poll() is None: - next_running.append((proc, result_path, dex_name)) - continue - - stderr_text = proc.stderr.read() if proc.stderr is not None else "" - if proc.returncode != 0: - try: - os.unlink(result_path) - except OSError: - pass - raise RuntimeError( - f"Search worker failed for {dex_name}: {stderr_text.strip() or proc.returncode}" - ) - - with open(result_path, "r", encoding="utf-8") as fp: - payload = json.load(fp) - try: - os.unlink(result_path) - except OSError: - pass - - lines = payload["lines"] - done += 1 - hit_count += len(lines) - batch_rows = [] - if len(shown) < _MAX_UI_RESULTS: - remain = _MAX_UI_RESULTS - len(shown) - for line in lines[:remain]: - row = parse_result_line(line, find_type) - shown.append(row) - batch_rows.append(row) - if batch_rows and result_callback is not None: - result_callback(batch_rows, done, total, hit_count) - if progress_callback is not None: - progress_callback(done, total, hit_count) - - running = next_running - - return hit_count, shown - - def search( - self, - find_type : str, - value : str, - class_name = None, - fuzzy_class : bool = False, - exact_member : bool = False, - max_workers = None, - progress_callback = None, - result_callback = None, - ): - find_type, find = build_find_query(find_type, value, class_name, fuzzy_class, exact_member) - workers = self.get_effective_search_workers(max_workers) - _debug_log( - self.debug, - "runtime", - f"search request type={find_type} value={value!r} class={class_name!r} fuzzy={fuzzy_class} workers={workers}", - ) - if self.search_executor is not None: - backend = "process" - hit_count, shown = self._search_with_process_pool( - self.search_executor, - workers, - find_type, - find, - progress_callback, - result_callback, - ) - else: - backend = "subprocess" - hit_count, shown = self._search_with_subprocess_pool( - workers, - find_type, - find, - progress_callback, - result_callback, - ) - - return { - "find_type": find_type, - "query_value": value, - "results": shown, - "total_hits": hit_count, - # "truncated": hit_count > len(shown), - "workers": workers, - "backend": backend, - } diff --git a/droidasc/asc_client/gui/settings.py b/droidasc/asc_client/gui/settings.py deleted file mode 100644 index 3978c08..0000000 --- a/droidasc/asc_client/gui/settings.py +++ /dev/null @@ -1,39 +0,0 @@ -import tkinter as tk -from tkinter import ttk - - -class SettingsDialog: - def __init__(self, app): - self.app = app - self.win = tk.Toplevel(app.root) - self.win.title("Settings") - self.win.transient(app.root) - self.win.resizable(False, False) - self.win.geometry("+%d+%d" % (app.root.winfo_rootx() + 96, app.root.winfo_rooty() + 96)) - self.theme_var = tk.StringVar(value=app.theme_name_var.get()) - - self._build_ui() - self.app.apply_theme_to_window(self.win) - - def _build_ui(self): - body = ttk.Frame(self.win, padding=12) - body.grid(row=0, column=0, sticky="nsew") - body.columnconfigure(1, weight=1) - - ttk.Label(body, text="Theme").grid(row=0, column=0, sticky="w") - combo = ttk.Combobox( - body, - textvariable=self.theme_var, - values=self.app.theme_manager.names(), - state="readonly", - width=18, - ) - combo.grid(row=0, column=1, sticky="ew", padx=(10, 0)) - combo.bind("<>", self._on_theme_changed) - - actions = ttk.Frame(body) - actions.grid(row=1, column=0, columnspan=2, sticky="e", pady=(12, 0)) - ttk.Button(actions, text="Close", command=self.win.destroy).grid(row=0, column=0, sticky="e") - - def _on_theme_changed(self, _event = None): - self.app.set_theme(self.theme_var.get()) diff --git a/droidasc/asc_client/gui/source_edit.py b/droidasc/asc_client/gui/source_edit.py deleted file mode 100644 index 6f126ee..0000000 --- a/droidasc/asc_client/gui/source_edit.py +++ /dev/null @@ -1,381 +0,0 @@ -_IDENT_START_EXTRA = "_$" -_IDENT_EXTRA = "_$" -_BLOCK_PREFIXES = ( - "if ", "if(", "for ", "for(", "while ", "while(", "switch ", "switch(", - "catch ", "catch(", "else", "try", "do", -) - - -def _is_ident_start(ch : str): - return ch.isalpha() or ch in _IDENT_START_EXTRA - - -def _is_ident(ch : str): - return ch.isalnum() or ch in _IDENT_EXTRA - - -def index_to_offset(text : str, index : str): - line_s, col_s = str(index).split(".", 1) - line_no = int(line_s) - col = int(col_s) - if line_no <= 1: - return min(col, len(text)) - off = 0 - cur_line = 1 - while cur_line < line_no and off < len(text): - nl = text.find("\n", off) - if nl < 0: - return len(text) - off = nl + 1 - cur_line += 1 - return min(off + col, len(text)) - - -def token_at_offset(text : str, offset : int): - if not text: - return None - offset = max(0, min(offset, len(text) - 1)) - if not _is_ident(text[offset]): - if offset > 0 and _is_ident(text[offset - 1]): - offset -= 1 - else: - return None - start = offset - while start > 0 and _is_ident(text[start - 1]): - start -= 1 - end = offset + 1 - while end < len(text) and _is_ident(text[end]): - end += 1 - token = text[start:end] - if not token or not _is_ident_start(token[0]): - return None - return start, end, token - - -def is_identifier(text : str): - if not text or not _is_ident_start(text[0]): - return False - return all(_is_ident(ch) for ch in text[1:]) - - -def find_method_range(text : str, offset : int): - offset = max(0, min(offset, len(text))) - signature_range = _method_range_from_signature_line(text, offset) - if signature_range is not None: - return signature_range - - brace = text.rfind("{", 0, offset + 1) - while brace >= 0: - line_start, prefix = _prefix_before_brace(text, brace) - if _looks_like_method_prefix(prefix): - end = _matching_brace(text, brace) - if end is not None and brace <= offset <= end: - return line_start, end + 1 - brace = text.rfind("{", 0, brace) - return None - - -def _prefix_before_brace(text : str, brace : int): - line_start = text.rfind("\n", 0, brace) + 1 - prefix = text[line_start:brace].strip() - if prefix: - return line_start, prefix - - prev_end = line_start - 1 - if prev_end < 0 or text[prev_end:prev_end + 1] != "\n": - return line_start, prefix - prev_start = text.rfind("\n", 0, prev_end) + 1 - return prev_start, text[prev_start:prev_end].strip() - - -def _method_range_from_signature_line(text : str, offset : int): - line_start = text.rfind("\n", 0, offset) + 1 - line_end_pos = text.find("\n", offset) - if line_end_pos < 0: - line_end_pos = len(text) - current_line = text[line_start:line_end_pos] - if "(" not in current_line or ")" not in current_line: - return None - - search_end = min(len(text), line_end_pos + 1) - open_brace = text.find("{", line_start, search_end) - if open_brace < 0: - next_line_end = text.find("\n", line_end_pos + 1) - if next_line_end < 0: - next_line_end = len(text) - between = text[line_end_pos + 1:next_line_end].strip() - if between != "{": - return None - open_brace = text.find("{", line_end_pos + 1, next_line_end + 1) - - prefix = text[line_start:open_brace].strip() - if not _looks_like_method_prefix(prefix): - return None - end = _matching_brace(text, open_brace) - if end is None: - return None - return line_start, end + 1 - - -def _looks_like_method_prefix(prefix : str): - if not prefix or prefix.startswith(_BLOCK_PREFIXES): - return False - if "(" not in prefix or ")" not in prefix: - return False - if prefix.endswith(("=", ".", ",")): - return False - return True - - -def _matching_brace(text : str, open_pos : int): - depth = 0 - state = "code" - i = open_pos - while i < len(text): - ch = text[i] - nxt = text[i + 1] if i + 1 < len(text) else "" - if state == "code": - if ch == "/" and nxt == "/": - state = "line_comment" - i += 2 - continue - if ch == "/" and nxt == "*": - state = "block_comment" - i += 2 - continue - if ch == '"': - state = "string" - i += 1 - continue - if ch == "'": - state = "char" - i += 1 - continue - if ch == "{": - depth += 1 - elif ch == "}": - depth -= 1 - if depth == 0: - return i - elif state == "line_comment": - if ch == "\n": - state = "code" - elif state == "block_comment": - if ch == "*" and nxt == "/": - state = "code" - i += 2 - continue - elif state == "string": - if ch == "\\": - i += 2 - continue - if ch == '"': - state = "code" - elif state == "char": - if ch == "\\": - i += 2 - continue - if ch == "'": - state = "code" - i += 1 - return None - - -def rename_identifier_in_range(text : str, start : int, end : int, old : str, new : str): - out = [] - spans = [] - last = start - state = "code" - i = start - while i < end: - ch = text[i] - nxt = text[i + 1] if i + 1 < end else "" - if state == "code": - if ch == "/" and nxt == "/": - state = "line_comment" - i += 2 - continue - if ch == "/" and nxt == "*": - state = "block_comment" - i += 2 - continue - if ch == '"': - state = "string" - i += 1 - continue - if ch == "'": - state = "char" - i += 1 - continue - if _is_ident_start(ch): - tok_start = i - i += 1 - while i < end and _is_ident(text[i]): - i += 1 - if text[tok_start:i] == old: - out.append(text[last:tok_start]) - out.append(new) - spans.append((tok_start, i)) - last = i - continue - elif state == "line_comment": - if ch == "\n": - state = "code" - elif state == "block_comment": - if ch == "*" and nxt == "/": - state = "code" - i += 2 - continue - elif state == "string": - if ch == "\\": - i += 2 - continue - if ch == '"': - state = "code" - elif state == "char": - if ch == "\\": - i += 2 - continue - if ch == "'": - state = "code" - i += 1 - if not spans: - return text, 0 - out.append(text[last:end]) - return text[:start] + "".join(out) + text[end:], len(spans) - - -def remap_ranges_after_replacements(ranges, spans, replacement_length : int): - def remap(point): - shift = 0 - for start, end in spans: - if point < end: - break - shift += replacement_length - (end - start) - return point + shift - - return [ - (remap(item[0]), remap(item[1]), *item[2:]) - for item in ranges - ] - - -def member_reference_at_offset(references, offset : int): - return next( - (item for item in references if item[0] <= offset < item[1]), - None, - ) - - -def find_member_declaration(references, member_type : str, member_name : str, descriptor : str): - declarations = [ - item for item in references - if item[2] == member_type and item[4] == member_name and item[6] - ] - exact = next((item for item in declarations if item[5] == descriptor), None) - if exact is not None: - return exact - if not descriptor and declarations: - return declarations[0] - return None - - -def find_search_reference( - source : str, references, method_name : str, method_descriptor : str, - target_type : str, target_class : str, target_name : str, - target_descriptor : str, occurrence : int = 0, -): - declaration = find_member_declaration( - references, "method", method_name, method_descriptor - ) - if declaration is None: - declaration = find_member_declaration(references, "method", method_name, "") - if declaration is None: - return None - method_range = find_method_range(source, declaration[0]) - if method_range is None: - return None - - def matches(item): - if not (method_range[0] <= item[0] < method_range[1]) or item[6]: - return False - if item[2] != target_type: - return False - if target_type == "string": - return item[4] == target_name - if target_type == "class": - return item[3] == target_class - return ( - item[3] == target_class - and item[4] == target_name - and (not target_descriptor or item[5] == target_descriptor) - ) - - matches_in_method = [item for item in references if matches(item)] - if occurrence < 0 or occurrence >= len(matches_in_method): - return None - return matches_in_method[occurrence] - - -def linkable_member_spans(references, available_classes): - return [ - (item[0], item[1]) - for item in references - if item[3] in available_classes - ] - - -def identifier_occurrences_in_range(text : str, start : int, end : int, name : str): - spans = [] - state = "code" - i = start - while i < end: - ch = text[i] - nxt = text[i + 1] if i + 1 < end else "" - if state == "code": - if ch == "/" and nxt == "/": - state = "line_comment" - i += 2 - continue - if ch == "/" and nxt == "*": - state = "block_comment" - i += 2 - continue - if ch == '"': - state = "string" - i += 1 - continue - if ch == "'": - state = "char" - i += 1 - continue - if _is_ident_start(ch): - tok_start = i - i += 1 - while i < end and _is_ident(text[i]): - i += 1 - if text[tok_start:i] == name: - spans.append((tok_start, i)) - continue - elif state == "line_comment": - if ch == "\n": - state = "code" - elif state == "block_comment": - if ch == "*" and nxt == "/": - state = "code" - i += 2 - continue - elif state == "string": - if ch == "\\": - i += 2 - continue - if ch == '"': - state = "code" - elif state == "char": - if ch == "\\": - i += 2 - continue - if ch == "'": - state = "code" - i += 1 - return spans diff --git a/droidasc/asc_client/gui/state.py b/droidasc/asc_client/gui/state.py new file mode 100644 index 0000000..4150788 --- /dev/null +++ b/droidasc/asc_client/gui/state.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass, field + + +@dataclass +class EditorTab: + dalvik_class : str + title : str + kind : str = "class" + dex_name : str = "" + source : str = "" + edited_source : str = "" + loading : bool = True + error : str = "" + yview : tuple = (0.0, 1.0) + xview : tuple = (0.0, 1.0) + insert_index : str = "1.0" + comments : dict = field(default_factory=dict) + member_references : list = field(default_factory=list) + rendered_member_references : list = field(default_factory=list) diff --git a/droidasc/asc_client/gui/text_utils.py b/droidasc/asc_client/gui/text_utils.py deleted file mode 100644 index 0368dba..0000000 --- a/droidasc/asc_client/gui/text_utils.py +++ /dev/null @@ -1,102 +0,0 @@ -def decode_java_unicode_escapes(text : str): - if "\\u" not in text: - return text - - out = [] - pending_high = None - pending_high_raw = None - i = 0 - n = len(text) - while i < n: - ch = text[i] - if ch != "\\" or i + 1 >= n or text[i + 1] != "u": - if pending_high is not None: - out.append(pending_high_raw) - pending_high = None - pending_high_raw = None - out.append(ch) - i += 1 - continue - - escape_start = i - j = i + 1 - while j < n and text[j] == "u": - j += 1 - if j + 4 > n: - if pending_high is not None: - out.append(pending_high_raw) - pending_high = None - pending_high_raw = None - out.append(ch) - i += 1 - continue - - hex_part = text[j:j + 4] - try: - code_unit = int(hex_part, 16) - except ValueError: - if pending_high is not None: - out.append(pending_high_raw) - pending_high = None - pending_high_raw = None - out.append(ch) - i += 1 - continue - - i = j + 4 - raw_escape = text[escape_start:i] - if code_unit == 0xFFFD: - if pending_high is not None: - out.append(pending_high_raw) - pending_high = None - pending_high_raw = None - out.append(raw_escape) - continue - if 0xD800 <= code_unit <= 0xDBFF: - if pending_high is not None: - out.append(pending_high_raw) - pending_high = code_unit - pending_high_raw = raw_escape - continue - - if pending_high is not None: - if 0xDC00 <= code_unit <= 0xDFFF: - code_point = 0x10000 + ((pending_high - 0xD800) << 10) + (code_unit - 0xDC00) - out.append(chr(code_point)) - pending_high = None - pending_high_raw = None - continue - out.append(pending_high_raw) - pending_high = None - pending_high_raw = None - - if 0xDC00 <= code_unit <= 0xDFFF: - out.append(raw_escape) - continue - - out.append(chr(code_unit)) - - if pending_high is not None: - out.append(pending_high_raw) - return "".join(out) - - -def decode_java_unicode_escapes_with_ranges(text : str, ranges): - if not ranges or "\\u" not in text: - return decode_java_unicode_escapes(text), list(ranges) - - points = sorted({point for item in ranges for point in item[:2]}) - mapped = {} - source_pos = 0 - decoded_pos = 0 - for point in points: - decoded_pos += len(decode_java_unicode_escapes(text[source_pos:point])) - mapped[point] = decoded_pos - source_pos = point - - decoded = decode_java_unicode_escapes(text) - adjusted = [ - (mapped[item[0]], mapped[item[1]], *item[2:]) - for item in ranges - ] - return decoded, adjusted diff --git a/droidasc/asc_client/gui/ui/__init__.py b/droidasc/asc_client/gui/ui/__init__.py new file mode 100644 index 0000000..eaed700 --- /dev/null +++ b/droidasc/asc_client/gui/ui/__init__.py @@ -0,0 +1,10 @@ +from .class_tree import ClassTree +from .dialogs import LineCommentDialog, RenameDialog, SearchDialog, SettingsDialog +from .tab_bar import EditorTabBar +from .theme import THEMES, ThemeManager + + +__all__ = [ + "ClassTree", "EditorTabBar", "LineCommentDialog", "RenameDialog", + "SearchDialog", "SettingsDialog", "THEMES", "ThemeManager", +] diff --git a/droidasc/asc_client/gui/ui/class_tree.py b/droidasc/asc_client/gui/ui/class_tree.py new file mode 100644 index 0000000..d95b15c --- /dev/null +++ b/droidasc/asc_client/gui/ui/class_tree.py @@ -0,0 +1,154 @@ +import tkinter as tk +from tkinter import ttk + +from droidasc.asc_client.gui.backend import dalvik_to_dot + + +_MAX_FILTER_CLASSES = 5000 + + +def _package_label(package_path : str): + if not package_path: + return "(root)" + return package_path.rsplit("/", 1)[-1] + + +def _class_label(dalvik_class : str): + return dalvik_class.split("/")[-1].rstrip(";") + + +class ClassTree(ttk.Frame): + def __init__(self, master, open_class, open_manifest): + super().__init__(master, padding=8) + self.open_class = open_class + self.store = None + self.filter_var = tk.StringVar() + self.info_var = tk.StringVar(value="Classes") + + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=1) + + top = ttk.Frame(self) + top.grid(row=0, column=0, sticky="ew") + top.columnconfigure(1, weight=1) + ttk.Label(top, textvariable=self.info_var).grid(row=0, column=0, sticky="w") + self.filter_entry = ttk.Entry(top, textvariable=self.filter_var) + self.filter_entry.grid(row=0, column=1, sticky="ew", padx=(8, 0)) + self.filter_entry.bind("", self._on_filter_changed) + self.manifest_button = ttk.Button(top, text="Manifest", command=open_manifest) + self.manifest_button.grid(row=0, column=2, sticky="e", padx=(8, 0)) + + body = ttk.Frame(self) + body.grid(row=1, column=0, sticky="nsew", pady=(8, 0)) + body.columnconfigure(0, weight=1) + body.rowconfigure(0, weight=1) + self.tree = ttk.Treeview(body, show="tree") + self.tree.grid(row=0, column=0, sticky="nsew") + self.tree.bind("<>", self._on_open) + self.tree.bind("<>", self._on_select) + scrollbar = ttk.Scrollbar(body, orient=tk.VERTICAL, command=self.tree.yview) + scrollbar.grid(row=0, column=1, sticky="ns") + self.tree.config(yscrollcommand=scrollbar.set, selectmode="browse") + + def set_store(self, store): + self.store = store + self.refresh() + + def set_enabled(self, enabled : bool): + state = tk.NORMAL if enabled else tk.DISABLED + self.filter_entry.config(state=state) + self.manifest_button.config(state=state) + + def refresh(self): + if self.store is None: + return + keyword = self.filter_var.get().strip() + self.tree.delete(*self.tree.get_children()) + if keyword: + # Rebuilding thousands of Treeview rows blocks Tk. The label makes + # the hard UI cap visible when more matches may exist. + matches = self.store.iter_filtered_classes(keyword, _MAX_FILTER_CLASSES) + for dalvik_class in matches: + self.tree.insert( + "", + tk.END, + iid=f"cls:{dalvik_class}", + text=dalvik_to_dot(dalvik_class), + ) + if len(matches) >= _MAX_FILTER_CLASSES: + self.info_var.set(f"Classes ({_MAX_FILTER_CLASSES}+ filtered)") + else: + self.info_var.set(f"Classes ({len(matches)} filtered)") + return + + for package_path in self.store.iter_root_packages(): + self._insert_package("", package_path) + for dalvik_class in self.store.iter_package_classes(""): + self.tree.insert( + "", + tk.END, + iid=f"cls:{dalvik_class}", + text=_class_label(dalvik_class), + values=("class",), + ) + self.info_var.set(f"Packages / Classes ({len(self.store.class_names)})") + + def _insert_package(self, parent_id : str, package_path : str): + item_id = f"pkg:{package_path}" + if self.tree.exists(item_id): + return item_id + self.tree.insert( + parent_id, + tk.END, + iid=item_id, + text=_package_label(package_path), + values=("pkg",), + ) + # Treeview shows an expand arrow only when a node already has a child. + # Replace this sentinel with real children on first expansion. + self.tree.insert(item_id, tk.END, iid=f"{item_id}:stub", text="...") + return item_id + + def _populate_package(self, package_path : str): + item_id = f"pkg:{package_path}" + if not self.tree.exists(item_id): + return + children = self.tree.get_children(item_id) + if len(children) == 1 and children[0] == f"{item_id}:stub": + self.tree.delete(children[0]) + elif children: + # Existing real children mean this package was already populated. + return + + for child in self.store.iter_child_packages(package_path): + self._insert_package(item_id, child) + for dalvik_class in self.store.iter_package_classes(package_path): + self.tree.insert( + item_id, + tk.END, + iid=f"cls:{dalvik_class}", + text=_class_label(dalvik_class), + values=("class",), + ) + + def select_class(self, dalvik_class : str): + item_id = f"cls:{dalvik_class}" + if not self.tree.exists(item_id): + return + self.tree.selection_set(item_id) + self.tree.focus(item_id) + self.tree.see(item_id) + + def _on_filter_changed(self, _event = None): + self.refresh() + + def _on_open(self, _event = None): + selection = self.tree.focus() + if selection.startswith("pkg:"): + self._populate_package(selection[4:]) + + def _on_select(self, _event = None): + selection = self.tree.selection() + if not selection or not selection[0].startswith("cls:"): + return + self.open_class(selection[0][4:]) diff --git a/droidasc/asc_client/gui/ui/dialogs.py b/droidasc/asc_client/gui/ui/dialogs.py new file mode 100644 index 0000000..7cc836d --- /dev/null +++ b/droidasc/asc_client/gui/ui/dialogs.py @@ -0,0 +1,331 @@ +import tkinter as tk +from tkinter import ttk + + +class SearchDialog: + def __init__( + self, + root, + find_type : str, + value : str, + class_name, + fuzzy_class : bool, + open_result, + apply_theme, + on_destroy = None, + ): + self.find_type = find_type + self.value = value + self.class_name = class_name + self.fuzzy_class = fuzzy_class + self.open_result = open_result + self.apply_window_theme = apply_theme + self.on_destroy = on_destroy + # all_rows is the immutable search result set; rows is the currently + # displayed filtered projection addressed by Treeview iid. + self.rows = [] + self.all_rows = [] + self.searching = True + + self.win = tk.Toplevel(root) + self.win.title("Search") + self.win.geometry("1280x760") + self.win.transient(root) + self.status_var = tk.StringVar(value="Searching...") + self.progress_var = tk.IntVar(value=0) + self.filter_var = tk.StringVar(value="") + + self._build_ui() + self.win.protocol("WM_DELETE_WINDOW", self.win.destroy) + self.win.bind("", self._on_destroy, add="+") + + def _build_ui(self): + self.win.columnconfigure(0, weight=1) + self.win.rowconfigure(1, weight=1) + top = ttk.Frame(self.win, padding=8) + top.grid(row=0, column=0, sticky="ew") + top.columnconfigure(0, weight=1) + top.columnconfigure(2, weight=1) + ttk.Label(top, textvariable=self.status_var).grid(row=0, column=0, sticky="w") + ttk.Label(top, text="Filter").grid(row=0, column=1, sticky="e", padx=(12, 0)) + self.filter_entry = ttk.Entry(top, textvariable=self.filter_var) + self.filter_entry.grid(row=0, column=2, sticky="ew", padx=(8, 0)) + self.filter_entry.bind("", self._on_filter_changed) + self.filter_entry.config(state=tk.DISABLED) + self.progress = ttk.Progressbar( + top, + mode="determinate", + variable=self.progress_var, + maximum=100, + ) + self.progress.grid(row=1, column=0, columnspan=3, sticky="ew", pady=(8, 0)) + + body = ttk.Frame(self.win, padding=(8, 0, 8, 8)) + body.grid(row=1, column=0, sticky="nsew") + body.columnconfigure(0, weight=1) + body.rowconfigure(0, weight=1) + self.tree = ttk.Treeview( + body, + columns=("dex", "class", "method", "matched"), + show="headings", + ) + self.tree.heading("dex", text="DEX") + self.tree.heading("class", text="Class") + self.tree.heading("method", text="Method") + self.tree.heading("matched", text="Matched") + self.tree.column("dex", width=120, stretch=False) + self.tree.column("class", width=320, stretch=False) + self.tree.column("method", width=260, stretch=False) + self.tree.column("matched", width=560, stretch=True) + self.tree.grid(row=0, column=0, sticky="nsew") + self.tree.bind("", self._open_selected) + ybar = ttk.Scrollbar(body, orient=tk.VERTICAL, command=self.tree.yview) + ybar.grid(row=0, column=1, sticky="ns") + xbar = ttk.Scrollbar(body, orient=tk.HORIZONTAL, command=self.tree.xview) + xbar.grid(row=1, column=0, sticky="ew") + self.tree.config(yscrollcommand=ybar.set, xscrollcommand=xbar.set) + self.apply_theme() + + def _on_destroy(self, event): + if event.widget is self.win and self.on_destroy is not None: + self.on_destroy(self) + + def apply_theme(self): + if self.win.winfo_exists(): + self.apply_window_theme(self.win) + + def update_progress(self, done : int, total : int, hit_count : int): + percent = int((done / total) * 100) if total else 0 + self.progress_var.set(percent) + self.status_var.set(f"Searching {done}/{total} dex, hits={hit_count}") + + def finish(self, payload): + if not self.rows and payload["results"]: + self.append_results(payload["results"]) + self.searching = False + self.filter_entry.config(state=tk.NORMAL) + self.status_var.set( + f"Done. hits={payload['total_hits']} workers={payload['workers']} " + f"backend={payload['backend']}" + ) + self.progress_var.set(100) + + def append_results(self, rows): + if not rows: + return + self.all_rows.extend(rows) + if self.searching: + self._append_visible_rows(rows) + return + keyword = self.filter_var.get().strip().lower() + if keyword: + rows = [row for row in rows if self._row_matches_filter(row, keyword)] + self._append_visible_rows(rows) + + def fail(self, message : str): + self.searching = False + self.filter_entry.config(state=tk.NORMAL) + self.status_var.set(message) + + def _open_selected(self, _event = None): + selection = self.tree.selection() + if selection: + self.open_result(self.rows[int(selection[0])], self.find_type) + + def _append_visible_rows(self, rows): + if not rows: + return + start_idx = len(self.rows) + self.rows.extend(rows) + for offset, row in enumerate(rows): + method_name = row["method_text"].split("->", 1)[1] + self.tree.insert( + "", + tk.END, + # Selection can recover the row in O(1) without storing a + # second object mapping on every Treeview item. + iid=str(start_idx + offset), + values=( + row["dex_name"], + row["class_display"], + method_name, + row["matched_text"], + ), + ) + + @staticmethod + def _row_matches_filter(row, keyword : str): + if not keyword: + return True + return ( + keyword in row["dex_name"].lower() + or keyword in row["class_display"].lower() + or keyword in row["method_text"].lower() + or keyword in row["matched_text"].lower() + ) + + def _rebuild_filtered_rows(self): + if self.searching: + return + keyword = self.filter_var.get().strip().lower() + self.tree.delete(*self.tree.get_children()) + self.rows = [] + if not keyword: + self._append_visible_rows(self.all_rows) + return + self._append_visible_rows([ + row for row in self.all_rows if self._row_matches_filter(row, keyword) + ]) + + def _on_filter_changed(self, _event = None): + if not self.searching: + self._rebuild_filtered_rows() + + +class SettingsDialog: + def __init__( + self, + root, + current_theme : str, + theme_names, + set_theme, + apply_theme, + ): + self.set_theme = set_theme + self.win = tk.Toplevel(root) + self.win.title("Settings") + self.win.transient(root) + self.win.resizable(False, False) + self.win.geometry( + "+%d+%d" % (root.winfo_rootx() + 96, root.winfo_rooty() + 96) + ) + self.theme_var = tk.StringVar(value=current_theme) + + body = ttk.Frame(self.win, padding=12) + body.grid(row=0, column=0, sticky="nsew") + body.columnconfigure(1, weight=1) + ttk.Label(body, text="Theme").grid(row=0, column=0, sticky="w") + combo = ttk.Combobox( + body, + textvariable=self.theme_var, + values=theme_names, + state="readonly", + width=18, + ) + combo.grid(row=0, column=1, sticky="ew", padx=(10, 0)) + combo.bind("<>", self._on_theme_changed) + actions = ttk.Frame(body) + actions.grid(row=1, column=0, columnspan=2, sticky="e", pady=(12, 0)) + ttk.Button(actions, text="Close", command=self.win.destroy).grid( + row=0, column=0, sticky="e" + ) + apply_theme(self.win) + + def _on_theme_changed(self, _event = None): + self.set_theme(self.theme_var.get()) + + +class LineCommentDialog: + def __init__( + self, + root, + line_no : int, + source_line : str, + comment : str, + save_comment, + apply_theme, + ): + self.save_comment = save_comment + self.win = tk.Toplevel(root) + self.win.title(f"Comment line {line_no}") + self.win.transient(root) + self.win.resizable(True, False) + apply_theme(self.win) + _place_dialog(root, self.win, 640, 118, 420) + + frame = ttk.Frame(self.win, padding=10) + frame.grid(row=0, column=0, sticky="nsew") + self.win.columnconfigure(0, weight=1) + frame.columnconfigure(1, weight=1) + ttk.Label(frame, text=f"Line {line_no}").grid(row=0, column=0, sticky="w") + ttk.Label(frame, text=_ellipsize(source_line.strip(), 92)).grid( + row=0, column=1, sticky="ew", padx=(8, 0) + ) + self.comment_var = tk.StringVar(value=comment) + ttk.Label(frame, text="Comment").grid(row=1, column=0, sticky="w", pady=(8, 0)) + self.entry = ttk.Entry(frame, textvariable=self.comment_var) + self.entry.grid(row=1, column=1, sticky="ew", padx=(8, 0), pady=(8, 0)) + actions = ttk.Frame(frame) + actions.grid(row=2, column=0, columnspan=2, sticky="e", pady=(10, 0)) + ttk.Button(actions, text="Save", command=self._save).grid( + row=0, column=0, sticky="e" + ) + ttk.Button(actions, text="Delete", command=self._delete).grid( + row=0, column=1, sticky="e", padx=(6, 0) + ) + ttk.Button(actions, text="Cancel", command=self.win.destroy).grid( + row=0, column=2, sticky="e", padx=(6, 0) + ) + self.entry.bind("", self._save) + self.entry.bind("", lambda _event: self.win.destroy()) + self.entry.focus_set() + self.entry.selection_range(0, tk.END) + + def _save(self, _event = None): + self.save_comment(self.comment_var.get().strip()) + self.win.destroy() + return "break" + + def _delete(self): + self.save_comment("") + self.win.destroy() + + +class RenameDialog: + def __init__(self, root, old_name : str, rename, apply_theme): + self.rename = rename + self.win = tk.Toplevel(root) + self.win.title(f"Rename {old_name}") + self.win.transient(root) + self.win.resizable(True, False) + apply_theme(self.win) + _place_dialog(root, self.win, 520, 110, 360) + + frame = ttk.Frame(self.win, padding=10) + frame.grid(row=0, column=0, sticky="nsew") + self.win.columnconfigure(0, weight=1) + frame.columnconfigure(1, weight=1) + ttk.Label(frame, text="Rename").grid(row=0, column=0, sticky="w") + self.name_var = tk.StringVar(value=old_name) + self.entry = ttk.Entry(frame, textvariable=self.name_var) + self.entry.grid(row=0, column=1, sticky="ew", padx=(8, 0)) + actions = ttk.Frame(frame) + actions.grid(row=1, column=0, columnspan=2, sticky="e", pady=(12, 0)) + ttk.Button(actions, text="Rename", command=self._save).grid( + row=0, column=0, sticky="e" + ) + ttk.Button(actions, text="Cancel", command=self.win.destroy).grid( + row=0, column=1, sticky="e", padx=(6, 0) + ) + self.entry.bind("", self._save) + self.entry.bind("", lambda _event: self.win.destroy()) + self.entry.focus_set() + self.entry.selection_range(0, tk.END) + + def _save(self, _event = None): + if self.rename(self.name_var.get().strip()): + self.win.destroy() + return "break" + + +def _place_dialog(root, win, width : int, height : int, min_width : int): + x = root.winfo_rootx() + max(80, (root.winfo_width() - width) // 2) + y = root.winfo_rooty() + 160 + win.geometry(f"{width}x{height}+{x}+{y}") + win.minsize(min_width, height) + + +def _ellipsize(text : str, limit : int): + if len(text) <= limit: + return text + return text[:max(0, limit - 3)] + "..." diff --git a/droidasc/asc_client/gui/widgets.py b/droidasc/asc_client/gui/ui/tab_bar.py similarity index 85% rename from droidasc/asc_client/gui/widgets.py rename to droidasc/asc_client/gui/ui/tab_bar.py index 5fc35a1..8aab139 100644 --- a/droidasc/asc_client/gui/widgets.py +++ b/droidasc/asc_client/gui/ui/tab_bar.py @@ -1,33 +1,34 @@ import tkinter as tk import tkinter.font as tkfont -from dataclasses import dataclass, field from tkinter import ttk -from droidasc.asc_client.gui.runtime import dalvik_to_dot - - -@dataclass -class EditorTab: - dalvik_class : str - title : str - kind : str = "class" - dex_name : str = "" - source : str = "" - edited_source : str = "" - loading : bool = True - error : str = "" - yview : tuple = (0.0, 1.0) - xview : tuple = (0.0, 1.0) - insert_index : str = "1.0" - comments : dict = field(default_factory=dict) - member_references : list = field(default_factory=list) - rendered_member_references : list = field(default_factory=list) +from droidasc.asc_client.gui.backend import dalvik_to_dot +from droidasc.asc_client.gui.state import EditorTab class EditorTabBar(ttk.Frame): - def __init__(self, master, app): + def __init__( + self, + master, + get_tabs, + get_active_index, + activate_tab, + close_tab, + clear_tabs, + open_settings, + get_theme, + apply_theme_to_window, + apply_listbox_theme, + ): super().__init__(master) - self.app = app + self.get_tabs = get_tabs + self.get_active_index = get_active_index + self.activate_tab = activate_tab + self.close_tab = close_tab + self.clear_tabs = clear_tabs + self.get_theme = get_theme + self.apply_theme_to_window = apply_theme_to_window + self.apply_listbox_theme = apply_listbox_theme self.tab_scroll_index = 0 self.tab_rects = [] self.hover_close_index = None @@ -66,7 +67,7 @@ def __init__(self, master, app): padx=0, pady=0, highlightthickness=0, - command=self.app._open_settings, + command=open_settings, ) self.settings_button.grid(row=0, column=2, sticky="ns") @@ -82,7 +83,7 @@ def __init__(self, master, app): self.apply_theme() def _theme(self): - return self.app.theme + return self.get_theme() def apply_theme(self): t = self._theme() @@ -102,16 +103,16 @@ def apply_theme(self): activeforeground=t["text"], ) if self.popup is not None and self.popup.winfo_exists(): - self.app.apply_theme_to_window(self.popup) + self.apply_theme_to_window(self.popup) if self.popup_listbox is not None: - self.app.theme_manager.apply_listbox(self.popup_listbox) + self.apply_listbox_theme(self.popup_listbox) self.redraw() def _tab_width(self, tab : EditorTab) -> int: return max(128, min(260, self.font.measure(tab.title) + 56)) def _visible_capacity(self, start : int, width : int): - tabs = self.app.editor_tabs + tabs = self.get_tabs() used = 0 visible = [] for idx in range(start, len(tabs)): @@ -125,9 +126,10 @@ def _visible_capacity(self, start : int, width : int): return visible def _tabs_overflow(self, width : int) -> bool: - if not self.app.editor_tabs: + tabs = self.get_tabs() + if not tabs: return False - total = sum(self._tab_width(tab) for tab in self.app.editor_tabs) + total = sum(self._tab_width(tab) for tab in tabs) return total > max(1, width) def _more_button_width(self): @@ -151,6 +153,8 @@ def _available_tab_width(self): return width frame_width = self.winfo_width() if frame_width > 1: + # Before the first layout pass the canvas reports width 1. Derive + # its future space from the frame minus the two fixed buttons. return max(1, frame_width - self._fixed_button_width()) return 240 @@ -180,8 +184,11 @@ def ensure_visible(self, index : int): return used = 0 start = index + tabs = self.get_tabs() + # Backfill from the requested tab so as many preceding tabs as possible + # remain visible instead of always pinning the target to the left edge. for idx in range(index, -1, -1): - tab_width = self._tab_width(self.app.editor_tabs[idx]) + tab_width = self._tab_width(tabs[idx]) if idx != index and used + tab_width > width: break used += tab_width @@ -192,7 +199,7 @@ def redraw(self): self._sync_more_button() self.canvas.delete("all") self.tab_rects = [] - tabs = self.app.editor_tabs + tabs = self.get_tabs() t = self._theme() width = self._available_tab_width() height = max(22, self.canvas.winfo_height()) @@ -206,7 +213,7 @@ def redraw(self): x = 0 for index, tab_width in self._visible_capacity(self.tab_scroll_index, width): tab = tabs[index] - active = index == self.app.active_tab_idx + active = index == self.get_active_index() x2 = min(width, x + tab_width) fill = t["tab_active"] if active else t["tab_inactive"] outline = t["border"] if active else t["border_light"] @@ -236,6 +243,8 @@ def redraw(self): font=self.close_font, ) + # Canvas items do not provide widget hit testing. Cache tab and + # close-button bounds for click and hover handling. self.tab_rects.append((index, x, x2, close_x1, close_x2)) x = x2 - 1 if self.popup is not None and self.popup.winfo_exists(): @@ -247,6 +256,8 @@ def _ellipsize(self, text : str, max_width : int) -> str: suffix = "..." low = 0 high = len(text) + # Font measurement is relatively expensive; binary search the longest + # fitting prefix instead of removing one character at a time. while low < high: mid = (low + high + 1) // 2 if self.font.measure(text[:mid] + suffix) <= max_width: @@ -266,14 +277,14 @@ def _on_click(self, event): if index is None: return if close_hit: - self.app.close_tab(index) + self.close_tab(index) return - self.app.activate_tab(index) + self.activate_tab(index) def _on_middle_click(self, event): index, _close_hit = self._hit_test(event.x) if index is not None: - self.app.close_tab(index) + self.close_tab(index) def _on_right_click(self, _event): self.show_tab_list() @@ -300,7 +311,7 @@ def _on_mousewheel(self, event): return "break" def scroll_tabs(self, step : int): - tabs = self.app.editor_tabs + tabs = self.get_tabs() if not tabs: return "break" next_index = max(0, min(len(tabs) - 1, self.tab_scroll_index + step)) @@ -310,7 +321,8 @@ def scroll_tabs(self, step : int): return "break" def show_tab_list(self): - if not self.app.editor_tabs: + tabs = self.get_tabs() + if not tabs: return if self.popup is not None and self.popup.winfo_exists(): self.popup.lift() @@ -328,7 +340,7 @@ def show_tab_list(self): screen_width = popup.winfo_screenwidth() width = max(360, min(max(640, text_width + 96), screen_width - 80)) row_height = 24 - visible_rows = min(14, max(4, len(self.app.editor_tabs))) + visible_rows = min(14, max(4, len(tabs))) height = visible_rows * row_height + 68 x = self.more_button.winfo_rootx() + self.more_button.winfo_width() - width y = self.more_button.winfo_rooty() + self.more_button.winfo_height() @@ -381,24 +393,25 @@ def choose(_event = None): return "break" index = self._tab_index_from_popup_selection(selection[0]) if index is not None: - self.app.activate_tab(index) + self.activate_tab(index) return "break" def close_selected(_event = None): selection = listbox.curselection() if not selection: return "break" - if not self.app.editor_tabs: + tabs = self.get_tabs() + if not tabs: return "break" index = self._tab_index_from_popup_selection(selection[0]) if index is None: return "break" - self.app.close_tab(index) - self.refresh_tab_list(min(index, len(self.app.editor_tabs) - 1), force_see=True) + self.close_tab(index) + self.refresh_tab_list(min(index, len(self.get_tabs()) - 1), force_see=True) return "break" def clear_tabs(): - self.app.clear_tabs() + self.clear_tabs() self.refresh_tab_list() def dismiss(_event = None): @@ -439,8 +452,8 @@ def on_mousewheel(event): listbox.bind("", on_mousewheel) listbox.bind("", lambda _event: listbox.yview_scroll(-1, "units")) listbox.bind("", lambda _event: listbox.yview_scroll(1, "units")) - self.app.apply_theme_to_window(popup) - self.app.theme_manager.apply_listbox(listbox) + self.apply_theme_to_window(popup) + self.apply_listbox_theme(listbox) filter_entry.focus_set() self.refresh_tab_list(force_see=True) @@ -450,7 +463,7 @@ def _tab_list_rows(self): keyword = "" if self.popup_filter_var is not None: keyword = self.popup_filter_var.get().strip().lower() - for idx, tab in enumerate(self.app.editor_tabs): + for idx, tab in enumerate(self.get_tabs()): state = "loading" if tab.loading else "error" if tab.error else tab.dex_name suffix = f" [{state}]" if state else "" row = f"{dalvik_to_dot(tab.dalvik_class)}{suffix}" @@ -458,6 +471,8 @@ def _tab_list_rows(self): continue rows.append(row) indexes.append(idx) + # Filtering changes listbox positions. Keep an explicit mapping back to + # the controller's original tab indexes for activate/close actions. self.popup_visible_tab_indexes = indexes return rows @@ -480,20 +495,21 @@ def refresh_tab_list(self, preferred_index = None, force_see : bool = False): return listbox = self.popup_listbox rows = self._tab_list_rows() + tabs = self.get_tabs() if not rows: - rows = ["(no matching tabs)" if self.app.editor_tabs else "(no open tabs)"] + rows = ["(no matching tabs)" if tabs else "(no open tabs)"] listbox.config(state=tk.DISABLED) if self.popup_close_button is not None: self.popup_close_button.config(state=tk.DISABLED) if self.popup_clear_button is not None: - self.popup_clear_button.config(state=tk.NORMAL if self.app.editor_tabs else tk.DISABLED) + self.popup_clear_button.config(state=tk.NORMAL if tabs else tk.DISABLED) else: listbox.config(state=tk.NORMAL) if self.popup_close_button is not None: self.popup_close_button.config(state=tk.NORMAL) if self.popup_clear_button is not None: self.popup_clear_button.config(state=tk.NORMAL) - if not self.app.editor_tabs or not self.popup_visible_tab_indexes: + if not tabs or not self.popup_visible_tab_indexes: preferred_index = None force_see = False if preferred_index is not None and preferred_index < 0: @@ -501,21 +517,23 @@ def refresh_tab_list(self, preferred_index = None, force_see : bool = False): yview = listbox.yview() xview = listbox.xview() if rows != self.popup_rows: + # Rebuild only when content changed, then restore scroll position so + # loading/error title updates do not jump the popup unexpectedly. listbox.config(state=tk.NORMAL) listbox.delete(0, tk.END) for row in rows: listbox.insert(tk.END, row) self.popup_rows = rows - if not self.app.editor_tabs or not self.popup_visible_tab_indexes: + if not tabs or not self.popup_visible_tab_indexes: listbox.config(state=tk.DISABLED) if yview: listbox.yview_moveto(yview[0]) if xview: listbox.xview_moveto(xview[0]) - if not self.app.editor_tabs or not self.popup_visible_tab_indexes: + if not tabs or not self.popup_visible_tab_indexes: return if preferred_index is None: - preferred_index = self.app.active_tab_idx + preferred_index = self.get_active_index() if preferred_index is not None and preferred_index >= 0: listbox.selection_clear(0, tk.END) if preferred_index in self.popup_visible_tab_indexes: diff --git a/droidasc/asc_client/gui/theme.py b/droidasc/asc_client/gui/ui/theme.py similarity index 94% rename from droidasc/asc_client/gui/theme.py rename to droidasc/asc_client/gui/ui/theme.py index d8bbde2..f68dacb 100644 --- a/droidasc/asc_client/gui/theme.py +++ b/droidasc/asc_client/gui/ui/theme.py @@ -91,13 +91,23 @@ def _apply_ttk(self): self.style.configure(".", background=t["panel_bg"], foreground=t["text"]) self.style.configure("TFrame", background=t["panel_bg"]) self.style.configure("TLabel", background=t["panel_bg"], foreground=t["text"]) - self.style.configure("TButton", background=t["surface_alt"], foreground=t["text"], padding=(5, 1)) + self.style.configure( + "TButton", + background=t["surface_alt"], + foreground=t["text"], + padding=(5, 1), + ) self.style.map( "TButton", background=[("active", t["tab_hover"]), ("disabled", t["panel_bg"])], foreground=[("disabled", t["muted_text"])], ) - self.style.configure("TCheckbutton", background=t["panel_bg"], foreground=t["text"], padding=(2, 1)) + self.style.configure( + "TCheckbutton", + background=t["panel_bg"], + foreground=t["text"], + padding=(2, 1), + ) self.style.map( "TCheckbutton", background=[("active", t["panel_bg"])], diff --git a/droidasc/asc_client/manifest_handler.py b/droidasc/asc_client/manifest_handler.py index 400300f..d71c4c9 100644 --- a/droidasc/asc_client/manifest_handler.py +++ b/droidasc/asc_client/manifest_handler.py @@ -16,6 +16,8 @@ def _dummy_axml_logger(): dummy_logger = _DummyLogger() logging_module = types.ModuleType("axml.helper.logging") logging_module.LOGGER = dummy_logger + # axml imports this optional logging module by its fully qualified name. + # Install the stub before importing AXMLPrinter to silence library output. sys.modules["axml.helper.logging"] = logging_module # axml modules cache LOGGER with a module-level import. diff --git a/tests/TESTING.md b/tests/TESTING.md index 3d76c78..85303bd 100644 --- a/tests/TESTING.md +++ b/tests/TESTING.md @@ -4,6 +4,7 @@ Use Python 3.11+ and install `requirements.txt` in a virtual environment. ```sh python tests/run_tests.py --require-decompiler +python tests/run_tests.py --require-decompiler --suite gui python tests/benchmark_startup.py --samples 9 python tests/benchmark_reference.py --samples 9 ``` @@ -16,7 +17,14 @@ without site packages. `listclass` coverage includes local class-table enumeration, stable DEX definition order, normalized package-prefix filtering, stored/Deflate multidex APKs, DEX 041 containers, malformed indices, operation without site packages, and a large-tail Deflate fixture. It does not open GUI -windows. +windows unless the `gui` suite or the unfiltered `all` suite is selected. The +GUI suite covers the backend store/search workers, source editing and +navigation, application layout, package tree, tab bar, dialogs, and themes. +On headless Linux, run it under a virtual display: + +```sh +xvfb-run -a python tests/run_tests.py --require-decompiler --suite gui +``` The synthetic startup gate measures `getclass --debug` in nine fresh interpreters. Its median budgets are 100 ms internal time and 250 ms wall time on Linux CI. @@ -43,6 +51,7 @@ from `artifacts/startup/` and `artifacts/reference/` even when a gate fails. ```sh python tests/run_tests.py --require-decompiler --suite unit python tests/run_tests.py --require-decompiler --suite integration +python tests/run_tests.py --require-decompiler --suite gui python tests/benchmark_compare.py --baseline /path/to/base-checkout --samples 31 ``` diff --git a/tests/run_tests.py b/tests/run_tests.py index c9a0baf..48d96d6 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -11,7 +11,11 @@ if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--require-decompiler', action='store_true') - parser.add_argument('--suite', choices=('all', 'unit', 'integration'), default='all') + parser.add_argument( + '--suite', + choices=('all', 'unit', 'integration', 'gui'), + default='all', + ) args = parser.parse_args() if args.require_decompiler and importlib.util.find_spec('dadguard') is None: parser.error('dadguard is required; check the source tree') @@ -23,10 +27,24 @@ def tests(items): yield from tests(item) else: yield item - suite = unittest.TestSuite(test for test in tests(suite) - if ('test_decompiler.DecompilerTests.' in test.id() - or test.id().endswith('test_archive_is_reproducible_and_runs_outside_checkout')) - == (args.suite == 'integration')) + + def group(test): + test_id = test.id() + if ('test_decompiler.DecompilerTests.' in test_id + or test_id.endswith('test_archive_is_reproducible_and_runs_outside_checkout')): + return 'integration' + if test_id.startswith(( + 'test_gui_backend.', + 'test_gui_editor.', + 'test_gui_views.', + 'test_source_edit.', + )): + return 'gui' + return 'unit' + + suite = unittest.TestSuite( + test for test in tests(suite) if group(test) == args.suite + ) if suite.countTestCases() == 0: parser.error('selected suite contains no tests') result = unittest.TextTestRunner(verbosity=2).run(suite) diff --git a/tests/test_apk_modules.py b/tests/test_apk_modules.py new file mode 100644 index 0000000..d3346db --- /dev/null +++ b/tests/test_apk_modules.py @@ -0,0 +1,137 @@ +import mmap +import os +from pathlib import Path +import tempfile +import threading +import unittest +from unittest import mock +import zipfile + +from dex_fixture import make_dex +from droidasc.asc_client import apk_handler +from droidasc.asc_client.apk import dex_reader, loader, worker + + +class LoaderTests(unittest.TestCase): + def test_stored_and_deflated_entries_round_trip(self): + payload = make_dex() + for compression in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED): + with self.subTest(compression=compression): + with tempfile.TemporaryDirectory() as directory: + apk = Path(directory) / "fixture.apk" + with zipfile.ZipFile(apk, "w", compression=compression) as archive: + archive.writestr("classes.dex", payload) + archive.writestr("assets/classes2.dex", b"ignored") + + with apk.open("rb") as fp, mmap.mmap( + fp.fileno(), 0, access=mmap.ACCESS_READ + ) as mm: + entries = loader._parse_cd_dex_entries(mm) + self.assertEqual([entry[0] for entry in entries], ["classes.dex"]) + self.assertEqual(loader._inflate_dex(mm, entries[0]), payload) + + def test_deflate_cancellation_and_malformed_apk_are_clean(self): + payload = make_dex() + with tempfile.TemporaryDirectory() as directory: + apk = Path(directory) / "fixture.apk" + with zipfile.ZipFile( + apk, "w", compression=zipfile.ZIP_DEFLATED + ) as archive: + archive.writestr("classes.dex", payload) + + with apk.open("rb") as fp, mmap.mmap( + fp.fileno(), 0, access=mmap.ACCESS_READ + ) as mm: + entry = loader._parse_cd_dex_entries(mm)[0] + cancelled = threading.Event() + cancelled.set() + self.assertIsNone(loader._inflate_dex(mm, entry, cancelled)) + + with self.assertRaisesRegex(ValueError, "EOCD not found"): + loader._parse_cd_dex_entries(b"broken") + + +class DexReaderTests(unittest.TestCase): + def test_class_lookup_and_listing_share_the_same_string_reader(self): + dex = make_dex() + + self.assertTrue(dex_reader._dex_defines_class(dex, b"Lexample/Test;")) + self.assertFalse(dex_reader._dex_defines_class(dex, b"Lexample/Missing;")) + self.assertEqual(dex_reader.list_dex_classes(dex), ["Lexample/Test;"]) + self.assertEqual( + dex_reader.list_dex_classes( + dex, + prefix="Lexample", + prefix_bytes=b"Lexample", + ), + ["Lexample/Test;"], + ) + self.assertEqual( + dex_reader.list_dex_classes( + dex, + prefix="Lmissing", + prefix_bytes=b"Lmissing", + ), + [], + ) + + def test_invalid_header_preserves_lookup_and_listing_contracts(self): + self.assertEqual(dex_reader._find_type_idx(b"broken", b"LTest;"), -1) + with self.assertRaisesRegex(ValueError, "invalid DEX header"): + dex_reader.list_dex_classes(b"broken") + + +class WorkerTests(unittest.TestCase): + def tearDown(self): + worker._close_worker_apk() + + def test_worker_reuses_and_closes_its_process_local_mmap(self): + with tempfile.TemporaryDirectory() as directory: + apk = Path(directory) / "fixture.apk" + apk.write_bytes(b"mapped") + + first = worker._get_worker_apk_mm(str(apk)) + second = worker._get_worker_apk_mm(str(apk)) + self.assertIs(first, second) + + worker._close_worker_apk() + self.assertIsNone(worker._WORKER_APK_PATH) + self.assertIsNone(worker._WORKER_APK_FP) + self.assertIsNone(worker._WORKER_APK_MM) + + def test_process_context_keeps_windows_on_spawn_default(self): + if os.name == "nt": + self.assertIsNone(worker._process_pool_context()) + + +class FacadeTests(unittest.TestCase): + def test_public_facade_does_not_reexport_moved_private_helpers(self): + self.assertTrue(hasattr(apk_handler, "ApkHandler")) + self.assertFalse(hasattr(apk_handler, "_inflate_dex")) + self.assertFalse(hasattr(apk_handler, "_parse_cd_dex_entries")) + self.assertFalse(hasattr(apk_handler, "_findrefs_worker")) + + def test_single_dex_lookup_avoids_a_useless_thread_pool(self): + with tempfile.TemporaryDirectory() as directory: + apk = Path(directory) / "fixture.apk" + with zipfile.ZipFile( + apk, "w", compression=zipfile.ZIP_DEFLATED + ) as archive: + archive.writestr("classes.dex", make_dex()) + + handler = apk_handler.ApkHandler(str(apk), max_workers=8) + with mock.patch.object(apk_handler, "ThreadPoolExecutor") as executor: + dex_name, dex = handler.get_class_dex("Lexample/Test;") + + executor.assert_not_called() + self.assertEqual(dex_name, "classes.dex") + self.assertTrue(dex.startswith(b"dex")) + + with self.assertRaises(ValueError): + apk_handler.ApkHandler( + str(apk), max_workers=0 + ).get_class_dex("Lexample/Test;") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_decompiler.py b/tests/test_decompiler.py index 764af2c..77abd4d 100644 --- a/tests/test_decompiler.py +++ b/tests/test_decompiler.py @@ -80,7 +80,7 @@ def test_cli_on_stored_and_compressed_multidex_apk(self): self.assertIn('class Test', source.stdout) def test_gui_store_can_decompile_twice_and_then_search(self): - from droidasc.asc_client.gui.runtime import GuiDexStore + from droidasc.asc_client.gui.backend import GuiDexStore with tempfile.TemporaryDirectory() as directory: apk = Path(directory) / 'fixture.apk' with zipfile.ZipFile(apk, 'w') as archive: @@ -94,7 +94,7 @@ def test_gui_store_can_decompile_twice_and_then_search(self): self.assertTrue(store.search_members('method', 'first')) def test_gui_store_subprocess_search_uses_package_module(self): - from droidasc.asc_client.gui.runtime import GuiDexStore + from droidasc.asc_client.gui.backend import GuiDexStore with tempfile.TemporaryDirectory() as directory: apk = Path(directory) / 'fixture.apk' with zipfile.ZipFile(apk, 'w') as archive: diff --git a/tests/test_gui_backend.py b/tests/test_gui_backend.py new file mode 100644 index 0000000..502562b --- /dev/null +++ b/tests/test_gui_backend.py @@ -0,0 +1,300 @@ +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock +import zipfile + +from dex_fixture import make_dex +from droidasc.asc_client.gui.backend import GuiDexStore +from droidasc.asc_client.gui.backend import search as search_backend +from droidasc.asc_client.gui.backend import worker as search_worker +from droidasc.asc_client.gui.backend.search import ( + build_find_query, + dalvik_to_dot, + format_class_name, + is_member_search, + parse_result_line, +) + + +class QueryTests(unittest.TestCase): + def test_class_names_convert_between_display_and_dalvik_forms(self): + self.assertEqual(format_class_name("example.Test"), "Lexample/Test;") + self.assertEqual(format_class_name("example/Test"), "Lexample/Test;") + self.assertEqual(format_class_name("Lexample/Test;"), "Lexample/Test;") + self.assertEqual(dalvik_to_dot("Lexample/Test;"), "example.Test") + self.assertEqual(dalvik_to_dot("int"), "int") + with self.assertRaisesRegex(ValueError, "cannot be empty"): + format_class_name("") + + def test_string_and_type_queries_keep_the_search_value(self): + self.assertEqual( + build_find_query("string refs", "a.b"), + ("string", {"string": "a.b"}), + ) + self.assertEqual( + build_find_query("type refs", "Lexample/Test;"), + ("type", {"type": "Lexample/Test;"}), + ) + + def test_exact_member_query_normalizes_class_and_member(self): + find_type, query = build_find_query( + "method refs", + "run", + "example.Test", + exact_member=True, + ) + self.assertEqual(find_type, "method") + self.assertEqual(query, { + "method": { + "class": ["Lexample/Test;", True], + "method": ["run", True], + }, + }) + + def test_fuzzy_member_query_uses_slashes_without_dalvik_wrapping(self): + find_type, query = build_find_query( + "field refs", + "value", + "example.Target", + fuzzy_class=True, + ) + self.assertEqual(find_type, "field") + self.assertEqual(query, { + "field": { + "class": ["example/Target", False], + "field": "value", + }, + }) + + def test_member_query_accepts_name_only_and_rejects_empty_query(self): + self.assertEqual( + build_find_query("method", "run"), + ("method", {"method": {"class": None, "method": "run"}}), + ) + with self.assertRaisesRegex(ValueError, "at least one"): + build_find_query("field", "", None) + + def test_result_parser_preserves_method_target_and_occurrence(self): + row = parse_result_line( + "classes2.dex | Lsource/Owner;->run(I)V | " + "matched=(Ltarget/Api;->call(Ljava/lang/String;)I) | occurrence=2", + "method", + ) + self.assertEqual(row["dex_name"], "classes2.dex") + self.assertEqual(row["class_name"], "Lsource/Owner;") + self.assertEqual(row["class_display"], "source.Owner") + self.assertEqual(row["method_name"], "run") + self.assertEqual(row["method_descriptor"], "(I)V") + self.assertEqual(row["target_type"], "method") + self.assertEqual(row["target_class"], "Ltarget/Api;") + self.assertEqual(row["target_name"], "call") + self.assertEqual(row["target_descriptor"], "(Ljava/lang/String;)I") + self.assertEqual(row["occurrence"], 2) + + def test_result_parser_handles_field_type_and_legacy_lines(self): + row = parse_result_line( + "classes.dex | Lsource/Owner;->run()V | " + "matched=(Ltarget/Api;->value:Ljava/lang/String;)", + "field", + ) + self.assertEqual(row["matched_text"], "matched=(Ltarget/Api;->value:Ljava/lang/String;)") + self.assertEqual(row["target_class"], "Ltarget/Api;") + self.assertEqual(row["target_name"], "value") + self.assertEqual(row["target_descriptor"], "Ljava/lang/String;") + self.assertEqual(row["occurrence"], 0) + + def test_member_search_type_is_limited_to_direct_member_modes(self): + self.assertTrue(is_member_search("method")) + self.assertTrue(is_member_search("field")) + self.assertFalse(is_member_search("method refs")) + self.assertFalse(is_member_search("string refs")) + + +class StoreTests(unittest.TestCase): + def _loaded_store(self): + directory = tempfile.TemporaryDirectory() + apk = Path(directory.name) / "fixture.apk" + with zipfile.ZipFile(apk, "w") as archive: + archive.writestr("classes.dex", make_dex()) + store = GuiDexStore(str(apk), max_workers=1) + store.load() + self.addCleanup(directory.cleanup) + return store + + def test_package_index_keeps_packages_and_root_classes_separate(self): + store = GuiDexStore("unused.apk") + store.class_names = sorted([ + "LRoot;", + "Lcom/z/Beta;", + "Lcom/a/Zed;", + "Lcom/a/Alpha;", + ]) + + store._build_package_index() + + self.assertEqual(store.iter_root_packages(), ["com"]) + self.assertEqual(store.iter_child_packages("com"), ["com/a", "com/z"]) + self.assertEqual(store.iter_package_classes(""), ["LRoot;"]) + self.assertEqual( + store.iter_package_classes("com/a"), + ["Lcom/a/Alpha;", "Lcom/a/Zed;"], + ) + + def test_filtered_classes_are_case_insensitive_and_limited(self): + store = GuiDexStore("unused.apk") + store.class_names = [ + "Lalpha/First;", + "Lalpha/Second;", + "Lbeta/Alpha;", + ] + self.assertEqual( + store.iter_filtered_classes("ALPHA", 2), + ["Lalpha/First;", "Lalpha/Second;"], + ) + self.assertEqual(store.iter_filtered_classes(" ", 10), []) + + def test_load_reports_progress_and_builds_indexes(self): + store = self._loaded_store() + self.assertEqual([entry[0] for entry in store.entries], ["classes.dex"]) + self.assertIn("Lexample/Test;", store.class_names) + self.assertEqual(store.class_to_dex["Lexample/Test;"], "classes.dex") + self.assertIn("classes.dex", store.dex_buffers) + self.assertIn("classes.dex", store.findref_managers) + + def test_effective_workers_respect_dex_count_and_minimum_parallelism(self): + store = GuiDexStore("unused.apk", max_workers=1) + store.entries = [("a.dex",), ("b.dex",), ("c.dex",)] + self.assertEqual(store.get_effective_search_workers(), 2) + self.assertEqual(store.get_effective_search_workers(20), 3) + store.entries = [("a.dex",)] + self.assertEqual(store.get_effective_search_workers(20), 1) + + def test_source_metadata_is_cached_by_class(self): + store = GuiDexStore("unused.apk") + store.class_to_dex = {"Lexample/Test;": "classes.dex"} + store.dex_buffers = {"classes.dex": b"dex"} + calls = [] + + class FakeHandler: + def __init__(self, debug): + self.debug = debug + + def getclass_with_metadata(self, dex_buf, class_name): + calls.append((dex_buf, class_name)) + return "class Test {}", [(6, 10, "class")] + + with mock.patch("droidasc.asc_client.asc_handler.AscHandler", FakeHandler): + first = store.get_source_with_metadata("Lexample/Test;") + second = store.get_source_with_metadata("Lexample/Test;") + + self.assertIs(first, second) + self.assertEqual(calls, [(b"dex", "Lexample/Test;")]) + + def test_unknown_class_fails_without_touching_the_cache(self): + store = GuiDexStore("unused.apk") + with self.assertRaisesRegex(ValueError, "class not indexed"): + store.get_source_with_metadata("Lmissing/Class;") + self.assertEqual(store.source_cache, {}) + + def test_member_search_returns_stable_rows_and_total_before_limit(self): + store = self._loaded_store() + result = store.search_members("method", "s", limit=1) + self.assertEqual(result["backend"], "locator") + self.assertEqual(result["workers"], 1) + self.assertGreater(result["total_hits"], len(result["results"])) + self.assertEqual(len(result["results"]), 1) + row = result["results"][0] + self.assertEqual(row["dex_name"], "classes.dex") + self.assertEqual(row["class_name"], "Lexample/Test;") + self.assertEqual(row["target_type"], "method") + self.assertEqual(row["target_class"], row["class_name"]) + + +class _FakeProcess: + def __init__(self, returncode, stderr=""): + self.returncode = returncode + self.stderr = io.StringIO(stderr) + self.terminated = False + self.killed = False + self.waited = False + + def poll(self): + return self.returncode + + def terminate(self): + self.terminated = True + self.returncode = -15 + + def kill(self): + self.killed = True + self.returncode = -9 + + def wait(self, timeout=None): + self.waited = True + return self.returncode + + +class WorkerTests(unittest.TestCase): + def test_worker_writes_the_json_protocol(self): + with tempfile.TemporaryDirectory() as directory: + result_path = Path(directory) / "result.json" + payload = { + "apk_path": "fixture.apk", + "entry": ["classes.dex", 1, 2, 3], + "find_type": "string", + "find": {"string": "token"}, + "result_path": str(result_path), + } + worker_result = ("classes.dex", ["line"], 10, 20, 30) + with mock.patch.object(search_worker, "_findrefs_worker", return_value=worker_result): + with mock.patch("sys.stdin", io.StringIO(json.dumps(payload))): + search_worker.main() + + self.assertEqual(json.loads(result_path.read_text(encoding="utf-8")), { + "dex_name": "classes.dex", + "lines": ["line"], + "inflate_us": 10, + "process_us": 20, + "pid": 30, + }) + + def test_subprocess_failure_cleans_other_workers_and_temp_files(self): + store = GuiDexStore("fixture.apk") + store.entries = [("classes.dex",), ("classes2.dex",)] + failed = _FakeProcess(1, "broken worker") + running = _FakeProcess(None) + + with tempfile.TemporaryDirectory() as directory: + failed_path = Path(directory) / "failed.json" + running_path = Path(directory) / "running.json" + failed_path.write_text("", encoding="utf-8") + running_path.write_text("", encoding="utf-8") + launched = iter([ + (failed, str(failed_path)), + (running, str(running_path)), + ]) + with mock.patch.object( + search_backend, + "_launch_subprocess_worker", + side_effect=lambda *_args: next(launched), + ): + with self.assertRaisesRegex(RuntimeError, "broken worker"): + search_backend._search_with_subprocess_pool( + store, + 2, + "string", + {"string": "token"}, + None, + ) + + self.assertTrue(running.terminated or running.killed) + self.assertTrue(running.waited) + self.assertFalse(failed_path.exists()) + self.assertFalse(running_path.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gui_editor.py b/tests/test_gui_editor.py new file mode 100644 index 0000000..5fba36a --- /dev/null +++ b/tests/test_gui_editor.py @@ -0,0 +1,414 @@ +from types import SimpleNamespace +import unittest + +from droidasc.asc_client.gui.app import AscGuiApp +from droidasc.asc_client.gui.editor.controller import EditorController +from droidasc.asc_client.gui.editor.source import ( + decode_java_unicode_escapes, + decode_java_unicode_escapes_with_ranges, + find_method_range, + find_search_reference, + identifier_occurrences_in_range, + index_to_offset, + is_identifier, + java_highlight_spans, + offset_spans_to_indexes, + rename_identifier_in_range, + token_at_offset, +) +from droidasc.asc_client.gui.state import EditorTab +from droidasc.asc_client.gui.ui.dialogs import SearchDialog + + +class _Value: + def __init__(self, value=None): + self.value = value + + def get(self): + return self.value + + def set(self, value): + self.value = value + + +class _Widget: + def __init__(self): + self.options = {} + + def config(self, **options): + self.options.update(options) + + +class _Tree: + def __init__(self): + self.items = {} + + def insert(self, _parent, _where, iid, values): + self.items[iid] = values + + def get_children(self): + return tuple(self.items) + + def delete(self, *items): + for item in items: + self.items.pop(item, None) + + +class _EditorView: + def __init__(self): + self.remembered = [] + self.shown = [] + self.empty_count = 0 + + def remember(self, tab): + self.remembered.append(tab) + + def show_tab(self, tab, available_classes): + self.shown.append((tab, available_classes)) + + def show_empty(self): + self.empty_count += 1 + + +class _TabBar: + def __init__(self): + self.tab_scroll_index = 0 + self.ensured = [] + self.redraw_count = 0 + + def ensure_visible(self, index): + self.ensured.append(index) + + def redraw(self): + self.redraw_count += 1 + + +def _row(name, matched="matched=(token)"): + return { + "dex_name": "classes.dex", + "class_display": "example.Test", + "method_text": f"Lexample/Test;->{name}()V", + "matched_text": matched, + } + + +def _dialog(): + dialog = SearchDialog.__new__(SearchDialog) + dialog.rows = [] + dialog.all_rows = [] + dialog.searching = True + dialog.filter_entry = _Widget() + dialog.status_var = _Value() + dialog.progress_var = _Value() + dialog.filter_var = _Value("") + dialog.tree = _Tree() + return dialog + + +class StateTests(unittest.TestCase): + def test_editor_tab_defaults_are_independent(self): + first = EditorTab("Lexample/First;", "First") + second = EditorTab("Lexample/Second;", "Second") + + first.comments[1] = "note" + first.member_references.append((1, 2)) + + self.assertEqual(first.kind, "class") + self.assertTrue(first.loading) + self.assertEqual(first.yview, (0.0, 1.0)) + self.assertEqual(first.xview, (0.0, 1.0)) + self.assertEqual(first.insert_index, "1.0") + self.assertEqual(second.comments, {}) + self.assertEqual(second.member_references, []) + + def test_search_done_restores_state_even_if_dialog_was_closed(self): + app = SimpleNamespace( + search_dialog=None, + search_inflight=True, + search_button=_Widget(), + status_var=_Value("Searching"), + ) + payload = { + "total_hits": 3, + "workers": 2, + "backend": "subprocess", + "results": [], + } + + AscGuiApp._handle_event(app, ("search_done", payload)) + + self.assertFalse(app.search_inflight) + self.assertEqual(app.search_button.options["state"], "normal") + self.assertEqual( + app.status_var.get(), + "Search done. hits=3 workers=2 backend=subprocess", + ) + + def test_search_error_restores_state_even_if_dialog_was_closed(self): + app = SimpleNamespace( + search_dialog=None, + search_inflight=True, + search_button=_Widget(), + status_var=_Value("Searching"), + ) + + AscGuiApp._handle_event(app, ("search_error", "Search failed: broken")) + + self.assertFalse(app.search_inflight) + self.assertEqual(app.search_button.options["state"], "normal") + self.assertEqual(app.status_var.get(), "Search failed: broken") + + def test_controller_tab_lifecycle_clears_navigation_and_ignores_late_success(self): + view = _EditorView() + bar = _TabBar() + statuses = [] + selected = [] + store = SimpleNamespace(class_to_dex={"Lexample/First;": "classes.dex"}) + controller = EditorController( + root=None, + view=view, + get_store=lambda: store, + emit_event=lambda _event: None, + set_status=statuses.append, + select_class=selected.append, + start_reference_search=lambda *_args: None, + apply_theme_to_window=lambda _win: None, + ) + controller.set_tab_bar(bar) + first = EditorTab( + "Lexample/First;", + "First", + dex_name="classes.dex", + source="class First {}", + loading=False, + ) + second = EditorTab( + "manifest:AndroidManifest.xml", + "AndroidManifest.xml", + kind="text", + source="", + loading=False, + ) + controller.tabs = [first, second] + controller.pending_navigation = ( + "Lexample/First;", "method", "run", "()V", + ) + + controller.activate_tab(0) + controller.close_tab(0) + + self.assertEqual(controller.tabs, [second]) + self.assertEqual(controller.active_tab_idx, 0) + self.assertIsNone(controller.pending_navigation) + self.assertEqual(selected, ["Lexample/First;"]) + self.assertEqual(view.shown[-1][0], second) + + controller.clear_tabs() + shown_count = len(view.shown) + controller.finish_open_tab( + "Lexample/First;", "classes.dex", "late", [], + ) + + self.assertEqual(controller.active_tab_idx, -1) + self.assertEqual(controller.tabs, []) + self.assertEqual(view.empty_count, 1) + self.assertEqual(len(view.shown), shown_count) + self.assertEqual(statuses[-1], "No class tab open") + + +class SourceTests(unittest.TestCase): + def test_tk_index_to_offset_clamps_lines_and_columns(self): + text = "one\ntwo\nthree" + self.assertEqual(index_to_offset(text, "1.2"), 2) + self.assertEqual(index_to_offset(text, "2.1"), 5) + self.assertEqual(index_to_offset(text, "4.0"), len(text)) + self.assertEqual(index_to_offset(text, "2.99"), len(text)) + + def test_token_lookup_accepts_identifier_end_but_not_punctuation(self): + text = "target.call();" + self.assertEqual(token_at_offset(text, text.index("target")), (0, 6, "target")) + self.assertEqual(token_at_offset(text, text.index(".")), (0, 6, "target")) + self.assertEqual(token_at_offset(text, text.index("(")), (7, 11, "call")) + self.assertIsNone(token_at_offset(text, len(text) - 1)) + self.assertIsNone(token_at_offset("", 0)) + + def test_java_identifier_rules_keep_dollar_and_underscore(self): + for value in ("name", "_name", "$name", "name2"): + with self.subTest(value=value): + self.assertTrue(is_identifier(value)) + for value in ("", "2name", "bad-name", "white space"): + with self.subTest(value=value): + self.assertFalse(is_identifier(value)) + + def test_method_range_ignores_braces_in_literals_and_comments(self): + source = ( + "void run()\n" + "{\n" + ' String text = "}";\n' + " // }\n" + " /* { } */\n" + " if (ready) { call(); }\n" + "}\n" + "void next() {}\n" + ) + offset = source.index("call") + method_range = find_method_range(source, offset) + self.assertIsNotNone(method_range) + start, end = method_range + self.assertEqual(source[start:end], source[:source.index("\nvoid next")]) + + def test_rename_changes_code_only_inside_requested_method(self): + source = ( + "void first() {\n" + " int value = 1;\n" + ' String text = "value"; // value\n' + " value++;\n" + "}\n" + "void second() { int value = 2; }\n" + ) + start, end = find_method_range(source, source.index("value")) + + renamed, count = rename_identifier_in_range( + source, + start, + end, + "value", + "renamed", + ) + + self.assertEqual(count, 2) + self.assertIn("int renamed = 1", renamed) + self.assertIn('"value"; // value', renamed) + self.assertIn("void second() { int value = 2; }", renamed) + + def test_identifier_occurrences_skip_comments_strings_and_larger_names(self): + source = 'value value2 "value" // value\nvalue /* value */' + spans = identifier_occurrences_in_range(source, 0, len(source), "value") + self.assertEqual([source[start:end] for start, end in spans], ["value", "value"]) + self.assertEqual(spans[1][0], source.index("\nvalue") + 1) + + def test_search_reference_selects_occurrence_inside_exact_method(self): + source = ( + "void first() { target.call(); }\n" + "void second() { target.call(); target.call(); }\n" + ) + first_decl = source.index("first") + second_decl = source.index("second") + calls = [] + start = 0 + while True: + start = source.find("call", start) + if start < 0: + break + calls.append((start, start + 4, "method", "LTarget;", "call", "()V", False)) + start += 4 + references = [ + (first_decl, first_decl + 5, "method", "LTest;", "first", "()V", True), + (second_decl, second_decl + 6, "method", "LTest;", "second", "()V", True), + *calls, + ] + + found = find_search_reference( + source, + references, + "second", + "()V", + "method", + "LTarget;", + "call", + "()V", + occurrence=1, + ) + + self.assertEqual(found, calls[2]) + + def test_unicode_decode_preserves_invalid_escapes_and_maps_ranges(self): + source = r'"\u4f60"; run(); "\uFFFD"; "\uD800";' + start = source.index("run") + decoded, ranges = decode_java_unicode_escapes_with_ranges( + source, + [(start, start + 3, "method")], + ) + self.assertIn(chr(0x4F60), decoded) + self.assertIn(r"\uFFFD", decoded) + self.assertIn(r"\uD800", decoded) + self.assertEqual(decoded[ranges[0][0]:ranges[0][1]], "run") + self.assertEqual(decode_java_unicode_escapes(r"\uD83D\uDE00"), chr(0x1F600)) + + def test_java_highlight_spans_keep_lexical_categories_and_tk_indexes(self): + source = '@Override\npublic void run() { String text = "class"; // return\n}' + + spans = java_highlight_spans(source) + indexed = offset_spans_to_indexes(source, spans) + + self.assertEqual( + [source[start:end] for start, end in spans["java_keyword"]], + ["public", "void"], + ) + self.assertEqual( + [source[start:end] for start, end in spans["java_annotation"]], + ["@Override"], + ) + self.assertEqual( + [source[start:end] for start, end in spans["java_string"]], + ['"class"'], + ) + self.assertEqual( + [source[start:end] for start, end in spans["java_comment"]], + ["// return"], + ) + self.assertEqual(indexed["java_annotation"], [("1.0", "1.9")]) + self.assertEqual(indexed["java_keyword"][0], ("2.0", "2.6")) + + +class SearchDialogTests(unittest.TestCase): + def test_streamed_rows_remain_visible_and_finish_enables_filter(self): + dialog = _dialog() + rows = [_row("first"), _row("second")] + + dialog.append_results(rows) + dialog.finish({ + "results": rows, + "total_hits": 2, + "workers": 1, + "backend": "locator", + }) + + self.assertEqual(dialog.rows, rows) + self.assertEqual(dialog.all_rows, rows) + self.assertEqual(len(dialog.tree.items), 2) + self.assertFalse(dialog.searching) + self.assertEqual(dialog.filter_entry.options["state"], "normal") + self.assertEqual(dialog.progress_var.get(), 100) + self.assertEqual( + dialog.status_var.get(), + "Done. hits=2 workers=1 backend=locator", + ) + + def test_filter_matches_all_displayed_columns(self): + dialog = _dialog() + dialog.append_results([ + _row("first", "matched=(alpha)"), + _row("second", "matched=(beta)"), + ]) + dialog.searching = False + dialog.filter_var.set("BETA") + + dialog._rebuild_filtered_rows() + + self.assertEqual(len(dialog.rows), 1) + self.assertIn("second", dialog.rows[0]["method_text"]) + self.assertEqual(len(dialog.all_rows), 2) + self.assertEqual(tuple(dialog.tree.items), ("0",)) + + def test_progress_handles_empty_and_nonempty_work(self): + dialog = _dialog() + dialog.update_progress(0, 0, 0) + self.assertEqual(dialog.progress_var.get(), 0) + dialog.update_progress(2, 4, 7) + self.assertEqual(dialog.progress_var.get(), 50) + self.assertEqual(dialog.status_var.get(), "Searching 2/4 dex, hits=7") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gui_views.py b/tests/test_gui_views.py new file mode 100644 index 0000000..3a806be --- /dev/null +++ b/tests/test_gui_views.py @@ -0,0 +1,357 @@ +from types import SimpleNamespace +import tkinter as tk +from tkinter import ttk +import unittest +from unittest import mock + +from droidasc.asc_client.gui.app import AscGuiApp +from droidasc.asc_client.gui.state import EditorTab +from droidasc.asc_client.gui.ui.class_tree import ClassTree +from droidasc.asc_client.gui.ui.dialogs import ( + LineCommentDialog, + RenameDialog, + SearchDialog, + SettingsDialog, +) +from droidasc.asc_client.gui.ui.tab_bar import EditorTabBar +from droidasc.asc_client.gui.ui.theme import THEMES, ThemeManager + + +def _row(method_name, matched): + return { + "dex_name": "classes.dex", + "class_name": "Lexample/Test;", + "class_display": "example.Test", + "method_text": f"Lexample/Test;->{method_name}()V", + "matched_text": matched, + } + + +class _TabApp: + def __init__(self, root): + self.root = root + self.theme_manager = ThemeManager(root) + self.theme = self.theme_manager.theme + self.editor_tabs = [ + EditorTab("Lexample/First;", "First", dex_name="classes.dex", loading=False), + EditorTab("Lexample/Second;", "Second", loading=True), + EditorTab("Lexample/Third;", "Third", error="broken", loading=False), + ] + self.active_tab_idx = 0 + self.actions = [] + + def _open_settings(self): + self.actions.append(("settings",)) + + def activate_tab(self, index): + self.active_tab_idx = index + self.actions.append(("activate", index)) + + def close_tab(self, index): + self.actions.append(("close", index)) + + def clear_tabs(self): + self.actions.append(("clear",)) + + def apply_theme_to_window(self, win): + self.theme_manager.apply_toplevel(win) + + +class _ClassStore: + class_names = ["LRoot;", "Lcom/example/First;", "Lcom/example/Second;"] + + def iter_root_packages(self): + return ["com"] + + def iter_package_classes(self, package_path): + if package_path == "": + return ["LRoot;"] + if package_path == "com": + return ["Lcom/Direct;"] + return [] + + def iter_child_packages(self, package_path): + return ["com/example"] if package_path == "com" else [] + + def iter_filtered_classes(self, keyword, _limit): + return [ + name for name in self.class_names + if keyword.lower() in name.lower() + ] + + +class TkTestCase(unittest.TestCase): + def setUp(self): + self.root = tk.Tk() + self.root.withdraw() + + def tearDown(self): + if self.root.winfo_exists(): + self.root.destroy() + + +class AppViewTests(TkTestCase): + def test_app_builds_the_existing_layout_without_starting_background_load(self): + with mock.patch.object(AscGuiApp, "_start_load"): + app = AscGuiApp(self.root, "fixture.apk", max_workers=4) + + self.assertEqual(self.root.title(), "ASC GUI Auth: MG1937 - fixture.apk") + self.assertEqual(app.status_var.get(), "Loading APK...") + self.assertEqual(tuple(app.search_type_box.cget("values")), ( + "string refs", + "type refs", + "method refs", + "field refs", + "method", + "field", + )) + self.assertEqual(str(app.search_button.cget("state")), "disabled") + self.assertEqual(str(app.search_type_box.cget("state")), "disabled") + self.assertEqual( + str(app.class_browser.manifest_button.cget("state")), + "disabled", + ) + self.assertEqual(app.editor_view.find_frame.winfo_manager(), "") + self.assertTrue(self.root.bind("")) + self.assertTrue(self.root.bind("")) + self.assertTrue(app.editor_view.text.bind("")) + + def test_runtime_theme_change_updates_app_and_editor_colors(self): + with mock.patch.object(AscGuiApp, "_start_load"): + app = AscGuiApp(self.root, "fixture.apk") + + app.set_theme("dark") + + self.assertEqual(app.theme_name_var.get(), "dark") + self.assertIs(app.theme, THEMES["dark"]) + self.assertEqual( + app.editor_view.text.cget("background"), + THEMES["dark"]["editor_bg"], + ) + self.assertEqual( + app.editor_view.text.tag_cget("member_link", "foreground"), + THEMES["dark"]["accent"], + ) + + +class TabBarTests(TkTestCase): + def setUp(self): + super().setUp() + self.root.geometry("320x180") + self.app = _TabApp(self.root) + self.bar = EditorTabBar( + self.root, + get_tabs=lambda: self.app.editor_tabs, + get_active_index=lambda: self.app.active_tab_idx, + activate_tab=self.app.activate_tab, + close_tab=self.app.close_tab, + clear_tabs=self.app.clear_tabs, + open_settings=self.app._open_settings, + get_theme=lambda: self.app.theme, + apply_theme_to_window=self.app.apply_theme_to_window, + apply_listbox_theme=self.app.theme_manager.apply_listbox, + ) + self.bar.pack(fill=tk.X) + self.root.update_idletasks() + self.bar.redraw() + + def test_overflow_keeps_fixed_buttons_and_draws_clickable_tabs(self): + self.assertEqual(str(self.bar.more_button.cget("state")), "normal") + self.assertEqual(self.bar.more_button.cget("text"), "...") + self.assertGreaterEqual(len(self.bar.tab_rects), 1) + self.assertLess(len(self.bar.tab_rects), len(self.app.editor_tabs)) + + index, x1, _x2, close_x1, close_x2 = self.bar.tab_rects[0] + self.bar._on_click(SimpleNamespace(x=x1 + 5)) + self.bar._on_click(SimpleNamespace(x=(close_x1 + close_x2) // 2)) + + self.assertEqual(self.app.actions, [ + ("activate", index), + ("close", index), + ]) + + def test_ensure_visible_and_scroll_keep_index_in_bounds(self): + self.bar.ensure_visible(2) + self.assertLessEqual(self.bar.tab_scroll_index, 2) + self.assertIn( + 2, + [item[0] for item in self.bar._visible_capacity( + self.bar.tab_scroll_index, + self.bar._available_tab_width(), + )], + ) + self.bar.scroll_tabs(100) + self.assertEqual(self.bar.tab_scroll_index, 2) + self.bar.scroll_tabs(-100) + self.assertEqual(self.bar.tab_scroll_index, 0) + + def test_tab_list_filter_preserves_original_tab_indexes_and_state(self): + self.bar.popup_filter_var = tk.StringVar(master=self.root, value="second") + self.assertEqual( + self.bar._tab_list_rows(), + ["example.Second [loading]"], + ) + self.assertEqual(self.bar.popup_visible_tab_indexes, [1]) + self.assertEqual(self.bar._tab_index_from_popup_selection(0), 1) + self.assertIsNone(self.bar._tab_index_from_popup_selection(1)) + + +class ClassTreeTests(TkTestCase): + def test_lazy_packages_filtering_and_selection_keep_existing_behavior(self): + opened = [] + browser = ClassTree( + self.root, + open_class=opened.append, + open_manifest=lambda: None, + ) + browser.pack(fill=tk.BOTH, expand=True) + browser.set_store(_ClassStore()) + + self.assertEqual(browser.info_var.get(), "Packages / Classes (3)") + self.assertTrue(browser.tree.exists("pkg:com:stub")) + self.assertTrue(browser.tree.exists("cls:LRoot;")) + + browser.tree.focus("pkg:com") + browser._on_open() + self.assertFalse(browser.tree.exists("pkg:com:stub")) + self.assertTrue(browser.tree.exists("pkg:com/example")) + self.assertTrue(browser.tree.exists("cls:Lcom/Direct;")) + + browser.tree.selection_set("cls:Lcom/Direct;") + browser._on_select() + self.assertEqual(opened, ["Lcom/Direct;"]) + + browser.filter_var.set("second") + browser.refresh() + self.assertEqual(browser.info_var.get(), "Classes (1 filtered)") + self.assertEqual( + browser.tree.get_children(), + ("cls:Lcom/example/Second;",), + ) + + +class DialogTests(TkTestCase): + def test_search_dialog_streams_filters_and_opens_selected_result(self): + opened = [] + theme_manager = ThemeManager(self.root) + dialog = SearchDialog( + self.root, + "string refs", + "token", + None, + False, + open_result=lambda row, find_type: opened.append((row, find_type)), + apply_theme=theme_manager.apply_toplevel, + ) + first = _row("first", "matched=(alpha)") + second = _row("second", "matched=(beta)") + + self.assertEqual(dialog.win.title(), "Search") + self.assertEqual(str(dialog.filter_entry.cget("state")), "disabled") + self.assertEqual(tuple(dialog.tree.cget("columns")), ( + "dex", + "class", + "method", + "matched", + )) + + dialog.append_results([first, second]) + dialog.finish({ + "results": [first, second], + "total_hits": 2, + "workers": 1, + "backend": "subprocess", + }) + dialog.filter_var.set("beta") + dialog._rebuild_filtered_rows() + dialog.tree.selection_set("0") + dialog._open_selected() + + self.assertEqual(dialog.rows, [second]) + self.assertEqual(opened, [(second, "string refs")]) + self.assertEqual(str(dialog.filter_entry.cget("state")), "normal") + dialog.win.destroy() + + def test_settings_dialog_applies_selected_theme_immediately(self): + manager = ThemeManager(self.root) + selected = [] + dialog = SettingsDialog( + self.root, + "light", + manager.names(), + selected.append, + manager.apply_toplevel, + ) + + dialog.theme_var.set("dark") + dialog._on_theme_changed() + + self.assertEqual(selected, ["dark"]) + self.assertEqual(dialog.win.title(), "Settings") + self.assertFalse(dialog.win.resizable()[0]) + self.assertFalse(dialog.win.resizable()[1]) + dialog.win.destroy() + + def test_rename_dialog_stays_open_for_invalid_name_and_closes_on_success(self): + manager = ThemeManager(self.root) + renamed = [] + dialog = RenameDialog( + self.root, + "oldName", + lambda value: renamed.append(value) or value == "newName", + manager.apply_toplevel, + ) + + dialog.name_var.set("bad-name") + dialog._save() + self.assertTrue(dialog.win.winfo_exists()) + dialog.name_var.set("newName") + dialog._save() + + self.assertEqual(renamed, ["bad-name", "newName"]) + self.assertFalse(dialog.win.winfo_exists()) + + def test_line_comment_dialog_strips_saved_text_and_delete_returns_empty(self): + manager = ThemeManager(self.root) + saved = [] + dialog = LineCommentDialog( + self.root, + 3, + " call();", + "old", + saved.append, + manager.apply_toplevel, + ) + dialog.comment_var.set(" revised ") + dialog._save() + self.assertEqual(saved, ["revised"]) + + dialog = LineCommentDialog( + self.root, + 3, + " call();", + "old", + saved.append, + manager.apply_toplevel, + ) + dialog._delete() + self.assertEqual(saved, ["revised", ""]) + + +class ThemeTests(TkTestCase): + def test_unknown_theme_falls_back_and_widget_helpers_apply_colors(self): + manager = ThemeManager(self.root, "missing") + text = tk.Text(self.root) + listbox = tk.Listbox(self.root) + + manager.apply_text(text) + manager.apply_listbox(listbox) + + self.assertEqual(manager.name, "light") + self.assertEqual(manager.names(), ("light", "dark")) + self.assertEqual(text.cget("background"), THEMES["light"]["editor_bg"]) + self.assertEqual(listbox.cget("selectbackground"), THEMES["light"]["accent"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_source_edit.py b/tests/test_source_edit.py index e625681..8a605f7 100644 --- a/tests/test_source_edit.py +++ b/tests/test_source_edit.py @@ -1,16 +1,17 @@ import unittest from types import SimpleNamespace -from droidasc.asc_client.gui.app import AscGuiApp -from droidasc.asc_client.gui.runtime import build_find_query -from droidasc.asc_client.gui.text_utils import decode_java_unicode_escapes_with_ranges -from droidasc.asc_client.gui.widgets import EditorTab -from droidasc.asc_client.gui.source_edit import ( +from droidasc.asc_client.gui.backend.search import build_find_query +from droidasc.asc_client.gui.editor.controller import EditorController +from droidasc.asc_client.gui.editor.source import ( + decode_java_unicode_escapes_with_ranges, find_member_declaration, linkable_member_spans, member_reference_at_offset, remap_ranges_after_replacements, ) +from droidasc.asc_client.gui.editor.view import EditorView +from droidasc.asc_client.gui.state import EditorTab from droidasc.asc_core.findrefs.findrefs_manager import FindRefManager from droidasc.asc_core.utils.decompiler import _source_with_member_references from droidasc.asc_core.utils.tinydex import DEX @@ -77,7 +78,7 @@ def _text_index(text, offset): return f"{line}.{offset - line_start}" -def _make_app(source, references, offset): +def _make_controller(source, references, offset): tab = EditorTab( dalvik_class="Lexample/Test;", title="Test", @@ -86,19 +87,19 @@ def _make_app(source, references, offset): member_references=references, ) started = [] - app = SimpleNamespace( - _active_tab=lambda: tab, - source_text=SimpleNamespace(index=lambda _mark: _text_index(source, offset)), - search_type_var=_Value(), - search_value_var=_Value(), - search_class_var=_Value(), - fuzzy_class_var=_Value(True), - status_var=_Value(), - _on_search_type_changed=lambda: None, - _start_search=lambda **kwargs: started.append(kwargs), + view = SimpleNamespace( + text=SimpleNamespace(index=lambda _mark: _text_index(source, offset)), ) - app._member_at_index = lambda index: AscGuiApp._member_at_index(app, index) - return app, started + view.member_at_index = lambda active_tab, index: EditorView.member_at_index( + view, active_tab, index + ) + controller = SimpleNamespace( + active_tab=lambda: tab, + view=view, + set_status=_Value().set, + start_reference_search=lambda *args: started.append(args), + ) + return controller, started class SourceEditTests(unittest.TestCase): @@ -106,16 +107,16 @@ def test_ctrl_f_clears_member_underlines_before_moving_focus(self): calls = [] frame = _FindWidget() entry = _FindWidget() - app = SimpleNamespace( - _hide_member_links=lambda: calls.append("hide-links"), - editor_find_frame=frame, - editor_find_entry=entry, - editor_find_var=_Value("needle"), - _last_editor_find_text="", - _refresh_editor_find_marks=lambda reset_cursor: calls.append(("refresh", reset_cursor)), + view = SimpleNamespace( + hide_member_links=lambda: calls.append("hide-links"), + find_frame=frame, + find_entry=entry, + find_var=_Value("needle"), + _last_find_text="", + refresh_find_marks=lambda reset_cursor: calls.append(("refresh", reset_cursor)), ) - result = AscGuiApp._show_editor_find(app) + result = EditorView.show_find(view) self.assertEqual(result, "break") self.assertEqual(calls[0], "hide-links") @@ -135,7 +136,7 @@ def test_rendered_member_ranges_follow_inserted_line_comments(self): ], ) - rendered, _comment_spans = AscGuiApp._render_tab_source(SimpleNamespace(), tab) + rendered, _comment_spans = EditorView.render_source(SimpleNamespace(), tab) self.assertEqual( rendered[tab.rendered_member_references[0][0]:tab.rendered_member_references[0][1]], @@ -146,25 +147,58 @@ def test_rendered_member_ranges_follow_inserted_line_comments(self): "second", ) + def test_line_comment_restores_cursor_to_commented_line(self): + tab = EditorTab( + dalvik_class="Lexample/Test;", + title="Test", + source="first\nsecond\n", + loading=False, + ) + shown = [] + status = _Value() + view = SimpleNamespace( + remember=lambda active_tab: setattr(active_tab, "insert_index", "9.9"), + show_tab=lambda active_tab, classes: shown.append((active_tab, classes)), + ) + controller = SimpleNamespace( + view=view, + _available_classes=lambda: (), + set_status=status.set, + ) + + EditorController.set_line_comment(controller, tab, 2, "check") + + self.assertEqual(tab.comments, {2: "check"}) + self.assertEqual(tab.insert_index, "2.0") + self.assertEqual(shown, [(tab, ())]) + self.assertEqual(status.get(), "Updated comment on line 2") + def test_ctrl_click_passes_exact_target_to_navigation(self): target = (4, 8, "method", "Lexample/Target;", "call", "(I)V", False) opened = [] completed = [] - app = SimpleNamespace( - source_text=_NavigationText("1.5"), - _member_at_index=lambda _index: target, - store=SimpleNamespace(class_to_dex={"Lexample/Target;": "classes.dex"}), - status_var=_Value(), - _pending_member_navigation=None, + controller = SimpleNamespace( + view=SimpleNamespace( + text=_NavigationText("1.5"), + member_at_index=lambda _tab, _index: target, + ), + active_tab=lambda: None, + _store=lambda: SimpleNamespace( + class_to_dex={"Lexample/Target;": "classes.dex"} + ), + set_status=_Value().set, + pending_navigation=None, open_class=lambda class_name: opened.append(class_name), - _complete_pending_member_navigation=lambda class_name: completed.append(class_name), + complete_pending_navigation=lambda class_name: completed.append(class_name), ) - result = AscGuiApp._open_member_from_click(app, SimpleNamespace(x=10, y=20)) + result = EditorController._open_member_from_click( + controller, SimpleNamespace(x=10, y=20) + ) self.assertEqual(result, "break") self.assertEqual( - app._pending_member_navigation, + controller.pending_navigation, ("Lexample/Target;", "method", "call", "(I)V"), ) self.assertEqual(opened, ["Lexample/Target;"] ) @@ -178,19 +212,27 @@ def test_pending_navigation_moves_to_exact_declaration(self): loading=False, rendered_member_references=[wrong, target], ) text = _NavigationText() - app = SimpleNamespace( - _pending_member_navigation=("Lexample/Target;", "method", "run", "(I)V"), - editor_tabs=[tab], - _find_tab_index=lambda _class_name: 0, - source_text=text, - _highlight_active_line=lambda: None, - _highlight_related_identifier=lambda _index: None, - status_var=_Value(), + controller = SimpleNamespace( + pending_navigation=("Lexample/Target;", "method", "run", "(I)V"), + tabs=[tab], + find_tab_index=lambda _class_name: 0, + view=SimpleNamespace( + text=text, + highlight_active_line=lambda: None, + highlight_related_identifier=lambda _tab, _index: None, + ), + set_status=_Value().set, + ) + controller._move_to_reference = lambda active_tab, reference: ( + EditorController._move_to_reference(controller, active_tab, reference) + ) + controller._complete_search_navigation = lambda active_tab, target: ( + EditorController._complete_search_navigation(controller, active_tab, target) ) - AscGuiApp._complete_pending_member_navigation(app, "Lexample/Target;") + EditorController.complete_pending_navigation(controller, "Lexample/Target;") - self.assertIsNone(app._pending_member_navigation) + self.assertIsNone(controller.pending_navigation) self.assertEqual(text.insert, "1.0+40c") self.assertEqual(text.seen, ["1.0+40c"] ) self.assertEqual(tab.insert_index, "1.0+40c") @@ -206,17 +248,17 @@ def test_ctrl_underline_marks_only_linkable_member_ranges(self): ) applied = [] text = _NavigationText() - app = SimpleNamespace( - _ctrl_member_links_visible=False, - source_text=text, - _active_tab=lambda: tab, - store=SimpleNamespace(class_to_dex={"Llocal/Target;": "classes.dex"}), - _tag_ranges=lambda name, spans: applied.append((name, spans)), + view = SimpleNamespace( + member_links_visible=False, + text=text, + tag_ranges=lambda name, spans: applied.append((name, spans)), ) - AscGuiApp._show_member_links(app) + EditorView.show_member_links( + view, tab, {"Llocal/Target;": "classes.dex"} + ) - self.assertTrue(app._ctrl_member_links_visible) + self.assertTrue(view.member_links_visible) self.assertEqual(applied, [("member_link", [(1, 4)])]) self.assertIn(("raise", "member_link"), text.tags) @@ -361,60 +403,57 @@ def test_source_metadata_uses_declaration_and_invoke_classes(self): def test_x_on_method_declaration_searches_current_class(self): source = "public void run()\n{\n return;\n}\n" start = source.index("run") - app, started = _make_app( + controller, started = _make_controller( source, [(start, start + 3, "method", "Lexample/Test;", "run", "()V", True)], start + 1 ) - result = AscGuiApp._find_current_member_references(app) + result = EditorController.find_current_member_references(controller) self.assertEqual(result, "break") - self.assertEqual(app.search_value_var.get(), "run") - self.assertEqual(app.search_class_var.get(), "Lexample/Test;") - self.assertEqual(started, [{"exact_member": True}]) + self.assertEqual(started, [("method", "run", "Lexample/Test;")]) def test_x_on_invocation_uses_invoked_method_class(self): source = "public void run() { this.a.callToMethod(); }" start = source.index("callToMethod") - app, started = _make_app( + controller, started = _make_controller( source, [(start, start + len("callToMethod"), "method", "Lexample/Target;", "callToMethod", "()V", False)], start + 2, ) - AscGuiApp._find_current_member_references(app) + EditorController.find_current_member_references(controller) - self.assertEqual(app.search_value_var.get(), "callToMethod") - self.assertEqual(app.search_class_var.get(), "Lexample/Target;") - self.assertEqual(started, [{"exact_member": True}]) + self.assertEqual(started, [ + ("method", "callToMethod", "Lexample/Target;"), + ]) def test_x_on_field_access_uses_declaring_field_class(self): source = "public void run() { this.a = target.value; }" start = source.index("value") - app, started = _make_app( + controller, started = _make_controller( source, [(start, start + len("value"), "field", "Lexample/Target;", "value", "I", False)], start + 2, ) - AscGuiApp._find_current_member_references(app) + EditorController.find_current_member_references(controller) - self.assertEqual(app.search_type_var.get(), "field refs") - self.assertEqual(app.search_value_var.get(), "value") - self.assertEqual(app.search_class_var.get(), "Lexample/Target;") - self.assertEqual(started, [{"exact_member": True}]) + self.assertEqual(started, [("field", "value", "Lexample/Target;")]) def test_x_inside_method_but_not_on_method_name_does_not_search(self): source = "public void run()\n{\n return;\n}\n" start = source.index("run") - app, started = _make_app( + status = _Value() + controller, started = _make_controller( source, [(start, start + 3, "method", "Lexample/Test;", "run", "()V", True)], source.index("return") ) + controller.set_status = status.set - AscGuiApp._find_current_member_references(app) + EditorController.find_current_member_references(controller) self.assertEqual(started, []) self.assertEqual( - app.status_var.get(), + status.get(), "Place the cursor on a method or field name to find its references", )