Skip to content

Commit 22fb671

Browse files
committed
project: wire open-as-project flows
1 parent 6a3f606 commit 22fb671

5 files changed

Lines changed: 178 additions & 5 deletions

File tree

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import org.json.JSONException
1919
import splitties.init.appCtx
2020
import java.io.File
2121
import java.io.IOException
22+
import java.util.zip.ZipFile
2223

2324
/**
2425
* the list of paths of project_info.json is saved to a SharedPreference.
@@ -284,6 +285,44 @@ object ProjectManager {
284285
}
285286
}
286287

288+
sealed class ProjectOpenAction {
289+
data object PromptCopy : ProjectOpenAction()
290+
data class OpenExistingProject(val projectInfoFile: File) : ProjectOpenAction()
291+
data class ImportProjectArchive(val archiveFile: File) : ProjectOpenAction()
292+
}
293+
294+
internal fun determineProjectOpenAction(targetFile: File, openAsProject: Boolean): ProjectOpenAction {
295+
if (!openAsProject) {
296+
return ProjectOpenAction.PromptCopy
297+
}
298+
projectInfoFileForDirectory(targetFile)?.let {
299+
return ProjectOpenAction.OpenExistingProject(it)
300+
}
301+
if (isProjectArchiveFile(targetFile)) {
302+
return ProjectOpenAction.ImportProjectArchive(targetFile)
303+
}
304+
return ProjectOpenAction.PromptCopy
305+
}
306+
307+
internal fun projectInfoFileForDirectory(targetFile: File): File? {
308+
if (!targetFile.isDirectory) {
309+
return null
310+
}
311+
val projectInfoFile = targetFile.resolve("project_info.json")
312+
return projectInfoFile.takeIf { it.isFile }
313+
}
314+
315+
fun isProjectArchiveFile(targetFile: File): Boolean {
316+
if (!targetFile.isFile || !targetFile.extension.equals("zip", ignoreCase = true)) {
317+
return false
318+
}
319+
return runCatching {
320+
ZipFile(targetFile).use { zipFile ->
321+
zipFile.getEntry("project_info.json") != null
322+
}
323+
}.getOrDefault(false)
324+
}
325+
287326
internal fun importedProjectInfoFile(projectDir: File): File {
288327
return projectDir.resolve("project_info.json")
289328
}

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

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ import com.kyhsgeekcode.disassembler.exporting.buildProjectExportFileName
2020
import com.kyhsgeekcode.disassembler.exporting.copyFileToDocument
2121
import com.kyhsgeekcode.disassembler.project.ProjectDataStorage
2222
import com.kyhsgeekcode.disassembler.project.ProjectManager
23+
import com.kyhsgeekcode.disassembler.project.ProjectOpenAction
2324
import com.kyhsgeekcode.disassembler.project.models.ProjectModel
2425
import com.kyhsgeekcode.disassembler.project.models.ProjectSourceDescriptor
2526
import com.kyhsgeekcode.disassembler.project.models.ProjectSourceKind
2627
import com.kyhsgeekcode.disassembler.project.models.ProjectType
28+
import com.kyhsgeekcode.disassembler.project.determineProjectOpenAction
2729
import com.kyhsgeekcode.disassembler.ui.FileDrawerTreeItem
2830
import com.kyhsgeekcode.disassembler.ui.TabData
2931
import com.kyhsgeekcode.disassembler.ui.TabKind
@@ -150,10 +152,55 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
150152
}
151153

152154
private fun onSelectFilePayload(payload: SelectedFileIntentPayload) {
153-
_file.value = File(payload.filePath)
154-
_nativeFile.value = payload.nativeFilePath?.let(::File)
155-
_projectType.value = payload.projectType
156-
_askCopy.value = true
155+
val selectedFile = File(payload.filePath)
156+
when (val action = determineProjectOpenAction(selectedFile, _openAsProject.value)) {
157+
ProjectOpenAction.PromptCopy -> {
158+
_file.value = selectedFile
159+
_nativeFile.value = payload.nativeFilePath?.let(::File)
160+
_projectType.value = payload.projectType
161+
_askCopy.value = true
162+
}
163+
164+
is ProjectOpenAction.OpenExistingProject -> {
165+
openExistingProject(action.projectInfoFile)
166+
}
167+
168+
is ProjectOpenAction.ImportProjectArchive -> {
169+
importProjectArchive(action.archiveFile)
170+
}
171+
}
172+
}
173+
174+
private fun openExistingProject(projectInfoFile: File) {
175+
viewModelScope.launch {
176+
eventChannel.send(Event.StartProgress())
177+
try {
178+
val project = withContext(Dispatchers.IO) {
179+
ProjectManager.openProject(projectInfoFile.absolutePath)
180+
}
181+
_selectedFilePath.value = project.sourceFilePath
182+
_currentProject.value = project
183+
} catch (e: Exception) {
184+
eventChannel.send(Event.AlertError("Failed to open project"))
185+
}
186+
eventChannel.send(Event.FinishProgress())
187+
}
188+
}
189+
190+
private fun importProjectArchive(archiveFile: File) {
191+
viewModelScope.launch {
192+
eventChannel.send(Event.StartProgress())
193+
try {
194+
val project = withContext(Dispatchers.IO) {
195+
ProjectManager.import(archiveFile)
196+
}
197+
_selectedFilePath.value = project.sourceFilePath
198+
_currentProject.value = project
199+
} catch (e: Exception) {
200+
eventChannel.send(Event.AlertError("Failed to import project"))
201+
}
202+
eventChannel.send(Event.FinishProgress())
203+
}
157204
}
158205

159206
private fun onSelectUri(uri: Uri, displayName: String? = null) {

app/src/main/java/com/kyhsgeekcode/filechooser/model/FileItem.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.kyhsgeekcode.disassembler.R
1111
import com.kyhsgeekcode.disassembler.importing.AdvancedImportOptions
1212
import com.kyhsgeekcode.disassembler.importing.AdvancedImportSource
1313
import com.kyhsgeekcode.disassembler.importing.DefaultAdvancedImportSourceCatalog
14+
import com.kyhsgeekcode.disassembler.project.isProjectArchiveFile
1415
import kotlinx.coroutines.Dispatchers
1516
import kotlinx.coroutines.withContext
1617
import splitties.init.appCtx
@@ -74,7 +75,7 @@ open class FileItem : Serializable {
7475

7576
open fun isAccessible(): Boolean = file?.isAccessible() ?: true
7677

77-
open fun isProjectAble(): Boolean = file?.isDirectory == true
78+
open fun isProjectAble(): Boolean = file?.isDirectory == true || file?.let(::isProjectArchiveFile) == true
7879

7980
open suspend fun listSubItems(publisher: (current: Int, total: Int) -> Unit = { _, _ -> }): List<FileItem> {
8081
if (!canExpand())
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.kyhsgeekcode.disassembler.viewmodel
2+
3+
import com.kyhsgeekcode.disassembler.project.ProjectOpenAction
4+
import com.kyhsgeekcode.disassembler.project.determineProjectOpenAction
5+
import java.io.File
6+
import java.nio.file.Path
7+
import java.util.zip.ZipEntry
8+
import java.util.zip.ZipOutputStream
9+
import kotlin.io.path.createTempDirectory
10+
import kotlin.test.Test
11+
import kotlin.test.assertEquals
12+
import kotlin.test.assertIs
13+
14+
class ProjectOpenActionTest {
15+
@Test
16+
fun `determineProjectOpenAction prompts copy when openAsProject is false`() {
17+
val file = createTempDirectory("project-open").toFile().resolve("sample.apk").apply {
18+
writeText("apk")
19+
}
20+
21+
val action = determineProjectOpenAction(file, openAsProject = false)
22+
23+
assertIs<ProjectOpenAction.PromptCopy>(action)
24+
}
25+
26+
@Test
27+
fun `determineProjectOpenAction opens existing project directory`() {
28+
val projectDir = createTempDirectory("project-open").toFile().apply {
29+
resolve("project_info.json").writeText("{}")
30+
}
31+
32+
val action = determineProjectOpenAction(projectDir, openAsProject = true)
33+
34+
val openExisting = assertIs<ProjectOpenAction.OpenExistingProject>(action)
35+
assertEquals(projectDir.resolve("project_info.json"), openExisting.projectInfoFile)
36+
}
37+
38+
@Test
39+
fun `determineProjectOpenAction imports project archive`() {
40+
val archive = createProjectArchive(createTempDirectory("project-open"))
41+
42+
val action = determineProjectOpenAction(archive, openAsProject = true)
43+
44+
val importArchive = assertIs<ProjectOpenAction.ImportProjectArchive>(action)
45+
assertEquals(archive, importArchive.archiveFile)
46+
}
47+
48+
@Test
49+
fun `determineProjectOpenAction falls back to copy prompt for non project directory`() {
50+
val folder = createTempDirectory("project-open").toFile()
51+
52+
val action = determineProjectOpenAction(folder, openAsProject = true)
53+
54+
assertIs<ProjectOpenAction.PromptCopy>(action)
55+
}
56+
57+
@Test
58+
fun `determineProjectOpenAction falls back to copy prompt for non project archive`() {
59+
val archive = createTempDirectory("project-open").toFile().resolve("plain.zip").apply {
60+
ZipOutputStream(outputStream()).use { zip ->
61+
zip.putNextEntry(ZipEntry("classes.dex"))
62+
zip.write("dex".encodeToByteArray())
63+
zip.closeEntry()
64+
}
65+
}
66+
67+
val action = determineProjectOpenAction(archive, openAsProject = true)
68+
69+
assertIs<ProjectOpenAction.PromptCopy>(action)
70+
}
71+
72+
private fun createProjectArchive(tempDir: Path): File {
73+
return tempDir.toFile().resolve("project.zip").apply {
74+
ZipOutputStream(outputStream()).use { zip ->
75+
zip.putNextEntry(ZipEntry("project_info.json"))
76+
zip.write("{}".encodeToByteArray())
77+
zip.closeEntry()
78+
zip.putNextEntry(ZipEntry("sourceFilePath"))
79+
zip.write("sample".encodeToByteArray())
80+
zip.closeEntry()
81+
}
82+
}
83+
}
84+
}

docs/maintenance/implementation-log.ko.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
| 이슈 `#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` | 완료 |
4141
| 이슈 `#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` | 완료 |
4242
| 이슈 `#95` imported project reopen 경계 정리 | exported project ZIP을 import한 뒤 `sourceFilePath`, `generatedFolder`, `project_info.json` 경로를 새 프로젝트 디렉터리 기준으로 재작성하고, temp extract 경로 매핑을 제거하도록 바꿨다 | SAF나 로컬 파일에서 가져온 project archive를 다시 열었을 때 삭제된 temp extract 경로나 예전 경로를 계속 참조하는 문제를 막아 reopen/open 경계를 실제 프로젝트 위치로 닫았다 | `app/src/main/java/com/kyhsgeekcode/disassembler/project/ProjectManager.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/ProjectManagerTest.kt` | 완료 |
43+
| 이슈 `#95` project archive / 기존 project reopen 연결 | chooser의 `Open as project` 흐름이 기존 project 디렉터리와 exported project ZIP을 실제로 열도록 연결하고, project zip은 `project_info.json` 존재 여부로 판별하게 바꿨다 | 이전에는 `openProject` 플래그가 state에만 저장되고 실질 동작이 없어서 reopen 경계가 끊겨 있었는데, 이제 기존 project 디렉터리는 바로 열고 exported project ZIP은 바로 import할 수 있다 | `app/src/main/java/com/kyhsgeekcode/disassembler/project/ProjectManager.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/viewmodel/MainViewModel.kt`, `app/src/main/java/com/kyhsgeekcode/filechooser/model/FileItem.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/viewmodel/ProjectOpenActionTest.kt` | 완료 |
4344
| 이슈 `#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` | 완료 |
4445
| 프로젝트 경로/파일명 회귀 방지 | 프로젝트 상대경로 계산과 import 파일명 정규화를 pure helper로 분리 | 단위 테스트가 가능하도록 로직을 분리하고 경계 케이스를 줄였다 | `app/src/main/java/com/kyhsgeekcode/disassembler/project/ProjectManager.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/viewmodel/MainViewModel.kt` | 완료 |
4546
| 회귀 테스트 부재 | `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` | 완료 |
@@ -69,6 +70,7 @@
6970
| text preview 테스트 | 통과 | Text 탭이 큰 파일 바이트 배열을 preview 상한으로 잘라 highlight하는 규칙을 고정 |
7071
| import destination 파일명 테스트 | 통과 | 같은 display name으로 여러 번 import해도 app-private 파일이 덮어써지지 않는 규칙을 고정 |
7172
| imported project relocation 테스트 | 통과 | project archive import 후 `sourceFilePath`, `generatedFolder`, `project_info.json` 경로를 새 프로젝트 위치로 다시 맞추는 규칙을 고정 |
73+
| project open action 테스트 | 통과 | `Open as project`가 기존 project 디렉터리와 exported project ZIP에 대해 올바른 reopen/import 동작을 고르는 규칙을 고정 |
7274
| architecture mapping 테스트 | 통과 | `x86_64`, `PPC64`가 64-bit mode로 매핑되는 규칙을 고정 |
7375
| binary manual setup reload 테스트 | 통과 | override autosetup 변경 시 disassembly 재로드 필요 여부를 고정 |
7476
| project export archive 테스트 | 통과 | ZIP 엔트리가 절대경로를 포함하지 않고 `sourceFilePath`, `baseFolder/...`로 묶이는지 확인 |

0 commit comments

Comments
 (0)