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
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ fun createCreateDocumentResult(
return outputFile to Instrumentation.ActivityResult(Activity.RESULT_OK, resultIntent)
}

fun createCanceledActivityResult(): Instrumentation.ActivityResult {
return Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null)
}

fun createAdvancedImportResultForFile(
file: File,
openProject: Boolean = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,37 @@ class MainActivityAdvancedImportFlowTest {
}
}

@Test
fun advancedImportResult_copyNo_opensProjectAndSurvivesRecreate() {
val context = ApplicationProvider.getApplicationContext<Context>()
val importedFile = context.filesDir.resolve("androidTest/advanced/sample-no-copy.so")
importedFile.parentFile?.mkdirs()
importedFile.writeBytes("so-data".encodeToByteArray())

intending(
hasComponent(
ComponentName(
composeRule.activity,
NewFileChooserActivity::class.java
)
)
).respondWith(
createAdvancedImportResultForFile(
file = importedFile,
openProject = false,
projectType = ProjectType.UNKNOWN
)
)

composeRule.onNodeWithTag(MainTestTags.IMPORT_ADVANCED_BUTTON).performClick()
composeRule.onNodeWithTag(MainTestTags.COPY_DIALOG_NO_BUTTON).performClick()

waitForProjectOpen()
composeRule.activityRule.scenario.recreate()
waitForProjectOpen()
composeRule.onNodeWithTag(MainTestTags.EXPORT_PROJECT_BUTTON).assertExists()
}

@Test
fun advancedImportOpenProjectResult_importsProjectArchive() {
val archiveFile = createProjectArchiveFixture()
Expand All @@ -93,4 +124,11 @@ class MainActivityAdvancedImportFlowTest {
.fetchSemanticsNodes().isNotEmpty()
}
}

