diff --git a/droidasc/asc_client/gui/app.py b/droidasc/asc_client/gui/app.py index 0979f04..6ad8fe7 100644 --- a/droidasc/asc_client/gui/app.py +++ b/droidasc/asc_client/gui/app.py @@ -458,6 +458,7 @@ def _bind_editor_shortcuts(self): 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) @@ -1318,6 +1319,7 @@ def apply_chunk(offset : int = 0): 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) diff --git a/droidasc/asc_core/utils/tinydex.py b/droidasc/asc_core/utils/tinydex.py index e261be8..06aaca8 100644 --- a/droidasc/asc_core/utils/tinydex.py +++ b/droidasc/asc_core/utils/tinydex.py @@ -9,6 +9,18 @@ _STRUCT_HHI = struct.Struct(' str: + raw = raw.replace(b'\xc0\x80', b'\x00') + try: + return ( + raw.decode('utf-8', errors='surrogatepass') + .encode('utf-16-le', errors='surrogatepass') + .decode('utf-16-le', errors='replace') + ) + except (UnicodeDecodeError, UnicodeEncodeError): + return raw.decode('utf-8', errors='replace') + + class DEXHeader: def __init__(self, buf): # (off, size) @@ -348,12 +360,31 @@ def get_string(self, str_idx): return self._strings[str_idx] str_idx_off = self.header.strings[0] - str_size = self.header.strings[1] string_off = _STRUCT_I.unpack_from(self.buf, str_idx_off + str_idx * 4)[0] utf16_size, c = read_uleb128_fast(self.buf, string_off) data_start = string_off + c end = data_start + utf16_size - s = bytes(self.buf[data_start:end]).decode('utf-8', errors='replace') + # ASCII keeps the old O(1) boundary calculation; MUTF-8 needs its terminator. + # refer to https://github.com/MG1937/ASC/issues/33 + # 20260923 DEX.get_string slices utf16_size bytes out of a string_data_item, but that field counts UTF-16 code units + if end >= len(self.buf) or self.buf[end] != 0: + raw_buf = self.buf.obj + if hasattr(raw_buf, 'find') and len(raw_buf) == len(self.buf): + end = raw_buf.find(b'\x00', data_start, len(self.buf)) + else: + end = data_start + while end < len(self.buf) and self.buf[end] != 0: + end += 1 + if end == len(self.buf): + end = -1 + if end < 0: + raise ValueError("unterminated string_data_item") + + raw = bytes(self.buf[data_start:end]) + try: + s = raw.decode('utf-8') + except UnicodeDecodeError: + s = _decode_mutf8_fallback(raw) self._strings[str_idx] = s return s @@ -518,6 +549,14 @@ def get_class(self, fullname): else: right = mid - 1 + if type_idx == -1: + # DEX sorts by UTF-16 code units, while Python compares code points. + for mid in range(type_ids_size): + desc_idx = _STRUCT_I.unpack_from(raw_bytes, type_ids_off + mid * 0x4)[0] + if self.get_string(desc_idx) == fullname: + type_idx = mid + break + if type_idx == -1: return None diff --git a/pyproject.toml b/pyproject.toml index 9facea4..368b46c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "droidasc" -version = "0.1.1.post1" +version = "0.1.1.post2" description = "ASC is a super FAST python Android Decompiler for Agents/Mobile Researchers" readme = "README.md" license = "Apache-2.0" @@ -21,4 +21,4 @@ droidasc = "droidasc:main" include = ["droidasc", "droidasc.*"] [tool.setuptools.package-data] -"droidasc" = ["py.typed"] \ No newline at end of file +"droidasc" = ["py.typed"] diff --git a/tests/benchmark_compare.py b/tests/benchmark_compare.py index 4022586..b409118 100644 --- a/tests/benchmark_compare.py +++ b/tests/benchmark_compare.py @@ -39,6 +39,16 @@ def revision(root): return subprocess.check_output(['git', '-C', str(root), 'rev-parse', 'HEAD'], text=True).strip() +def _stable_cli_findrefs_answer(text): + # String decoding fixes may intentionally change matched payload text while + # preserving the referenced DEX/method rows and their count. + return sorted( + line.split(' | matched=', 1)[0] + for line in text.splitlines() + if ' | ' in line + ) + + def _wrap_workload_test(root, directory, test_name): """Wrap a workload test file so old top-level imports (findrefs, utils, core, models) are rewritten to droidasc.asc_core.*, matching the current package structure.""" @@ -95,8 +105,11 @@ def measure(root, directory, case, output, sample): if match is None: raise ValueError('missing CLI timing') times = {case: float(match[1])} - answer = (result.stdout.split('-' * 50)[-1].strip() if case == 'cli_getclass' else - sorted(line for line in result.stdout.splitlines() if ' | ' in line)) + answer = ( + result.stdout.split('-' * 50)[-1].strip() + if case == 'cli_getclass' + else _stable_cli_findrefs_answer(result.stdout) + ) if not answer or (case in ('core', 'cli_getclass') and 'class ClockFaceView' not in answer): raise ValueError(f'{case}: missing result') return times, answer diff --git a/tests/test_mutf8_strings.py b/tests/test_mutf8_strings.py new file mode 100644 index 0000000..4fe6b3c --- /dev/null +++ b/tests/test_mutf8_strings.py @@ -0,0 +1,143 @@ +import base64 +import struct +import unittest + +from droidasc.asc_core.utils.tinydex import DEX, _decode_mutf8_fallback + + +ISSUE_DEX = base64.b64decode( + "ZGV4CjAzNQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADtAAAAcAAAAHhWNBIAAAAAAAAA" + "AAAAAAADAAAAcAAAAAMAAAB8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAIgA" + "AAAAAAAAAAAAAMgAAADcAAAA5AAAAAAAAAABAAAAAgAAAAEAAAABAAAAAAAAAAAAAAD/" + "////AAAAAAAAAAAAAAAAAgAAAAEAAAAAAAAAAAAAAP////8AAAAAAAAAAAAAAAASTGph" + "dmEvbGFuZy9PYmplY3Q7AAVMbC/boTsABUxsL+GpuzsA" +) + + +def _uleb128(value): + out = bytearray() + while value > 0x7f: + out.append((value & 0x7f) | 0x80) + value >>= 7 + out.append(value) + return out + + +def _encode_mutf8(text): + out = bytearray() + utf16 = text.encode("utf-16-be") + for offset in range(0, len(utf16), 2): + unit = int.from_bytes(utf16[offset:offset + 2], "big") + if unit == 0: + out.extend(b"\xc0\x80") + elif unit <= 0x7f: + out.append(unit) + elif unit <= 0x7ff: + out.extend((0xc0 | (unit >> 6), 0x80 | (unit & 0x3f))) + else: + out.extend(( + 0xe0 | (unit >> 12), + 0x80 | ((unit >> 6) & 0x3f), + 0x80 | (unit & 0x3f), + )) + return bytes(out) + + +def _make_class_dex(names): + encoded = [_encode_mutf8(name) for name in names] + units = [len(name.encode("utf-16-le")) // 2 for name in names] + buf = bytearray(0x70) + + string_ids_off = len(buf) + buf.extend(bytes(4 * len(names))) + type_ids_off = len(buf) + buf.extend(bytes(4 * len(names))) + class_defs_off = len(buf) + buf.extend(bytes(32 * len(names))) + data_off = len(buf) + + for index, (raw, utf16_size) in enumerate(zip(encoded, units)): + struct.pack_into("run | matched=(full payload)' + ), + ) + def test_consistent_small_regression_is_reported_but_not_gated(self): result = compare_pairs([100.] * 31, [100.1] * 31, 26) self.assertTrue(result['statistically_significant']) diff --git a/tests/test_source_edit.py b/tests/test_source_edit.py index 95057ee..ab0e08d 100644 --- a/tests/test_source_edit.py +++ b/tests/test_source_edit.py @@ -57,6 +57,20 @@ def tag_raise(self, name): self.tags.append(("raise", name)) +class _FindWidget: + def __init__(self): + self.calls = [] + + def grid(self): + self.calls.append("grid") + + def focus_set(self): + self.calls.append("focus") + + def selection_range(self, start, end): + self.calls.append(("selection", start, end)) + + def _text_index(text, offset): line = text.count("\n", 0, offset) + 1 line_start = text.rfind("\n", 0, offset) + 1 @@ -88,6 +102,26 @@ def _make_app(source, references, offset): class SourceEditTests(unittest.TestCase): + 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)), + ) + + result = AscGuiApp._show_editor_find(app) + + self.assertEqual(result, "break") + self.assertEqual(calls[0], "hide-links") + self.assertEqual(frame.calls, ["grid"] ) + self.assertEqual(entry.calls[0], "focus") + def test_rendered_member_ranges_follow_inserted_line_comments(self): source = "void first() {}\nvoid second() {}\n" first = source.index("first")