Skip to content

Commit dde36d9

Browse files
authored
Merge pull request #741 from yhs0602/codex/large-file-stability
stability: reduce string-search pressure on large files
2 parents 74c77c1 + c6010d2 commit dde36d9

23 files changed

Lines changed: 571 additions & 203 deletions

app/src/androidTest/java/com/kyhsgeekcode/disassembler/DocumentIntentFixtures.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ fun createCreateDocumentResult(
9191
return outputFile to Instrumentation.ActivityResult(Activity.RESULT_OK, resultIntent)
9292
}
9393

94+
fun createCanceledActivityResult(): Instrumentation.ActivityResult {
95+
return Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null)
96+
}
97+
9498
fun createAdvancedImportResultForFile(
9599
file: File,
96100
openProject: Boolean = false,

app/src/androidTest/java/com/kyhsgeekcode/disassembler/MainActivityAdvancedImportFlowTest.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,37 @@ class MainActivityAdvancedImportFlowTest {
6767
}
6868
}
6969

70+
@Test
71+
fun advancedImportResult_copyNo_opensProjectAndSurvivesRecreate() {
72+
val context = ApplicationProvider.getApplicationContext<Context>()
73+
val importedFile = context.filesDir.resolve("androidTest/advanced/sample-no-copy.so")
74+
importedFile.parentFile?.mkdirs()
75+
importedFile.writeBytes("so-data".encodeToByteArray())
76+
77+
intending(
78+
hasComponent(
79+
ComponentName(
80+
composeRule.activity,
81+
NewFileChooserActivity::class.java
82+
)
83+
)
84+
).respondWith(
85+
createAdvancedImportResultForFile(
86+
file = importedFile,
87+
openProject = false,
88+
projectType = ProjectType.UNKNOWN
89+
)
90+
)
91+
92+
composeRule.onNodeWithTag(MainTestTags.IMPORT_ADVANCED_BUTTON).performClick()
93+
composeRule.onNodeWithTag(MainTestTags.COPY_DIALOG_NO_BUTTON).performClick()
94+
95+
waitForProjectOpen()
96+
composeRule.activityRule.scenario.recreate()
97+
waitForProjectOpen()
98+
composeRule.onNodeWithTag(MainTestTags.EXPORT_PROJECT_BUTTON).assertExists()
99+
}
100+
70101
@Test
71102
fun advancedImportOpenProjectResult_importsProjectArchive() {
72103
val archiveFile = createProjectArchiveFixture()
@@ -93,4 +124,11 @@ class MainActivityAdvancedImportFlowTest {
93124
.fetchSemanticsNodes().isNotEmpty()
94125
}
95126
}
127+
128+
private fun waitForProjectOpen() {
129+
composeRule.waitUntil(timeoutMillis = PROJECT_OPEN_TIMEOUT_MS) {
130+
composeRule.onAllNodesWithTag(MainTestTags.EXPORT_PROJECT_BUTTON)
131+
.fetchSemanticsNodes().isNotEmpty()
132+
}
133+
}
96134
}

app/src/androidTest/java/com/kyhsgeekcode/disassembler/MainActivityExtraStreamIntentFlowTest.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ class MainActivityExtraStreamIntentFlowTest {
4242
composeRule.onNodeWithTag(MainTestTags.EXPORT_PROJECT_BUTTON).assertExists()
4343
}
4444