private fun waitForProjectOpen() {
composeRule.waitUntil(timeoutMillis = PROJECT_OPEN_TIMEOUT_MS) {
composeRule.onAllNodesWithTag(MainTestTags.EXPORT_PROJECT_BUTTON)
.fetchSemanticsNodes().isNotEmpty()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ class MainActivityExtraStreamIntentFlowTest {
composeRule.onNodeWithTag(MainTestTags.EXPORT_PROJECT_BUTTON).assertExists()
}

@Test
fun extraStreamContentUri_survivesRecreate() {
waitForProjectOpen()
composeRule.activityRule.scenario.recreate()
waitForProjectOpen()
composeRule.onNodeWithTag(MainTestTags.EXPORT_PROJECT_BUTTON).assertExists()
}

private fun waitForProjectOpen() {
composeRule.waitUntil(timeoutMillis = PROJECT_OPEN_TIMEOUT_MS) {
composeRule.onAllNodesWithTag(MainTestTags.EXPORT_PROJECT_BUTTON)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,37 @@ class MainActivityProjectExportFlowTest {
assertTrue(outputFile.readBytes().size > 4)
assertTrue(outputFile.readText(Charsets.ISO_8859_1).startsWith("PK"))
}

@Test
fun exportProjectCancel_keepsProjectOpen() {
intending(
allOf(
hasAction(Intent.ACTION_OPEN_DOCUMENT)
)
).respondWith(
createOpenDocumentResult(
displayName = "export-cancel-source.apk",
content = "export-content".encodeToByteArray()
)
)
intending(
allOf(
hasAction(Intent.ACTION_CREATE_DOCUMENT)
)
).respondWith(createCanceledActivityResult())

composeRule.onNodeWithTag(MainTestTags.IMPORT_SAF_BUTTON).performClick()
waitForProjectOpen()

composeRule.onNodeWithTag(MainTestTags.EXPORT_PROJECT_BUTTON).performClick()
composeRule.waitForIdle()
composeRule.onNodeWithTag(MainTestTags.EXPORT_PROJECT_BUTTON).assertExists()
}

private fun waitForProjectOpen() {
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithTag(MainTestTags.EXPORT_PROJECT_BUTTON)
.fetchSemanticsNodes().isNotEmpty()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,32 @@ class MainActivitySafImportFlowTest {

composeRule.onNodeWithTag(MainTestTags.EXPORT_PROJECT_BUTTON).assertExists()
}

@Test
fun safImportResult_survivesRecreate() {
intending(
allOf(
hasAction(Intent.ACTION_OPEN_DOCUMENT)
)
).respondWith(
createOpenDocumentResult(
displayName = "sample-recreate.apk",
content = "apk-content".encodeToByteArray()
)
)

composeRule.onNodeWithTag(MainTestTags.IMPORT_SAF_BUTTON).performClick()

waitForProjectOpen()
composeRule.activityRule.scenario.recreate()
waitForProjectOpen()
composeRule.onNodeWithTag(MainTestTags.EXPORT_PROJECT_BUTTON).assertExists()
}

private fun waitForProjectOpen() {
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithTag(MainTestTags.EXPORT_PROJECT_BUTTON)
.fetchSemanticsNodes().isNotEmpty()
}
}
}
16 changes: 14 additions & 2 deletions app/src/main/java/com/kyhsgeekcode/disassembler/Analyzer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.pow

private const val MAX_EMITTED_FOUND_STRING_CHARS = 4_096

@ExperimentalUnsignedTypes
class Analyzer(private val bytes: ByteArray) {
Expand Down Expand Up @@ -50,7 +51,18 @@ class Analyzer(private val bytes: ByteArray) {
val length = i - strstart
val offset = strstart
if (length in min..max) {
val str = String(bytes, strstart, length)
val previewLength = minOf(length, MAX_EMITTED_FOUND_STRING_CHARS)
val str = String(bytes, strstart, previewLength).let {
if (length > MAX_EMITTED_FOUND_STRING_CHARS) {
if (previewLength <= 3) {
it.take(previewLength)
} else {
it.take(previewLength - 3) + "..."
}
} else {
it
}
}
val fs = FoundString(length, offset.toLong(), str)
// Log.v(TAG,str);
progress(i, bytes.size, fs)
Expand Down Expand Up @@ -400,4 +412,4 @@ fun Simpson3_8(f: (Double) -> Double, a: Double, b: Double, N: Int, gamma: Doubl

private fun log2(a: Double): Double {
return ln(a) / ln(2.0)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ object ProjectDataStorage {
val key = Pair(keykey, DataType.FileContent)
data[key] = datadata
}

fun clear() {
data.clear()
}
}

internal const val MAX_CACHED_FILE_CONTENT_BYTES = 8L * 1024 * 1024
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,9 @@ fun MainScreen(viewModel: MainViewModel) {

val showSearchForStringsDialog =
viewModel.showSearchForStringsDialog.collectAsState()
if (showSearchForStringsDialog.value == ShowSearchForStringsDialog.Shown) {
SearchForStringsDialog(viewModel)
val dialogState = showSearchForStringsDialog.value
if (dialogState is ShowSearchForStringsDialog.Shown) {
SearchForStringsDialog(viewModel, dialogState.notice)
}
}
}
Expand Down
127 changes: 113 additions & 14 deletions app/src/main/java/com/kyhsgeekcode/disassembler/ui/tabs/StringTab.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,74 @@ import kotlinx.coroutines.flow.StateFlow
import timber.log.Timber

private const val MAX_RENDERED_STRING_RESULTS = 5_000
private const val MAX_RENDERED_STRING_CHARS = 4_096
private const val MAX_RENDERED_STRING_TOTAL_CHARS = 32_768
internal const val MAX_SEARCHED_STRING_BYTES = 4 * 1024 * 1024

data class StringSearchInput(
val bytes: ByteArray,
val originalSize: Long,
val isTruncated: Boolean
)

internal fun buildStringSearchInput(
previewBytes: ByteArray,
originalSize: Long,
maxBytes: Int = MAX_SEARCHED_STRING_BYTES
): StringSearchInput {
return StringSearchInput(
bytes = previewBytes,
originalSize = originalSize,
isTruncated = originalSize > maxBytes
)
}

internal fun buildStringSearchNotice(
input: StringSearchInput,
resultsTruncated: Boolean
): String? {
val parts = mutableListOf<String>()
if (input.isTruncated) {
parts += "Searching strings in first ${input.bytes.size} bytes of ${input.originalSize} bytes"
}
if (resultsTruncated) {
parts += "Showing first $MAX_RENDERED_STRING_RESULTS results."
}
return when {
parts.isEmpty() -> null
parts.size == 1 -> parts.single()
else -> parts.joinToString(". ")
}
Comment on lines +52 to +67
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't map every truncation path to “Showing first 5000 results.”

accumulator.isTruncated is flipped both when rows are dropped and when the render-char budget clips content, but buildStringSearchNotice() renders both cases as a row-count limit. After the 32,768-char budget is exhausted, for example, the UI can show far fewer than 5,000 rows while still claiming only the first 5,000 are shown. This shape also leaves no way to surface analyzer-side 4,096-char clipping. Split count truncation from content clipping before building the notice.

🧭 Suggested direction
 class StringSearchResultAccumulator(private val maxResults: Int) {
     private val _results = mutableListOf<FoundString>()
     val results: List<FoundString>
         get() = _results
 
-    var isTruncated: Boolean = false
+    var resultCountTruncated: Boolean = false
+        private set
+
+    var contentClipped: Boolean = false
         private set
 
     private var renderedChars: Int = 0
 
     fun append(result: FoundString) {
         if (_results.size >= maxResults) {
-            isTruncated = true
+            resultCountTruncated = true
             return
         }
         val remainingChars = MAX_RENDERED_STRING_TOTAL_CHARS - renderedChars
         if (remainingChars <= 0) {
-            isTruncated = true
+            contentClipped = true
             return
         }
         val (displayResult, wasClipped) = clipFoundStringForRendering(
             result,
             minOf(MAX_RENDERED_STRING_CHARS, remainingChars)
         )
         if (wasClipped) {
-            isTruncated = true
+            contentClipped = true
         }
         _results.add(displayResult)
         renderedChars += displayResult.string.length
     }
 }
 
 internal fun buildStringSearchNotice(
     input: StringSearchInput,
-    resultsTruncated: Boolean
+    resultCountTruncated: Boolean,
+    contentClipped: Boolean
 ): String? {
     val parts = mutableListOf<String>()
     if (input.isTruncated) {
         parts += "Searching strings in first ${input.bytes.size} bytes of ${input.originalSize} bytes"
     }
-    if (resultsTruncated) {
+    if (resultCountTruncated) {
         parts += "Showing first $MAX_RENDERED_STRING_RESULTS results."
     }
+    if (contentClipped) {
+        parts += "Some matches are shortened for display."
+    }
     return when {
         parts.isEmpty() -> null
         parts.size == 1 -> parts.single()
         else -> parts.joinToString(". ")
     }
 }
 
-                _notice.value = buildStringSearchNotice(input, accumulator.isTruncated)
+                _notice.value = buildStringSearchNotice(
+                    input = input,
+                    resultCountTruncated = accumulator.resultCountTruncated,
+                    contentClipped = accumulator.contentClipped
+                )

Also applies to: 99-117, 151-152

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/kyhsgeekcode/disassembler/ui/tabs/StringTab.kt` around
lines 52 - 67, buildStringSearchNotice currently conflates two different
truncation causes (row-count truncation and content clipping) because
input.isTruncated is overloaded; change the function to accept two distinct
booleans (e.g., countTruncated: Boolean, contentClipped: Boolean) or otherwise
distinguish input.isTruncated into those two signals at the caller sites (places
around the previous comments ~99-117 and ~151-152), then build separate notice
parts: when countTruncated is true append the existing "Showing first
$MAX_RENDERED_STRING_RESULTS results." message, and when contentClipped is true
append a message indicating rendering/content clipping (e.g., "Some results were
truncated by the render-character budget" and, if available, include analyzer
clipping like "analyzer truncated to 4096 chars"); update callers of
buildStringSearchNotice and any uses of input.isTruncated so the UI accurately
reflects count vs content clipping.

}

internal fun buildStringSearchDialogNotice(
originalSize: Long,
maxBytes: Int = MAX_SEARCHED_STRING_BYTES
): String? {
if (originalSize <= maxBytes) {
return null
}
return "Large file detected. String search will only scan the first $maxBytes bytes of $originalSize bytes."
}

internal fun clipFoundStringForRendering(
result: FoundString,
maxChars: Int = MAX_RENDERED_STRING_CHARS
): Pair<FoundString, Boolean> {
require(maxChars >= 0) { "maxChars must be non-negative" }
if (result.string.length <= maxChars) {
return result to false
}
if (maxChars == 0) {
return result.copy(string = "") to true
}
val clippedString = if (maxChars <= 3) {
result.string.take(maxChars)
} else {
result.string.take(maxChars - 3) + "..."
}
return result.copy(string = clippedString) to true
}

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

private var renderedChars: Int = 0

fun append(result: FoundString) {
if (_results.size >= maxResults) {
isTruncated = true
return
}
_results.add(result)
val remainingChars = MAX_RENDERED_STRING_TOTAL_CHARS - renderedChars
if (remainingChars <= 0) {
isTruncated = true
return
}
val (displayResult, wasClipped) = clipFoundStringForRendering(
result,
minOf(MAX_RENDERED_STRING_CHARS, remainingChars)
)
if (wasClipped) {
isTruncated = true
}
_results.add(displayResult)
renderedChars += displayResult.string.length
}
}

Expand All @@ -52,21 +135,31 @@ class StringTabData(val data: TabKind.FoundString) : PreparedTabData() {
val isDone = _isDone as StateFlow<Boolean>
private val _isTruncated = MutableStateFlow(false)
val isTruncated = _isTruncated as StateFlow<Boolean>
private val _notice = MutableStateFlow<String?>(null)
val notice = _notice as StateFlow<String?>
lateinit var analyzer: Analyzer
override suspend fun prepare() {
val bytes = ProjectDataStorage.getFileContent(data.relPath)
val fileSize = ProjectDataStorage.resolveToRead(data.relPath)?.length() ?: 0L
val input = buildStringSearchInput(
previewBytes = ProjectDataStorage.getFileContentPreview(
data.relPath,
MAX_SEARCHED_STRING_BYTES
),
originalSize = fileSize
)
Timber.d("Given relPath: ${data.relPath}")
analyzer = Analyzer(bytes)
analyzer = Analyzer(input.bytes)
val accumulator = StringSearchResultAccumulator(MAX_RENDERED_STRING_RESULTS)
analyzer.searchStrings(data.range.first, data.range.last) { p, t, fs ->
fs?.let {
accumulator.append(it)
if (!accumulator.isTruncated) {
strings.add(it)
if (strings.size < accumulator.results.size) {
strings.add(accumulator.results.last())
}
}
if (p == t) { // done
_isTruncated.value = accumulator.isTruncated
_notice.value = buildStringSearchNotice(input, accumulator.isTruncated)
_isDone.value = true
}
}
Expand All @@ -80,15 +173,13 @@ fun StringTab(data: TabData, viewModel: MainViewModel) {
val preparedTabData: StringTabData = viewModel.getTabData(data)
val strings = preparedTabData.strings
val isDone = preparedTabData.isDone.collectAsState()
val isTruncated = preparedTabData.isTruncated.collectAsState()
val notice = preparedTabData.notice.collectAsState()
Column {
Row {
if (!isDone.value) {
Icon(imageVector = Icons.Default.MoreVert, contentDescription = "Searching...")
}
if (isTruncated.value) {
Text("Showing first $MAX_RENDERED_STRING_RESULTS results")
}
notice.value?.let { Text(it) }
}
TableView(
titles = listOf("Offset" to 100.dp, "Length" to 50.dp, "String" to 800.dp),
Expand All @@ -106,7 +197,7 @@ fun StringTab(data: TabData, viewModel: MainViewModel) {
}

@Composable
fun SearchForStringsDialog(viewModel: MainViewModel) {
fun SearchForStringsDialog(viewModel: MainViewModel, notice: String?) {
var from by remember { mutableStateOf("0") }
var to by remember { mutableStateOf("0") }
AlertDialog(
Expand All @@ -117,10 +208,18 @@ fun SearchForStringsDialog(viewModel: MainViewModel) {
Text(text = "Search for strings with length ? to ?")
},
text = {
Row {
NumberTextField(from, { from = it }, modifier = Modifier.weight(1f))
Text(text = "to..")
NumberTextField(to, { to = it }, modifier = Modifier.weight(1f))
Column {
notice?.let {
Text(
text = it,
modifier = Modifier.padding(bottom = 8.dp)
)
}
Row {
NumberTextField(from, { from = it }, modifier = Modifier.weight(1f))
Text(text = "to..")
NumberTextField(to, { to = it }, modifier = Modifier.weight(1f))
}
}
},
confirmButton = {
Expand Down
Loading
Loading