The app worked on Android 9 and dies on the same phone after the Android 10 update. Your profile is untouched, the certificates are the same, and the crash arrives before any authentication traffic. Logcat shows a null pointer first, and a few lines above it sits Cannot run program ...: error=13, Permission denied. That message is about executing a file, not about reading your configuration.

What error 13 is really refusing

Android 10, API level 29, stopped apps from executing files they wrote into their own data directory. The rule targets writable-then-executable storage, which is exactly what a copy-out-of-assets launcher creates. Older builds of the ics-openvpn core unpack an openvpn executable from the APK on first run and start it with ProcessBuilder. The unpack still succeeds; the exec is what gets refused. An app that targets API 29 inherits the rule even when it runs on a device that shipped with an older release, which is why an update can break a build that nobody touched.

Nothing about the refusal is specific to one folder name. A file the app created under its data directory is treated as non-executable regardless of the path, so getFilesDir(), getCacheDir() and a custom subdirectory all fail the same way.

Separate the helper crash from a profile problem

Read the log around the process start instead of scrolling from the top. The signature to search for is the error=13 line, and the stack trace above it usually names the launcher method that called ProcessBuilder:

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

If the helper never started, you will see no OpenVPN log output at all: no cipher negotiation, no pushed routes, no DNS configuration. If those lines do appear and the failure comes later, the binary ran and the problem is in the profile, the server or the routing layer. That single distinction saves a lot of time spent editing .ovpn files that were never at fault. It also tells you which of the two fixes below you actually need.

Also check the API level on the device you are testing, not the one in your build config. An emulator image on API 28 reproduces nothing, because the restriction only bites from 29 upward. Check the target SDK in your build file at the same time: raising it is what pulls the rule in.

adb shell getprop ro.build.version.sdk

Why copying the binary elsewhere does not help

The obvious workaround, writing the helper to a different writable directory, fails for the same reason the original did. Android still permits executing shared libraries that were installed into the app's native library directory, because those are packaged by the installer rather than written at runtime. That directory is the one place the launcher should be pointing at. Anything you extract yourself at runtime, however you name it, stays in the refused category.

External storage is worse: it is mounted noexec, and a helper placed there cannot be started by any app. Clearing app data does not restore the old behaviour either, since the next launch simply recreates the same non-executable copy.

Point the launcher at a packaged library

Rename the helper to a shared-library name and ship it under jniLibs for every ABI you support, then have the launcher return the installed library path from API 28 onward:

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

The library has to be extracted at install time for that path to contain a real file, so set android:extractNativeLibs="true" on the <application> element. If a manifest merger pulls in a different value, add tools:replace="android:extractNativeLibs" next to it. The application element reference documents what the attribute controls.

Watch for a duplicated local variable when you patch the launcher by hand. Older sources declare nativeAPI later in the same method, and a second declaration is a compile error rather than a runtime one, so the build fails fast and the mistake is easy to spot. Keep the ABI list in the Gradle block in sync with the folders you add.

Make the release artifact match the test build

A debug APK can start the tunnel while a release bundle still crashes, because a bundle packs native libraries in a different shape and can serve them compressed from the archive. If only bundle-derived builds fail, add this to gradle.properties and rebuild:

android.bundle.enableUncompressedNativeLibs=false

Then confirm the installed package reports the ABI you expect, and repeat the check on a 32-bit and a 64-bit device if you ship both. The package manager names the ABI it selected, so a device that fell back to 32-bit shows up here instead of in a crash report:

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

A missing library for one ABI reproduces the original permission error on that device alone, which is why a single successful test phone is not proof. Ship the manifest flag, the library name and the bundle setting together; changing one and forgetting the others puts you back at the same crash. The lasting fix is to move to a maintained core release rather than carrying the patch forever.

Artihkkalat mat laktásit