45+
@Test
46+
fun extraStreamContentUri_survivesRecreate() {
47+
waitForProjectOpen()
48+
composeRule.activityRule.scenario.recreate()
49+
waitForProjectOpen()
50+
composeRule.onNodeWithTag(MainTestTags.EXPORT_PROJECT_BUTTON).assertExists()
51+
}
52+
4553
private fun waitForProjectOpen() {
4654
composeRule.waitUntil(timeoutMillis = PROJECT_OPEN_TIMEOUT_MS) {
4755
composeRule.onAllNodesWithTag(MainTestTags.EXPORT_PROJECT_BUTTON)

app/src/androidTest/java/com/kyhsgeekcode/disassembler/MainActivityProjectExportFlowTest.kt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,37 @@ class MainActivityProjectExportFlowTest {
6363
assertTrue(outputFile.readBytes().size > 4)
6464
assertTrue(outputFile.readText(Charsets.ISO_8859_1).startsWith("PK"))
6565
}
66+
67+
@Test
68+
fun exportProjectCancel_keepsProjectOpen() {
69+
intending(
70+
allOf(
71+
hasAction(Intent.ACTION_OPEN_DOCUMENT)
72+
)
73+
).respondWith(
74+
createOpenDocumentResult(
75+
displayName = "export-cancel-source.apk",
76+
content = "export-content".encodeToByteArray()
77+
)
78+
)
79+
intending(
80+
allOf(
81+
hasAction(Intent.ACTION_CREATE_DOCUMENT)
82+
)
83+
).respondWith(createCanceledActivityResult())
84+
85+
composeRule.onNodeWithTag(MainTestTags.IMPORT_SAF_BUTTON).performClick()
86+
waitForProjectOpen()
87+
88+
composeRule.onNodeWithTag(MainTestTags.EXPORT_PROJECT_BUTTON).performClick()
89+
composeRule.waitForIdle()
90+
composeRule.onNodeWithTag(MainTestTags.EXPORT_PROJECT_BUTTON).assertExists()
91+
}
92+
93+
private fun waitForProjectOpen() {
94+
composeRule.waitUntil(timeoutMillis = 5_000) {
95+
composeRule.onAllNodesWithTag(MainTestTags.EXPORT_PROJECT_BUTTON)
96+
.fetchSemanticsNodes().isNotEmpty()
97+
}
98+
}
6699
}

app/src/androidTest/java/com/kyhsgeekcode/disassembler/MainActivitySafImportFlowTest.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,32 @@ class MainActivitySafImportFlowTest {
5050

5151
composeRule.onNodeWithTag(MainTestTags.EXPORT_PROJECT_BUTTON).assertExists()
5252
}
53+
54+
@Test
55+
fun safImportResult_survivesRecreate() {
56+
intending(
57+
allOf(
58+
hasAction(Intent.ACTION_OPEN_DOCUMENT)
59+
)
60+
).respondWith(
61+
createOpenDocumentResult(
62+
displayName = "sample-recreate.apk",
63+
content = "apk-content".encodeToByteArray()
64+
)
65+
)
66+
67+
composeRule.onNodeWithTag(MainTestTags.IMPORT_SAF_BUTTON).performClick()
68+
69+
waitForProjectOpen()
70+
composeRule.activityRule.scenario.recreate()
71+
waitForProjectOpen()
72+
composeRule.onNodeWithTag(MainTestTags.EXPORT_PROJECT_BUTTON).assertExists()
73+
}
74+
75+
private fun waitForProjectOpen() {
76+
composeRule.waitUntil(timeoutMillis = 5_000) {
77+
composeRule.onAllNodesWithTag(MainTestTags.EXPORT_PROJECT_BUTTON)
78+
.fetchSemanticsNodes().isNotEmpty()
79+
}
80+
}
5381
}

app/src/main/java/com/kyhsgeekcode/disassembler/Analyzer.kt

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import kotlin.math.exp
1515
import kotlin.math.ln
1616
import kotlin.math.pow
1717

18+
private const val MAX_EMITTED_FOUND_STRING_CHARS = 4_096
1819

