Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 42 additions & 12 deletions src/cache/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1163,26 +1163,56 @@ fn resolve_no_line_frames<'a>(
return resolve_base_entries(cache, frame, &base_entries, outer_source_file);
}

// No base entries — fall back to all entries with output line 0.
// No base entries — check if the first range group forms an inline group
// (multiple entries sharing the same startline/endline). If so, resolve
// that group with proper output lines. Otherwise, fall back to emitting
// a single frame with line 0 (ambiguous non-inline case).
//
// This matches retrace's `allRangesForLine(0, true)` which picks the first
// range containing line 0 and returns all entries in that range group.
// Whether this is intentional retrace behavior or accidental is debatable,
// but we match it because users compare our output against retrace-based tools.
let mut frames = Vec::new();
if let Some(first) = members.first() {
let all_same = members.iter().all(|m| {
m.original_class_offset == first.original_class_offset
&& m.original_name_offset == first.original_name_offset
});
if all_same {
if let Some(f) =
map_member_without_lines(cache, frame, first, outer_source_file, Some(0))
{
frames.push(f);
let first_start = first.startline();
let first_end = first.endline();
let first_group: Vec<_> = members
.iter()
.take_while(|m| m.startline() == first_start && m.endline() == first_end)
.collect();

if first_group.len() > 1 {
// Inline group: multiple entries share the same range.
// Resolve each with its proper original line.
for member in &first_group {
let line = compute_member_output_line(member).or(Some(0));
if let Some(f) =
map_member_without_lines(cache, frame, member, outer_source_file, line)
{
frames.push(f);
}
}
} else {
for member in members {
// Ambiguous: each entry has a different range. Collapse to one
// frame with line 0, matching retrace behavior.
let all_same = members.iter().all(|m| {
m.original_class_offset == first.original_class_offset
&& m.original_name_offset == first.original_name_offset
});
if all_same {
if let Some(f) =
map_member_without_lines(cache, frame, member, outer_source_file, Some(0))
map_member_without_lines(cache, frame, first, outer_source_file, Some(0))
{
frames.push(f);
}
} else {
for member in members {
if let Some(f) =
map_member_without_lines(cache, frame, member, outer_source_file, Some(0))
{
frames.push(f);
}
}
}
}
}
Expand Down
50 changes: 40 additions & 10 deletions src/mapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,23 +318,53 @@ fn resolve_no_line_frames<'s>(
return;
}

// No base entries — fall back to all entries with output line 0.
// No base entries — check if the first range group forms an inline group
// (multiple entries sharing the same startline/endline). If so, resolve
// that group with proper output lines. Otherwise, fall back to emitting
// a single frame with line 0 (ambiguous non-inline case).
//
// This matches retrace's `allRangesForLine(0, true)` which picks the first
// range containing line 0 and returns all entries in that range group.
// Whether this is intentional retrace behavior or accidental is debatable,
// but we match it because users compare our output against retrace-based tools.
let Some(first) = mapping_entries.first() else {
return;
};
let unambiguous = mapping_entries.iter().all(|m| m.original == first.original);
if unambiguous {
collected
.frames
.push(map_member_without_lines(frame, first, Some(0)));
collected.rewrite_rules.extend(first.rewrite_rules.iter());
} else {
for member in mapping_entries {

let first_start = first.startline;
let first_end = first.endline;
let first_group: Vec<_> = mapping_entries
.iter()
.take_while(|m| m.startline == first_start && m.endline == first_end)
.collect();

if first_group.len() > 1 {
// Inline group: multiple entries share the same range.
// Resolve each with its proper original line.
for member in &first_group {
let line = member.original_startline.filter(|&v| v > 0).or(Some(0));
collected
.frames
.push(map_member_without_lines(frame, member, Some(0)));
.push(map_member_without_lines(frame, member, line));
collected.rewrite_rules.extend(member.rewrite_rules.iter());
}
} else {
// Ambiguous: each entry has a different range. Collapse to one
// frame with line 0, matching retrace behavior.
let unambiguous = mapping_entries.iter().all(|m| m.original == first.original);
if unambiguous {
collected
.frames
.push(map_member_without_lines(frame, first, Some(0)));
collected.rewrite_rules.extend(first.rewrite_rules.iter());
} else {
for member in mapping_entries {
collected
.frames
.push(map_member_without_lines(frame, member, Some(0)));
collected.rewrite_rules.extend(member.rewrite_rules.iter());
}
}
}
}

Expand Down
55 changes: 55 additions & 0 deletions tests/r8.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ static MAPPING_OUTLINE: &[u8] = include_bytes!("res/mapping-outline.txt");
static MAPPING_OUTLINE_COMPLEX: &[u8] = include_bytes!("res/mapping-outline-complex.txt");
static MAPPING_REWRITE_COMPLEX: &str = include_str!("res/mapping-rewrite-complex.txt");
static MAPPING_ZERO_LINE_INFO: &[u8] = include_bytes!("res/mapping-zero-line-info.txt");
static MAPPING_INLINE_NO_BASE: &str = include_str!("res/mapping-inline-no-base.txt");

static MAPPING_WIN_R8: LazyLock<Vec<u8>> = LazyLock::new(|| {
MAPPING_R8
Expand Down Expand Up @@ -577,3 +578,57 @@ fn test_method_with_zero_zero_and_line_specific_mappings_cache() {
assert_eq!(remapped_frame.line(), Some(70));
assert_eq!(mapped.next(), None);
}

#[test]
fn test_inline_group_no_base_entries() {
// Regression test: when a frame has no line number and all mapping entries
// have non-zero endline (no base entries), the first range group should be
// detected as an inline chain and each entry should get its proper original
// line number instead of all being collapsed to line 0.
let mapper = ProguardMapper::new(ProguardMapping::new(MAPPING_INLINE_NO_BASE.as_bytes()));

let test = mapper.remap_stacktrace(
r#"
java.lang.RuntimeException: Crash
at a.b.onClick(SourceFile:0)"#,
);

assert_eq!(
test.unwrap().trim(),
r#"java.lang.RuntimeException: Crash
at com.example.app.MainActivity.innerCall(MainActivity.kt:54)
at com.example.app.MainActivity.middleCall(MainActivity.kt:44)
at com.example.app.MainActivity.onClick(MainActivity.kt:30)"#
.trim()
);
}

#[test]
fn test_inline_group_no_base_entries_cache() {
// Same regression test as above, but using ProguardCache.
let mapping = ProguardMapping::new(MAPPING_INLINE_NO_BASE.as_bytes());
let mut buf = Vec::new();
ProguardCache::write(&mapping, &mut buf).unwrap();
let cache = ProguardCache::parse(&buf).unwrap();
cache.test();

let frame = StackFrame::new("a.b", "onClick", 0);
let mut mapped = cache.remap_frame(&frame);

let frame1 = mapped.next().unwrap();
assert_eq!(frame1.class(), "com.example.app.MainActivity");
assert_eq!(frame1.method(), "innerCall");
assert_eq!(frame1.line(), Some(54));

let frame2 = mapped.next().unwrap();
assert_eq!(frame2.class(), "com.example.app.MainActivity");
assert_eq!(frame2.method(), "middleCall");
assert_eq!(frame2.line(), Some(44));

let frame3 = mapped.next().unwrap();
assert_eq!(frame3.class(), "com.example.app.MainActivity");
assert_eq!(frame3.method(), "onClick");
assert_eq!(frame3.line(), Some(30));

assert_eq!(mapped.next(), None);
}
11 changes: 11 additions & 0 deletions tests/res/mapping-inline-no-base.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# compiler: R8
# compiler_version: 8.6.17
# min_api: 21
# pg_map_id: abc123
# common_typos_disable
com.example.app.MainActivity -> a.b:
# {"id":"sourceFile","fileName":"MainActivity.kt"}
1:1:void innerCall():54:54 -> onClick
1:1:void middleCall():44 -> onClick
1:1:void onClick(android.view.View):30 -> onClick
2:5:void otherMethod():100:100 -> otherMethod
Loading