Skip to content

Commit f503f04

Browse files
committed
stability: cap text previews and uniquify imports
1 parent 5ddb487 commit f503f04

5 files changed

Lines changed: 115 additions & 12 deletions

File tree

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

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,44 @@ import kotlinx.coroutines.flow.MutableStateFlow
2020
import kotlinx.coroutines.flow.StateFlow
2121
import timber.log.Timber
2222

23+
internal const val MAX_RENDERED_TEXT_BYTES = 256 * 1024
24+
25+
data class TextContentPreview(
26+
val bytes: ByteArray,
27+
val originalSize: Int,
28+
val isTruncated: Boolean
29+
)
30+
31+
fun buildTextContentPreview(bytes: ByteArray, maxBytes: Int = MAX_RENDERED_TEXT_BYTES): TextContentPreview {
32+
if (bytes.size <= maxBytes) {
33+
return TextContentPreview(bytes = bytes, originalSize = bytes.size, isTruncated = false)
34+
}
35+
return TextContentPreview(
36+
bytes = bytes.copyOf(maxBytes),
37+
originalSize = bytes.size,
38+
isTruncated = true
39+
)
40+
}
41+
2342
class TextTabData(val data: TabKind.Text) : PreparedTabData() {
2443
val relPath = data.key
25-
val fileContent = ProjectDataStorage.getFileContent(relPath)
2644

2745
private val _highlighted = MutableStateFlow(AnnotatedString(""))
2846
val highlighted = _highlighted as StateFlow<AnnotatedString>
47+
private val _notice = MutableStateFlow<String?>(null)
48+
val notice = _notice as StateFlow<String?>
2949

3050
override suspend fun prepare() {
31-
highlightContents()
51+
val preview = buildTextContentPreview(ProjectDataStorage.getFileContent(relPath))
52+
_notice.value = if (preview.isTruncated) {
53+
"Showing first ${preview.bytes.size} bytes of ${preview.originalSize} bytes"
54+
} else {
55+
null
56+
}
57+
highlightContents(preview.bytes)
3258
}
3359

34-
private suspend fun highlightContents() {
60+
private suspend fun highlightContents(fileContent: ByteArray) {
3561
val ext = ProjectDataStorage.getExtension(relPath) // File(relPath).extension.toLowerCase()
3662
var highlightedBuilder = AnnotatedString("")
3763
var strContent: String?
@@ -66,14 +92,18 @@ class TextTabData(val data: TabKind.Text) : PreparedTabData() {
6692
fun TextTab(data: TabData, viewModel: MainViewModel) {
6793
val preparedTabData: TextTabData = viewModel.getTabData(data)
6894
val highlighted = preparedTabData.highlighted.collectAsState()
69-
Text(
70-
text = highlighted.value,
71-
Modifier
72-
.background(Color.Black)
73-
.verticalScroll(
74-
rememberScrollState()
75-
)
76-
)
95+
val notice = preparedTabData.notice.collectAsState()
96+
androidx.compose.foundation.layout.Column {
97+
notice.value?.let { Text(it) }
98+
Text(
99+
text = highlighted.value,
100+
Modifier
101+
.background(Color.Black)
102+
.verticalScroll(
103+
rememberScrollState()
104+
)
105+
)
106+
}
77107

78108
// binding.textFragmentTextView.setBackgroundColor(Color.BLACK)
79109
}

app/src/main/java/com/kyhsgeekcode/disassembler/viewmodel/MainViewModel.kt

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
163163
app.contentResolver.openInputStream(uri).use { inStream ->
164164
requireNotNull(inStream) { "Failed to open content URI: $uri" }
165165
val fileName = resolveImportedFileName(app, uri, displayName)
166-
val file = app.filesDir.resolve("imports").resolve(fileName)
166+
val file = resolveImportedDestinationFile(
167+
app.filesDir.resolve("imports"),
168+
fileName
169+
)
167170
file.parentFile?.mkdirs()
168171
file.outputStream().use { fileOut ->
169172
inStream.copyTo(fileOut)
@@ -375,6 +378,23 @@ internal fun sanitizeImportedFileName(displayName: String?): String {
375378
return normalized ?: "openDirect"
376379
}
377380

381+
internal fun resolveImportedDestinationFile(importsDir: File, displayName: String?): File {
382+
val sanitizedName = sanitizeImportedFileName(displayName)
383+
var candidate = importsDir.resolve(sanitizedName)
384+
if (!candidate.exists()) {
385+
return candidate
386+
}
387+
388+
val baseName = candidate.nameWithoutExtension.ifBlank { candidate.name }
389+
val extensionSuffix = candidate.extension.takeIf { it.isNotBlank() }?.let { ".$it" } ?: ""
390+
var index = 1
391+
while (candidate.exists()) {
392+
candidate = importsDir.resolve("${baseName}_$index$extensionSuffix")
393+
index++
394+
}
395+
return candidate
396+
}
397+
378398
private fun resolveImportedFileName(
379399
app: Application,
380400
uri: Uri,
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.kyhsgeekcode.disassembler.ui.tabs
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertEquals
5+
import kotlin.test.assertFalse
6+
import kotlin.test.assertTrue
7+
8+
class TextPreviewTest {
9+
@Test
10+
fun `buildTextContentPreview keeps short content unchanged`() {
11+
val preview = buildTextContentPreview("hello".encodeToByteArray(), maxBytes = 8)
12+
13+
assertEquals("hello", preview.bytes.decodeToString())
14+
assertFalse(preview.isTruncated)
15+
assertEquals(5, preview.originalSize)
16+
}
17+
18+
@Test
19+
fun `buildTextContentPreview truncates large content`() {
20+
val preview = buildTextContentPreview("abcdefghij".encodeToByteArray(), maxBytes = 4)
21+
22+
assertEquals("abcd", preview.bytes.decodeToString())
23+
assertTrue(preview.isTruncated)
24+
assertEquals(10, preview.originalSize)
25+
}
26+
}

app/src/test/java/com/kyhsgeekcode/disassembler/viewmodel/ImportedFileNameTest.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package com.kyhsgeekcode.disassembler.viewmodel
22

3+
import java.io.File
4+
import kotlin.io.path.createTempDirectory
35
import org.junit.jupiter.api.Assertions.assertEquals
6+
import org.junit.jupiter.api.Assertions.assertTrue
47
import org.junit.jupiter.api.Test
58

69
class ImportedFileNameTest {
@@ -20,4 +23,24 @@ class ImportedFileNameTest {
2023
assertEquals("openDirect", sanitizeImportedFileName(null))
2124
assertEquals("openDirect", sanitizeImportedFileName(" "))
2225
}
26+
27+
@Test
28+
fun `resolveImportedDestinationFile keeps original file name when unused`() {
29+
val importsDir = createTempDirectory("imports-test").toFile()
30+
31+
val destination = resolveImportedDestinationFile(importsDir, "sample.apk")
32+
33+
assertEquals(File(importsDir, "sample.apk"), destination)
34+
}
35+
36+
@Test
37+
fun `resolveImportedDestinationFile appends suffix when file name already exists`() {
38+
val importsDir = createTempDirectory("imports-test").toFile()
39+
File(importsDir, "sample.apk").writeText("existing")
40+
41+
val destination = resolveImportedDestinationFile(importsDir, "sample.apk")
42+
43+
assertEquals("sample_1.apk", destination.name)
44+
assertTrue(destination.parentFile == importsDir)
45+
}
2346
}

docs/maintenance/implementation-log.ko.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
| 이슈 `#219` 대용량 파일 detail 렌더링 OOM 완화 | binary detail 화면이 전체 문자열을 그대로 레이아웃하지 않도록 preview helper를 추가하고, 화면에는 잘린 preview만 유지하며 전체 내용은 export 시점에만 다시 생성하도록 바꿨다 | 큰 ELF/PE detail 문자열이 화면 레이아웃 단계에서 메모리를 과도하게 잡는 경로를 줄이고, 저장 기능은 유지한 채 UI 메모리 압박을 낮췄다 | `app/src/main/java/com/kyhsgeekcode/disassembler/ui/tabs/BinaryDetailTab.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/ui/tabs/BinaryDetailsPreviewTest.kt` | 완료 |
3838
| 이슈 `#219`, `#523` 대용량 파일 확장 cache key OOM 완화 | archive/dex/app expansion cache 경로가 더 이상 파일 전체를 `readBytes()`로 읽지 않고, streaming SHA-256으로 cache key를 계산하도록 바꿨다 | 큰 파일을 확장하기 전에 해시 계산만으로 메모리를 크게 잡아먹던 경계를 제거해서 대용량 import의 선행 OOM 위험을 줄였다 | `app/src/main/java/com/kyhsgeekcode/filechooser/model/ExpandedFileCache.kt`, `app/src/test/java/com/kyhsgeekcode/filechooser/model/ExpandedFileCacheTest.kt` | 완료 |
3939
| 이슈 `#523` 대용량 Hex 탭 렌더링 완화 | Hex 탭이 매우 큰 파일을 열 때 전체 바이트 배열을 그대로 Compose에 전달하지 않고, 상한이 있는 preview만 렌더링하며 원본 크기 안내 문구를 보여주도록 바꿨다 | 큰 파일을 Hex 화면에 띄우는 순간 UI 레이어가 과도한 메모리와 레이아웃 비용을 쓰는 경로를 줄여서 large-file cluster를 더 좁혔다 | `app/src/main/java/com/kyhsgeekcode/disassembler/ui/components/HexView.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/ui/tabs/HexTab.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/ui/components/HexViewLayoutTest.kt` | 완료 |
40+
| 이슈 `#523` 대용량 Text 탭 렌더링 완화 | Text 탭이 매우 큰 파일을 열 때 전체 바이트를 그대로 highlight하지 않고, 상한이 있는 preview만 decode/highlight하며 원본 크기 안내 문구를 보여주도록 바꿨다 | 큰 텍스트/스몰리/XML 파일을 Text 화면에 띄우는 순간 AnnotatedString과 syntax highlighter가 과도한 메모리를 잡는 경로를 줄였다 | `app/src/main/java/com/kyhsgeekcode/disassembler/ui/tabs/TextTab.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/ui/tabs/TextPreviewTest.kt` | 완료 |
41+
| 이슈 `#95` content URI import 파일 충돌 완화 | content URI를 app-private `imports/` 아래로 복사할 때 같은 display name이 이미 있으면 `_1`, `_2` suffix를 붙인 새 파일로 저장하도록 바꿨다 | 같은 이름의 문서를 여러 번 가져오거나 provider가 동일한 표시 이름을 줄 때 기존 import를 조용히 덮어쓰는 문제를 막아 SAF 기반 import 경계를 더 안전하게 만들었다 | `app/src/main/java/com/kyhsgeekcode/disassembler/viewmodel/MainViewModel.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/viewmodel/ImportedFileNameTest.kt` | 완료 |
4042
| 이슈 `#129` `.ar` archive 지원 | 공용 archive extractor를 추가하고, Compose/legacy drawer의 archive 확장 경로를 ZIP 전용 구현에서 generic archive extractor로 교체했다 | archive 판정은 되는데 실제 확장은 ZIP만 되던 불일치를 제거해서 `.ar` 같은 지원 가능한 archive도 실제로 탐색 가능하게 만들었다 | `app/src/main/java/com/kyhsgeekcode/Util.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/ui/FileDrawerTree.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/FileDrawerListItem.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/FileDrawerListAdapter.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/ArchiveExtractionTest.kt` | 완료 |
4143
| 프로젝트 경로/파일명 회귀 방지 | 프로젝트 상대경로 계산과 import 파일명 정규화를 pure helper로 분리 | 단위 테스트가 가능하도록 로직을 분리하고 경계 케이스를 줄였다 | `app/src/main/java/com/kyhsgeekcode/disassembler/project/ProjectManager.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/viewmodel/MainViewModel.kt` | 완료 |
4244
| 회귀 테스트 부재 | `ProjectManager`, 저장소 권한, Hex 레이아웃, import 파일명 테스트 추가 | 최소한의 유지보수 안전망을 확보했다 | `app/src/test/java/com/kyhsgeekcode/disassembler/ProjectManagerTest.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/PermissionUtilsTest.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/ui/components/HexViewLayoutTest.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/viewmodel/ImportedFileNameTest.kt` | 완료 |
@@ -63,6 +65,8 @@
6365
| binary detail preview 테스트 | 통과 | 긴 detail 문자열은 preview만 렌더링하고 notice를 붙이는 규칙을 고정 |
6466
| expanded file cache key 테스트 | 통과 | 같은 파일 내용은 같은 streaming hash를 만들고, 전체 `readBytes()` 없이 key를 계산하는 규칙을 고정 |
6567
| hex preview 테스트 | 통과 | Hex 탭이 큰 파일 바이트 배열을 preview 상한으로 잘라 렌더링하는 규칙을 고정 |
68+
| text preview 테스트 | 통과 | Text 탭이 큰 파일 바이트 배열을 preview 상한으로 잘라 highlight하는 규칙을 고정 |
69+
| import destination 파일명 테스트 | 통과 | 같은 display name으로 여러 번 import해도 app-private 파일이 덮어써지지 않는 규칙을 고정 |
6670
| architecture mapping 테스트 | 통과 | `x86_64`, `PPC64`가 64-bit mode로 매핑되는 규칙을 고정 |
6771
| binary manual setup reload 테스트 | 통과 | override autosetup 변경 시 disassembly 재로드 필요 여부를 고정 |
6872
| project export archive 테스트 | 통과 | ZIP 엔트리가 절대경로를 포함하지 않고 `sourceFilePath`, `baseFolder/...`로 묶이는지 확인 |

0 commit comments

Comments
 (0)