1920
@ExperimentalUnsignedTypes
2021
class Analyzer(private val bytes: ByteArray) {
@@ -50,7 +51,18 @@ class Analyzer(private val bytes: ByteArray) {
5051
val length = i - strstart
5152
val offset = strstart
5253
if (length in min..max) {
53-
val str = String(bytes, strstart, length)
54+
val previewLength = minOf(length, MAX_EMITTED_FOUND_STRING_CHARS)
55+
val str = String(bytes, strstart, previewLength).let {
56+
if (length > MAX_EMITTED_FOUND_STRING_CHARS) {
57+
if (previewLength <= 3) {
58+
it.take(previewLength)
59+
} else {
60+
it.take(previewLength - 3) + "..."
61+
}
62+
} else {
63+
it
64+
}
65+
}
5466
val fs = FoundString(length, offset.toLong(), str)
5567
// Log.v(TAG,str);
5668
progress(i, bytes.size, fs)
@@ -400,4 +412,4 @@ fun Simpson3_8(f: (Double) -> Double, a: Double, b: Double, N: Int, gamma: Doubl
400412

401413
private fun log2(a: Double): Double {
402414
return ln(a) / ln(2.0)
403-
}
415+
}

app/src/main/java/com/kyhsgeekcode/disassembler/project/ProjectDataStorage.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ object ProjectDataStorage {
146146
val key = Pair(keykey, DataType.FileContent)
147147
data[key] = datadata
148148
}
149+
150+
fun clear() {
151+
data.clear()
152+
}
149153
}
150154

151155
internal const val MAX_CACHED_FILE_CONTENT_BYTES = 8L * 1024 * 1024

app/src/main/java/com/kyhsgeekcode/disassembler/ui/MainPage.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,9 @@ fun MainScreen(viewModel: MainViewModel) {
7777

7878
val showSearchForStringsDialog =
7979
viewModel.showSearchForStringsDialog.collectAsState()
80-
if (showSearchForStringsDialog.value == ShowSearchForStringsDialog.Shown) {
81-
SearchForStringsDialog(viewModel)
80+
val dialogState = showSearchForStringsDialog.value
81+
if (dialogState is ShowSearchForStringsDialog.Shown) {
82+
SearchForStringsDialog(viewModel, dialogState.notice)
8283
}
8384
}
8485
}

app/src/main/java/com/kyhsgeekcode/disassembler/ui/tabs/StringTab.kt

Lines changed: 113 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,74 @@ import kotlinx.coroutines.flow.StateFlow
2727
import timber.log.Timber
2828

2929
private const val MAX_RENDERED_STRING_RESULTS = 5_000
30+
private const val MAX_RENDERED_STRING_CHARS = 4_096
31+
private const val MAX_RENDERED_STRING_TOTAL_CHARS = 32_768
32+
internal const val MAX_SEARCHED_STRING_BYTES = 4 * 1024 * 1024
33+
34+
data class StringSearchInput(
35+
val bytes: ByteArray,
36+
val originalSize: Long,
37+
val isTruncated: Boolean
38+
)
39+
40+
internal fun buildStringSearchInput(
41+
previewBytes: ByteArray,
42+
originalSize: Long,
43+
maxBytes: Int = MAX_SEARCHED_STRING_BYTES
44+
): StringSearchInput {
45+
return StringSearchInput(
46+
bytes = previewBytes,
47+
originalSize = originalSize,
48+
isTruncated = originalSize > maxBytes
49+
)
50+
}
51+
52+
internal fun buildStringSearchNotice(
53+
input: StringSearchInput,
54+
resultsTruncated: Boolean
55+
): String? {
56+
val parts = mutableListOf<String>()
57+
if (input.isTruncated) {
58+
parts += "Searching strings in first ${input.bytes.size} bytes of ${input.originalSize} bytes"
59+
}
60+
if (resultsTruncated) {
61+
parts += "Showing first $MAX_RENDERED_STRING_RESULTS results."
62+
}
63+
return when {
64+
parts.isEmpty() -> null
65+
parts.size == 1 -> parts.single()
66+
else -> parts.joinToString(". ")
67+
}
68+
}
69+
70+
internal fun buildStringSearchDialogNotice(
71+
originalSize: Long,
72+
maxBytes: Int = MAX_SEARCHED_STRING_BYTES
73+
): String? {
74+
if (originalSize <= maxBytes) {
75+
return null
76+
}
77+
return "Large file detected. String search will only scan the first $maxBytes bytes of $originalSize bytes."
78+
}
79+
80+
internal fun clipFoundStringForRendering(
81+
result: FoundString,
82+
maxChars: Int = MAX_RENDERED_STRING_CHARS
83+
): Pair<FoundString, Boolean> {
84+
require(maxChars >= 0) { "maxChars must be non-negative" }
85+
if (result.string.length <= maxChars) {
86+
return result to false
87+
}
88+
if (maxChars == 0) {
89+
return result.copy(string = "") to true
90+
}
91+
val clippedString = if (maxChars <= 3) {
92+
result.string.take(maxChars)
93+
} else {
94+
result.string.take(maxChars - 3) + "..."
95+
}
96+
return result.copy(string = clippedString) to true
97+
}
3098

3199
class StringSearchResultAccumulator(private val maxResults: Int) {
32100
private val _results = mutableListOf<FoundString>()
@@ -36,12 +104,27 @@ class StringSearchResultAccumulator(private val maxResults: Int) {
36104
var isTruncated: Boolean = false
37105
private set
38106

107+
private var renderedChars: Int = 0
108+
39109
fun append(result: FoundString) {
40110
if (_results.size >= maxResults) {
41111
isTruncated = true
42112
return
43113
}
44-
_results.add(result)
114+
val remainingChars = MAX_RENDERED_STRING_TOTAL_CHARS - renderedChars
115+
if (remainingChars <= 0) {
116+
isTruncated = true
117+
return
118+
}
119+
val (displayResult, wasClipped) = clipFoundStringForRendering(
120+
result,
121+
minOf(MAX_RENDERED_STRING_CHARS, remainingChars)
122+
)
123+
if (wasClipped) {
124+
isTruncated = true
125+
}
126+
_results.add(displayResult)
127+
renderedChars += displayResult.string.length
45128
}
46129
}
47130

@@ -52,21 +135,31 @@ class StringTabData(val data: TabKind.FoundString) : PreparedTabData() {
52135
val isDone = _isDone as StateFlow<Boolean>
53136
private val _isTruncated = MutableStateFlow(false)
54137
val isTruncated = _isTruncated as StateFlow<Boolean>
138+
private val _notice = MutableStateFlow<String?>(null)
139+
val notice = _notice as StateFlow<String?>
55140
lateinit var analyzer: Analyzer
56141
override suspend fun prepare() {
57-
val bytes = ProjectDataStorage.getFileContent(data.relPath)
142+
val fileSize = ProjectDataStorage.resolveToRead(data.relPath)?.length() ?: 0L
143+
val input = buildStringSearchInput(
144+
previewBytes = ProjectDataStorage.getFileContentPreview(
145+
data.relPath,
146+
MAX_SEARCHED_STRING_BYTES
147+
),
148+
originalSize = fileSize
149+
)
58150
Timber.d("Given relPath: ${data.relPath}")
59-
analyzer = Analyzer(bytes)
151+
analyzer = Analyzer(input.bytes)
60152
val accumulator = StringSearchResultAccumulator(MAX_RENDERED_STRING_RESULTS)
61153
analyzer.searchStrings(data.range.first, data.range.last) { p, t, fs ->
62154
fs?.let {
63155
accumulator.append(it)
64-
if (!accumulator.isTruncated) {
65-
strings.add(it)
156+
if (strings.size < accumulator.results.size) {
157+
strings.add(accumulator.results.last())
66158
}
67159
}
68160
if (p == t) { // done
69161
_isTruncated.value = accumulator.isTruncated
162+
_notice.value = buildStringSearchNotice(input, accumulator.isTruncated)
70163
_isDone.value = true
71164
}
72165
}
@@ -80,15 +173,13 @@ fun StringTab(data: TabData, viewModel: MainViewModel) {
80173
val preparedTabData: StringTabData = viewModel.getTabData(data)
81174
val strings = preparedTabData.strings
82175
val isDone = preparedTabData.isDone.collectAsState()
83-
val isTruncated = preparedTabData.isTruncated.collectAsState()
176+
val notice = preparedTabData.notice.collectAsState()
84177
Column {
85178
Row {
86179
if (!isDone.value) {
87180
Icon(imageVector = Icons.Default.MoreVert, contentDescription = "Searching...")
88181
}
89-
if (isTruncated.value) {
90-
Text("Showing first $MAX_RENDERED_STRING_RESULTS results")
91-
}
182+
notice.value?.let { Text(it) }
92183
}
93184
TableView(
94185
titles = listOf("Offset" to 100.dp, "Length" to 50.dp, "String" to 800.dp),
@@ -106,7 +197,7 @@ fun StringTab(data: TabData, viewModel: MainViewModel) {
106197
}
107198

108199
@Composable
109-
fun SearchForStringsDialog(viewModel: MainViewModel) {
200+
fun SearchForStringsDialog(viewModel: MainViewModel, notice: String?) {
110201
var from by remember { mutableStateOf("0") }
111202
var to by remember { mutableStateOf("0") }
112203
AlertDialog(
@@ -117,10 +208,18 @@ fun SearchForStringsDialog(viewModel: MainViewModel) {
117208
Text(text = "Search for strings with length ? to ?")
118209
},
119210
text = {
120-
Row {
121-
NumberTextField(from, { from = it }, modifier = Modifier.weight(1f))
122-
Text(text = "to..")
123-
NumberTextField(to, { to = it }, modifier = Modifier.weight(1f))
211+
Column {
212+
notice?.let {
213+
Text(
214+
text = it,
215+
modifier = Modifier.padding(bottom = 8.dp)
216+
)
217+
}
218+
Row {
219+
NumberTextField(from, { from = it }, modifier = Modifier.weight(1f))
220+
Text(text = "to..")
221+
NumberTextField(to, { to = it }, modifier = Modifier.weight(1f))
222+
}
124223
}
125224
},
126225
confirmButton = {

0 commit comments

Comments
 (0)