diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
deleted file mode 100644
index 68f484a..0000000
--- a/android/app/src/debug/AndroidManifest.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
diff --git a/android/app/src/debug/res/xml/network_security_config.xml b/android/app/src/debug/res/xml/network_security_config.xml
deleted file mode 100644
index c7dd079..0000000
--- a/android/app/src/debug/res/xml/network_security_config.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 3ab1cbc..2b95fd3 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
+ android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..c28dfd9
--- /dev/null
+++ b/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+ zennotes.org
+ zennotes.laravel.cloud
+
+
+
+
+
diff --git a/src/bridge/network-security-config.test.ts b/src/bridge/network-security-config.test.ts
new file mode 100644
index 0000000..f69ca6d
--- /dev/null
+++ b/src/bridge/network-security-config.test.ts
@@ -0,0 +1,73 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { it } from 'node:test'
+
+// The app-wide Network Security Configuration (zennotesandroid#140, #141)
+// trusts user-installed CAs so a self-hosted server behind a private CA
+// connects, and keeps ZenNotes Cloud on the system store alone. Cloud moved
+// hosts once already (zennotes.laravel.cloud to zennotes.org), so the Cloud
+// hosts the policy names are checked against the URLs the app really uses.
+
+const root = new URL('../../', import.meta.url)
+const read = (path: string) => readFileSync(new URL(path, root), 'utf8')
+const policy = read('android/app/src/main/res/xml/network_security_config.xml').replace(
+ //g,
+ ''
+)
+
+interface TrustConfig {
+ cleartext: string | null
+ anchors: string[]
+ domains: { name: string; includeSubdomains: boolean }[]
+}
+
+function parse(tag: 'base-config' | 'domain-config'): TrustConfig[] {
+ return [...policy.matchAll(new RegExp(`<${tag}([^>]*)>([\\s\\S]*?)${tag}>`, 'g'))].map(
+ ([, attributes, body]) => ({
+ cleartext: /cleartextTrafficPermitted="(\w+)"/.exec(attributes)?.[1] ?? null,
+ anchors: [...body.matchAll(/ m[1]),
+ domains: [...body.matchAll(/]*)>\s*([^<\s]+)\s*<\/domain>/g)].map((m) => ({
+ name: m[2],
+ includeSubdomains: /includeSubdomains="true"/.test(m[1])
+ }))
+ })
+ )
+}
+
+function cloudHosts(): string[] {
+ const source = read('src/bridge/mobile-cloud-auth.ts')
+ return ['PRODUCTION_CLOUD_BASE_URL', 'LEGACY_CLOUD_BASE_URL'].map((name) => {
+ const url = new RegExp(`const ${name} = '([^']+)'`).exec(source)?.[1]
+ assert.ok(url, `${name} not found in mobile-cloud-auth.ts`)
+ return new URL(url).hostname
+ })
+}
+
+it('trusts system and user CAs and allows plain HTTP for self-hosted servers', () => {
+ const [base, ...extra] = parse('base-config')
+ assert.equal(extra.length, 0)
+ assert.deepEqual(base.anchors.sort(), ['system', 'user'])
+ assert.equal(base.cleartext, 'true')
+})
+
+it('keeps every ZenNotes Cloud host on system trust only, over HTTPS', () => {
+ const configs = parse('domain-config')
+ for (const host of cloudHosts()) {
+ const matching = configs.filter((config) =>
+ config.domains.some(
+ (d) => host === d.name || (d.includeSubdomains && host.endsWith(`.${d.name}`))
+ )
+ )
+ assert.equal(matching.length, 1, `${host} needs exactly one domain-config`)
+ assert.deepEqual(matching[0].anchors, ['system'], `${host} must not trust user CAs`)
+ assert.equal(matching[0].cleartext, 'false', `${host} must refuse plain HTTP`)
+ }
+})
+
+it('is the one policy, declared by the main manifest for every build type', () => {
+ assert.match(
+ read('android/app/src/main/AndroidManifest.xml'),
+ /android:networkSecurityConfig="@xml\/network_security_config"/
+ )
+ assert.doesNotMatch(policy, /