Skip to content

Commit 662cab0

Browse files
authored
Merge pull request #729 from yhs0602/codex/saf-instrumentation-regressions
test: strengthen SAF storage regression coverage
2 parents 1593fdb + aa1d310 commit 662cab0

8 files changed

Lines changed: 295 additions & 11 deletions

File tree

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

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,55 @@ import kotlinx.serialization.json.Json
2020

2121
private const val FILE_PROVIDER_AUTHORITY = "com.kyhsgeekcode.disassembler.provider"
2222

23-
fun createOpenDocumentResult(
23+
fun createIncomingContentUri(
2424
displayName: String,
2525
content: ByteArray
26-
): Instrumentation.ActivityResult {
26+
): Uri {
2727
val context = ApplicationProvider.getApplicationContext<Context>()
2828
val inputFile = context.filesDir.resolve("androidTest/input/$displayName")
2929
inputFile.parentFile?.mkdirs()
3030
inputFile.writeBytes(content)
31-
val uri = testFileUri(context, inputFile)
31+
return testFileUri(context, inputFile)
32+
}
33+
34+
fun createActionViewIntent(
35+
displayName: String,
36+
content: ByteArray
37+
): Intent {
38+
val uri = createIncomingContentUri(displayName, content)
39+
return Intent(
40+
ApplicationProvider.getApplicationContext(),
41+
MainActivity::class.java
42+
).apply {
43+
action = Intent.ACTION_VIEW
44+
data = uri
45+
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
46+
addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
47+
}
48+
}
49+
50+
fun createExtraStreamIntent(
51+
displayName: String,
52+
content: ByteArray
53+
): Intent {
54+
val uri = createIncomingContentUri(displayName, content)
55+
return Intent(
56+
ApplicationProvider.getApplicationContext(),
57+
MainActivity::class.java
58+
).apply {
59+
action = Intent.ACTION_SEND
60+
type = "*/*"
61+
putExtra(Intent.EXTRA_STREAM, uri)
62+
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
63+
addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
64+
}
65+
}
66+
67+
fun createOpenDocumentResult(
68+
displayName: String,
69+
content: ByteArray
70+
): Instrumentation.ActivityResult {
71+
val uri = createIncomingContentUri(displayName, content)
3272
val resultIntent = Intent()
3373
.setData(uri)
3474
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.kyhsgeekcode.disassembler
2+
3+
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
4+
import androidx.compose.ui.test.onAllNodesWithTag
5+
import androidx.compose.ui.test.onNodeWithTag
6+
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import androidx.test.ext.junit.rules.ActivityScenarioRule
8+
import com.kyhsgeekcode.disassembler.ui.MainTestTags
9+
import org.junit.Rule
10+
import org.junit.Test
11+
import org.junit.rules.RuleChain
12+
import org.junit.runner.RunWith
13+
14+
@RunWith(AndroidJUnit4::class)
15+
class MainActivityActionViewIntentFlowTest {
16+
companion object {
17+
private const val PROJECT_OPEN_TIMEOUT_MS = 10_000L
18+
}
19+
20+
private val projectCleanupRule = ProjectStateCleanupRule()
21+
private val activityRule = ActivityScenarioRule<MainActivity>(
22+
createActionViewIntent(
23+
displayName = "incoming-view.apk",
24+
content = "incoming-view-content".encodeToByteArray()
25+
)
26+
)
27+
private val composeRule = AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>(
28+
activityRule
29+
) { rule: ActivityScenarioRule<MainActivity> ->
30+
var activity: MainActivity? = null
31+
rule.scenario.onActivity { activity = it }
32+
checkNotNull(activity)
33+
}
34+
35+
@get:Rule
36+
val rules: RuleChain = RuleChain.outerRule(projectCleanupRule)
37+
.around(composeRule)
38+
39+
@Test
40+
fun actionViewContentUri_opensProjectAndSurvivesRecreate() {
41+
waitForProjectOpen()
42+
43+
composeRule.activityRule.scenario.recreate()
44+
45+
waitForProjectOpen()
46+
composeRule.onNodeWithTag(MainTestTags.EXPORT_PROJECT_BUTTON).assertExists()
47+
}
48+
49+
private fun waitForProjectOpen() {
50+
composeRule.waitUntil(timeoutMillis = PROJECT_OPEN_TIMEOUT_MS) {
51+
composeRule.onAllNodesWithTag(MainTestTags.EXPORT_PROJECT_BUTTON)
52+
.fetchSemanticsNodes().isNotEmpty()
53+
}
54+
}
55+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.kyhsgeekcode.disassembler
2+
3+
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
4+
import androidx.compose.ui.test.onNodeWithTag
5+
import androidx.test.ext.junit.rules.ActivityScenarioRule
6+
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import com.kyhsgeekcode.disassembler.project.ProjectManager
8+
import com.kyhsgeekcode.disassembler.ui.MainTestTags
9+
import org.junit.Assert.assertEquals
10+
import org.junit.Assert.assertTrue
11+
import org.junit.Rule
12+
import org.junit.Test
13+
import org.junit.rules.RuleChain
14+
import org.junit.runner.RunWith
15+
16+
@RunWith(AndroidJUnit4::class)
17+
class MainActivityActionViewProjectArchiveIntentFlowTest {
18+
companion object {
19+
private const val PROJECT_OPEN_TIMEOUT_MS = 10_000L
20+
}
21+
22+
private val projectCleanupRule = ProjectStateCleanupRule()
23+
private val activityRule = ActivityScenarioRule<MainActivity>(
24+
createActionViewIntent(
25+
displayName = "shared-project.zip",
26+
content = createProjectArchiveFixture().readBytes()
27+
)
28+
)
29+
private val composeRule = AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>(
30+
activityRule
31+
) { rule: ActivityScenarioRule<MainActivity> ->
32+
var activity: MainActivity? = null
33+
rule.scenario.onActivity { activity = it }
34+
checkNotNull(activity)
35+
}
36+
37+
@get:Rule
38+
val rules: RuleChain = RuleChain.outerRule(projectCleanupRule)
39+
.around(composeRule)
40+
41+
@Test
42+
fun actionViewProjectArchive_importsProjectArchiveModel() {
43+
composeRule.waitUntil(timeoutMillis = PROJECT_OPEN_TIMEOUT_MS) {
44+
ProjectManager.currentProject?.name == "ArchiveFixture"
45+
}
46+
47+
composeRule.onNodeWithTag(MainTestTags.EXPORT_PROJECT_BUTTON).assertExists()
48+
assertEquals("ArchiveFixture", ProjectManager.currentProject?.name)
49+
assertTrue(ProjectManager.currentProject?.sourceFilePath?.endsWith("sourceFilePath") == true)
50+
}
51+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.kyhsgeekcode.disassembler
2+
3+
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
4+
import androidx.compose.ui.test.onAllNodesWithTag
5+
import androidx.compose.ui.test.onNodeWithTag
6+
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import androidx.test.ext.junit.rules.ActivityScenarioRule
8+
import com.kyhsgeekcode.disassembler.ui.MainTestTags
9+
import org.junit.Rule
10+
import org.junit.Test
11+
import org.junit.rules.RuleChain
12+
import org.junit.runner.RunWith
13+
14+
@RunWith(AndroidJUnit4::class)
15+
class MainActivityExtraStreamIntentFlowTest {
16+
companion object {
17+
private const val PROJECT_OPEN_TIMEOUT_MS = 10_000L
18+
}
19+
20+
private val projectCleanupRule = ProjectStateCleanupRule()
21+
private val activityRule = ActivityScenarioRule<MainActivity>(
22+
createExtraStreamIntent(
23+
displayName = "incoming-stream.apk",
24+
content = "incoming-stream-content".encodeToByteArray()
25+
)
26+
)
27+
private val composeRule = AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>(
28+
activityRule
29+
) { rule: ActivityScenarioRule<MainActivity> ->
30+
var activity: MainActivity? = null
31+
rule.scenario.onActivity { activity = it }
32+
checkNotNull(activity)
33+
}
34+
35+
@get:Rule
36+
val rules: RuleChain = RuleChain.outerRule(projectCleanupRule)
37+
.around(composeRule)
38+
39+
@Test
40+
fun extraStreamContentUri_opensProject() {
41+
waitForProjectOpen()
42+
composeRule.onNodeWithTag(MainTestTags.EXPORT_PROJECT_BUTTON).assertExists()
43+
}
44+
45+
private fun waitForProjectOpen() {
46+
composeRule.waitUntil(timeoutMillis = PROJECT_OPEN_TIMEOUT_MS) {
47+
composeRule.onAllNodesWithTag(MainTestTags.EXPORT_PROJECT_BUTTON)
48+
.fetchSemanticsNodes().isNotEmpty()
49+
}
50+
}
51+
}

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

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -218,13 +218,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
218218
file.outputStream().use { fileOut ->
219219
inStream.copyTo(fileOut)
220220
}
221-
val project = ProjectManager.newProject(
222-
file,
223-
ProjectType.UNKNOWN,
224-
file.name,
225-
true,
226-
ProjectSourceDescriptor(ProjectSourceKind.CONTENT_URI, uri.toString())
227-
)
221+
val project = when (
222+
val action = determineImportedUriAction(file, uri.toString())
223+
) {
224+
is ImportedUriAction.CreateProject -> ProjectManager.newProject(
225+
action.importedFile,
226+
ProjectType.UNKNOWN,
227+
action.importedFile.name,
228+
true,
229+
action.sourceDescriptor
230+
)
231+
232+
is ImportedUriAction.ImportProjectArchive -> ProjectManager.import(action.archiveFile)
233+
}
228234
_selectedFilePath.value = project.sourceFilePath
229235
_currentProject.value = project
230236
}
@@ -425,6 +431,29 @@ internal fun sanitizeImportedFileName(displayName: String?): String {
425431
return normalized ?: "openDirect"
426432
}
427433

434+
internal sealed class ImportedUriAction {
435+
data class CreateProject(
436+
val importedFile: File,
437+
val sourceDescriptor: ProjectSourceDescriptor
438+
) : ImportedUriAction()
439+
440+
data class ImportProjectArchive(val archiveFile: File) : ImportedUriAction()
441+
}
442+
443+
internal fun determineImportedUriAction(importedFile: File, sourceUriLocation: String): ImportedUriAction {
444+
return when (val action = determineProjectOpenAction(importedFile, openAsProject = true)) {
445+
is ProjectOpenAction.ImportProjectArchive -> ImportedUriAction.ImportProjectArchive(action.archiveFile)
446+
is ProjectOpenAction.OpenExistingProject,
447+
ProjectOpenAction.PromptCopy -> ImportedUriAction.CreateProject(
448+
importedFile = importedFile,
449+
sourceDescriptor = ProjectSourceDescriptor(
450+
ProjectSourceKind.CONTENT_URI,
451+
sourceUriLocation
452+
)
453+
)
454+
}
455+
}
456+
428457
internal fun resolveImportedDestinationFile(importsDir: File, displayName: String?): File {
429458
val sanitizedName = sanitizeImportedFileName(displayName)
430459
var candidate = importsDir.resolve(sanitizedName)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.kyhsgeekcode.disassembler.viewmodel
2+
3+
import com.kyhsgeekcode.disassembler.project.models.ProjectSourceKind
4+
import java.io.File
5+
import java.nio.file.Path
6+
import java.util.zip.ZipEntry
7+
import java.util.zip.ZipOutputStream
8+
import kotlin.io.path.createTempDirectory
9+
import kotlin.test.Test
10+
import kotlin.test.assertEquals
11+
import kotlin.test.assertIs
12+
13+
class ImportedUriActionTest {
14+
@Test
15+
fun `determineImportedUriAction imports project archive when copied file is project zip`() {
16+
val archive = createProjectArchive(createTempDirectory("imported-uri"))
17+
18+
val action = determineImportedUriAction(
19+
importedFile = archive,
20+
sourceUriLocation = "content://test/project.zip"
21+
)
22+
23+
val importArchive = assertIs<ImportedUriAction.ImportProjectArchive>(action)
24+
assertEquals(archive, importArchive.archiveFile)
25+
}
26+
27+
@Test
28+
fun `determineImportedUriAction creates project with content uri descriptor for regular file`() {
29+
val file = createTempDirectory("imported-uri").toFile().resolve("sample.apk").apply {
30+
writeText("apk")
31+
}
32+
33+
val action = determineImportedUriAction(
34+
importedFile = file,
35+
sourceUriLocation = "content://test/sample.apk"
36+
)
37+
38+
val createProject = assertIs<ImportedUriAction.CreateProject>(action)
39+
assertEquals(file, createProject.importedFile)
40+
assertEquals(ProjectSourceKind.CONTENT_URI, createProject.sourceDescriptor.kind)
41+
assertEquals("content://test/sample.apk", createProject.sourceDescriptor.location)
42+
}
43+
44+
private fun createProjectArchive(tempDir: Path): File {
45+
return tempDir.toFile().resolve("project.zip").apply {
46+
ZipOutputStream(outputStream()).use { zip ->
47+
zip.putNextEntry(ZipEntry("project_info.json"))
48+
zip.write("{}".encodeToByteArray())
49+
zip.closeEntry()
50+
zip.putNextEntry(ZipEntry("sourceFilePath"))
51+
zip.write("sample".encodeToByteArray())
52+
zip.closeEntry()
53+
}
54+
}
55+
}
56+
}

docs/maintenance/backlog-triage.ko.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333

3434
| 작업 묶음 | 관련 이슈 | 제안 상태 | 판단 | 다음 액션 |
3535
| --- | --- | --- | --- | --- |
36-
| 최신 Android storage 정책 | `#95` | `planned-fast-follow` | 핵심 유지보수 항목이며 이미 SAF 전환을 시작했다 | 앱 전체 import/open 경로를 SAF 중심으로 계속 이관 |
36+
| 최신 Android storage 정책 | `#95` | `planned-fast-follow` | 핵심 유지보수 항목이며 이미 SAF 전환, incoming `ACTION_VIEW`/`EXTRA_STREAM` instrumentation 회귀 검증, 외부 공유 project ZIP의 reopen 경계까지 보강했다 | 앱 전체 import/open 경로를 SAF 중심으로 계속 이관하고 남은 실기기 경계를 확인 |
3737
| 릴리스 산출물 부재 | `#719` | `planned-fast-follow` | 코드 문제보다 릴리스 파이프라인 문제다 | CI artifact, preview prerelease, formal release 흐름으로 운영 정리 |
3838
| 대용량/메모리/RecyclerView 크래시 | `#219`, `#235`, `#442`, `#523` | `planned-fast-follow` | `#728`에서 큰 파일 byte cache 제한과 문자열 검색 결과 상한/stable key를 먼저 넣었다 | `#728` 병합 후 실제 150MB 파일과 긴 문자열 리스트로 재검증하고 나머지 OOM 경로를 분리 |
3939
| `.so`/ELF/autosetup | `#514`, `#543`, `#576`, `#137` | `planned-fast-follow` | `#728`에서 64-bit ELF machine type 매핑과 override autosetup 재적용 경로를 먼저 수정했다 | 실제 `.so` 샘플로 재검증하고 남는 parser 문제만 분리 |

docs/maintenance/implementation-log.ko.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
| 이슈 `#719` 최신판 체험 경로 부족 | rolling `Preview latest` prerelease를 발행하는 `preview.yml`을 추가하고 README에 preview/debug 배포 정책을 문서화 | CI artifact만 아는 사람만 최신판을 받던 상태에서, GitHub Releases의 prerelease 자산으로도 최신 debug 빌드를 쉽게 배포할 수 있게 했다 | `.github/workflows/preview.yml`, `README.md` | 완료 |
1616
| 저장소 정책 현대화 착수, 이슈 `#95` 대응 기반 | SAF 기반 선택 경로를 도입하고 `content://` 입력을 앱 내부 import 파일로 저장 | 외부 절대경로 전제를 줄이고, 재시작 이후에도 다시 열 수 있는 URI 권한 흐름을 시작했다 | `app/src/main/java/com/kyhsgeekcode/filechooser/NewFileChooserActivity.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/viewmodel/MainViewModel.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/PermissionUtils.kt` | 진행 중 |
1717
| 이슈 `#95` 외부 문서 인텐트 권한 누락 | 앱이 `ACTION_VIEW``EXTRA_STREAM`으로 열린 경우에도 persistable grant 가능 여부를 계산하고 `content://` URI 권한을 선제적으로 유지 | SAF picker 밖에서 들어온 문서도 같은 storage 정책 흐름으로 흡수해서, provider가 허용하는 경우 앱 재실행 이후에도 접근이 끊길 가능성을 줄였다 | `app/src/main/java/com/kyhsgeekcode/disassembler/MainActivity.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/PermissionUtils.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/PermissionUtilsTest.kt` | 완료 |
18+
| 이슈 `#95` incoming content intent 회귀 검증 부재 | `ACTION_VIEW``EXTRA_STREAM`으로 들어오는 `content://` 문서를 직접 주입하는 instrumentation fixture와 flow test를 추가했다 | picker 기반 SAF import만 보던 기존 merge gate를 넓혀서, 외부 앱에서 전달된 문서가 실제 프로젝트로 열리고 activity recreate 뒤에도 UI가 살아 있는지 CI에서 계속 감시할 수 있게 했다 | `app/src/androidTest/java/com/kyhsgeekcode/disassembler/DocumentIntentFixtures.kt`, `app/src/androidTest/java/com/kyhsgeekcode/disassembler/MainActivityActionViewIntentFlowTest.kt`, `app/src/androidTest/java/com/kyhsgeekcode/disassembler/MainActivityExtraStreamIntentFlowTest.kt` | 완료 |
19+
| 이슈 `#95` 외부 공유 project ZIP reopen 경계 | content URI로 들어온 app-private 복사본이 project archive인지 다시 판별하는 helper를 추가하고, archive면 일반 파일 import 대신 `ProjectManager.import(...)`로 연결했다. `ACTION_VIEW` project archive instrumentation도 추가했다 | 외부 앱이 exported project ZIP을 공유했을 때 `ArchiveFixture.zip` 같은 일반 파일 프로젝트가 생기지 않고, 실제 imported project model이 열리도록 고정했다 | `app/src/main/java/com/kyhsgeekcode/disassembler/viewmodel/MainViewModel.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/viewmodel/ImportedUriActionTest.kt`, `app/src/androidTest/java/com/kyhsgeekcode/disassembler/MainActivityActionViewProjectArchiveIntentFlowTest.kt` | 완료 |
1820
| 이슈 `#95` legacy 저장소 권한 범위 축소 | import entry-point별 legacy 권한 요구를 분리하고, `Advanced import`만 Android 9 이하에서 권한 요청을 하도록 변경 | 기본 SAF import는 어떤 지원 SDK에서도 저장소 권한을 요구하지 않게 고정하고, 구형 Android의 raw filesystem 진입점에서만 legacy 권한 모델을 제한적으로 유지했다 | `app/src/main/java/com/kyhsgeekcode/disassembler/importing/ImportEntryPointCatalog.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/ui/MainTab.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/importing/ImportEntryPointCatalogTest.kt` | 완료 |
1921
| 프로젝트 경계의 raw path 의존 | `ProjectModel`에 source helper를 추가하고 프로젝트 트리/리스트/저장소가 helper를 사용하도록 교체 | `sourceFilePath` 문자열 조합을 한곳으로 모아서 이후 `sourceDescriptor` 중심 구조로 더 옮기기 쉬운 상태를 만들었다 | `app/src/main/java/com/kyhsgeekcode/disassembler/project/models/ProjectModel.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/ui/FileDrawerTree.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/FileDrawerListAdapter.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/FileDrawerListItem.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/project/ProjectDataStorage.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/viewmodel/MainViewModel.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/ProjectManagerTest.kt` | 완료 |
2022
| broad storage 우회 경로 정리 | 더 이상 쓰지 않는 legacy picker 코드와 Android 11+의 `MANAGE_ALL_FILES_ACCESS` 유도 설정을 제거 | SAF 기반 경로를 기본 흐름으로 고정하고, 정책상 불리한 all-files access 진입점을 줄였다 | `app/src/main/java/com/kyhsgeekcode/disassembler/MainActivity.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/preference/SettingsFragment.kt`, `app/src/main/res/xml/pref_settings.xml`, `app/src/main/res/xml-v30/pref_settings.xml`, `app/src/main/res/values/array.xml` | 완료 |

0 commit comments

Comments
 (0)