앱은 Android 9에서 동작했고 Android 10으로 업데이트한 뒤 같은 휴대폰에서 죽습니다. 프로필은 그대로이고 인증서도 같으며, 크래시는 어떤 인증 트래픽보다 먼저 옵니다. logcat은 먼저 널 포인터를 보여 주고, 그 몇 줄 위에 Cannot run program ...: error=13, Permission denied이 있습니다. 그 메시지는 파일 실행에 관한 것이지 설정 읽기에 관한 것이 아닙니다.

오류 13이 실제로 거부하는 것

Android 10, 즉 API 레벨 29는 앱이 자신의 데이터 디렉터리에 쓴 파일을 실행하지 못하게 막았습니다. 이 규칙은 먼저 쓰기가 가능하고 그다음 실행이 가능한 저장소를 겨냥하며, 바로 그것이 assets에서 복사하는 런처가 만드는 대상입니다. ics-openvpn 코어의 오래된 빌드는 첫 실행 때 APK에서 실행 파일 openvpn을 풀고 ProcessBuilder로 시작합니다. 압축 해제는 여전히 성공하고, 거부되는 것은 실행입니다. API 29를 대상으로 하는 앱은 더 오래된 릴리스로 출하된 기기에서 실행되어도 이 규칙을 물려받기 때문에, 아무도 건드리지 않은 빌드가 업데이트로 깨질 수 있습니다.

이 거부에 특정 폴더 이름만의 특징은 없습니다. 앱이 자기 데이터 디렉터리 아래에 만든 파일은 경로와 상관없이 실행 불가로 취급되므로, getFilesDir(), getCacheDir() 그리고 사용자 지정 하위 디렉터리가 모두 같은 방식으로 실패합니다.

도우미 크래시와 프로필 문제를 분리하세요

위에서 스크롤하는 대신 프로세스 시작 전후의 로그를 읽으세요. 찾아야 할 서명은 error=13 줄이고, 그 위의 스택 트레이스는 보통 ProcessBuilder을 호출한 런처 메서드를 지목합니다:

adb logcat -d | grep -n -i -E "cannot run program|permission denied|startOpenVPN"

도우미가 한 번도 시작되지 않았다면 OpenVPN 로그 출력이 전혀 보이지 않습니다: 암호 협상도, 푸시된 경로도, DNS 설정도 없습니다. 반대로 그 줄들이 나타나고 실패가 나중에 온다면 바이너리는 실행된 것이고 문제는 프로필, 서버 또는 라우팅 계층에 있습니다. 이 한 가지 구분만으로 결코 원인이 아니었던 .ovpn 파일을 편집하느라 쓸 시간을 크게 아낄 수 있습니다. 아래 두 수정 중 무엇이 정말 필요한지도 알려 줍니다.

테스트하는 기기의 API 레벨을, 빌드 설정에 있는 값이 아니라, 확인하세요. API 28의 에뮬레이터 이미지는 이 제한이 29부터 위로만 작용하기 때문에 아무것도 재현하지 못합니다. 동시에 빌드 파일의 대상 SDK도, 그것을 올리는 것이 이 규칙을 끌어들이므로 확인하세요.

adb shell getprop ro.build.version.sdk

바이너리를 다른 곳으로 복사하는 것이 도움이 되지 않는 이유

뻔한 우회책, 즉 도우미를 다른 쓰기 가능한 디렉터리에 쓰는 방법은 원본이 실패한 것과 같은 이유로 실패합니다. Android는 설치 프로그램이 패키징하는 것이지 실행 시점에 기록되는 것이 아니기 때문에, 앱의 네이티브 라이브러리 디렉터리에 설치된 공유 라이브러리 실행을 여전히 허용합니다. 런처가 가리켜야 할 곳은 바로 그 디렉터리입니다. 실행 시점에 직접 푸는 것은 무엇이라 부르든 거부되는 분류에 남습니다.

외부 저장소는 더 나쁩니다: noexec로 마운트되어 있어 그곳에 둔 도우미는 어떤 앱도 시작할 수 없습니다. 앱 데이터를 지워도 예전 동작이 돌아오지 않고, 다음 실행에서 같은 실행 불가 사본이 다시 만들어질 뿐입니다.

런처를 패키징된 라이브러리로 향하게 하세요

도우미 이름을 공유 라이브러리 이름으로 바꾸고 지원하는 모든 ABI에 대해 jniLibs 아래로 배포한 다음, 런처가 API 28부터 설치된 라이브러리 경로를 반환하게 하세요:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
    return new File(context.getApplicationInfo().nativeLibraryDir,
                    "libovpnexec.so").getPath();
}

그 경로에 실제 파일이 있으려면 라이브러리가 설치 시점에 풀려 있어야 하므로, android:extractNativeLibs="true"를 <application> 요소에 설정하세요. 매니페스트 병합기가 다른 값을 가져오면 그 옆에 tools:replace="android:extractNativeLibs"를 추가하세요. application 요소 참조에는 이 속성이 무엇을 제어하는지 나와 있습니다.

런처를 직접 패치할 때 지역 변수 중복을 조심하세요. 오래된 소스는 같은 메서드 뒤쪽에서 nativeAPI을 선언하며, 두 번째 선언은 런타임 오류가 아니라 컴파일 오류이므로 빌드가 빠르게 실패하고 실수도 쉽게 눈에 띕니다. Gradle 블록의 ABI 목록은 추가하는 폴더와 맞춰 두세요.

릴리스 산출물을 테스트 빌드와 일치시키세요

디버그 APK는 터널을 올리는데 릴리스 bundle은 여전히 크래시할 수 있는데, bundle이 네이티브 라이브러리를 다른 형태로 묶고 아카이브에서 압축된 채로 제공할 수 있기 때문입니다. bundle에서 나온 빌드만 실패한다면 이것을 gradle.properties에 추가하고 다시 빌드하세요:

android.bundle.enableUncompressedNativeLibs=false

그다음 설치된 패키지가 기대하는 ABI를 보고하는지 확인하고, 둘 다 배포한다면 32비트와 64비트 기기에서 같은 점검을 반복하세요. 패키지 관리자는 선택한 ABI를 알려 주므로, 32비트로 되돌아간 기기는 크래시 보고서가 아니라 여기에 나타납니다:

adb shell dumpsys package com.example.openvpn | grep -i primaryCpuAbi

한 ABI에 대한 라이브러리가 없으면 그 기기에서만 원래의 권한 오류가 재현되므로, 테스트 휴대폰 한 대의 성공은 증거가 아닙니다. 매니페스트 플래그, 라이브러리 이름, bundle 설정을 함께 배포하고, 하나만 바꾸고 나머지를 잊으면 같은 크래시로 돌아갑니다. 오래 가는 해결책은 패치를 영원히 안고 가는 대신 유지 관리되는 코어 릴리스로 옮기는 것입니다.

관련 글