Приложение работало на 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, и помощник, положенный туда, не сможет запустить ни одно приложение. Очистка данных приложения тоже не возвращает прежнее поведение, ведь следующий запуск просто создаёт ту же неисполняемую копию.

Направьте загрузчик на упакованную библиотеку

Переименуйте помощник в имя разделяемой библиотеки и поставляйте его в jniLibs для каждой поддерживаемой ABI, а затем заставьте загрузчик возвращать путь установленной библиотеки начиная с 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 позже в том же методе, а второе объявление — ошибка компиляции, а не времени выполнения, поэтому сборка падает быстро и ошибку легко заметить. Держите список ABI в блоке Gradle в соответствии с добавляемыми каталогами.

Приведите артефакт релиза в соответствие с тестовой сборкой

Отладочный 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 вместе; меняя одно и забывая остальные, вы возвращаетесь к тому же сбою. Долговременное решение — перейти на поддерживаемый релиз ядра, а не таскать заплатку вечно.

Похожие статьи