[PR #106] Add subscription sync, spaces, and per-item sharing #103

Open
opened 2026-10-13 21:06:08 +00:00 by bsakaguchi · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/amnezia-vpn/amneziawg-android/pull/106
Author: @uussoop
Created: 9/29/2026
Status: 🔄 Open

Base: master ← Head: master


📝 Commits (2)

  • 72a461f fix: bump libwg-go toolchain to Go 1.25.0
  • 07ba1b8 feat: add subscription sync, spaces, and per-item sharing

📊 Changes

34 files changed (+2365 additions, -65 deletions)

View changed files

📝 gradle/libs.versions.toml (+1 -0)
📝 tunnel/tools/libwg-go/Makefile (+5 -4)
📝 ui/build.gradle.kts (+1 -0)
📝 ui/src/main/AndroidManifest.xml (+1 -0)
📝 ui/src/main/java/org/amnezia/awg/activity/MainActivity.kt (+38 -0)
📝 ui/src/main/java/org/amnezia/awg/fragment/AddTunnelsSheet.kt (+9 -0)
➕ ui/src/main/java/org/amnezia/awg/fragment/QrCodeDialogFragment.kt (+121 -0)
➕ ui/src/main/java/org/amnezia/awg/fragment/SubscriptionDialogFragment.kt (+331 -0)
📝 ui/src/main/java/org/amnezia/awg/fragment/TunnelListFragment.kt (+189 -14)
➕ ui/src/main/java/org/amnezia/awg/fragment/TunnelShare.kt (+108 -0)
➕ ui/src/main/java/org/amnezia/awg/model/Subscription.kt (+28 -0)
➕ ui/src/main/java/org/amnezia/awg/subscription/SubscriptionFetcher.kt (+286 -0)
➕ ui/src/main/java/org/amnezia/awg/subscription/SubscriptionStore.kt (+123 -0)
➕ ui/src/main/java/org/amnezia/awg/subscription/SubscriptionSyncManager.kt (+336 -0)
📝 ui/src/main/java/org/amnezia/awg/util/ClipboardUtils.kt (+23 -0)
➕ ui/src/main/java/org/amnezia/awg/util/QrCodeRenderer.kt (+88 -0)
➕ ui/src/main/java/org/amnezia/awg/util/ShareUtils.kt (+160 -0)
➕ ui/src/main/res/drawable/ic_action_more.xml (+10 -0)
➕ ui/src/main/res/drawable/ic_action_subscription.xml (+10 -0)
➕ ui/src/main/res/drawable/ic_action_sync.xml (+10 -0)

...and 14 more files

📄 Description

Summary

Adds three related things to the tunnel list: importing tunnels from a provider
subscription URL, grouping them into spaces, and a per-item share action.

Everything here hands out credentials, so the security behaviour is called out
in detail below rather than buried.

Subscriptions

Import a tunnel list from a URL. Accepted payloads are a plain .conf
concatenation, a .zip of configs, or a JSON array of {name, config}. They
are stored in the existing DataStore.

  • Sync reconciles rather than duplicates. A tunnel that already exists is
    updated in place, so the switch position and state survive a sync. Tunnels the
    provider has dropped are removed.
  • A failed fetch changes nothing. A provider outage, a 500, or a truncated
    body leaves the existing tunnel list exactly as it was. There is no path where a
    bad response empties the list.
  • Hand-made tunnels are never touched. Sync only acts on tunnels the
    subscription owns.
  • Name collisions get a numeric suffix rather than overwriting an existing
    tunnel.
  • Removing a subscription removes its tunnels, permanently, after a
    confirmation that states how many.

Spaces

A Default space for hand-made tunnels, one per subscription, plus All. The
tabs filter the list; they are not containers, so a tunnel can be moved between
spaces without a re-sync.

Sharing

Every tunnel row and every subscription row has a share action offering copy,
QR code, and save to file.

  • A tunnel configuration contains a private key, so all three actions
    authenticate first. Nothing is read from disk until authentication succeeds, so
    a declined or cancelled prompt never touches the key.
  • A subscription URL is a credential but not a private key, so it is
    confirmed with a warning rather than put behind biometrics. I think this is the
    right line to draw — locking a URL behind a fingerprint is friction the
    threat model does not justify — but it is a judgement call and easy to change.
  • The QR dialog states what the code actually contains, and keeps the text
    selectable as a fallback for payloads too long to encode. The rendered code
    has been verified to actually decode, not just render.

Config export honours the existing disableConfigExport app restriction, so a
policy that forbids zipping configurations also forbids handing them out one at
a time.

Security behaviour change — please look at this one

network_security_config.xml refuses cleartext HTTP by default, which is a
behaviour change for any user whose provider is currently served over plain HTTP.
I think this belongs here rather than being left to the provider: a subscription
URL is a bearer token for the user's private keys, and sending it in the clear is
a real exposure. But it will break HTTP-only providers, and it is the kind of
change a maintainer should agree to deliberately.

The exceptions are loopback and the emulator host aliases (localhost,
127.0.0.1, 10.0.2.2, 10.0.3.2), so a self-hosted provider on a local
network can still be tested. Android's config cannot express CIDR ranges, so
arbitrary RFC1918 addresses are not covered — a provider at, say,
192.168.1.10 over HTTP will still be rejected. That is a deliberate limit
rather than an oversight.

If you would rather not take this on, it is self-contained in one new XML file
plus a manifest line, and the feature works without it — it would just be less
safe by default.

Testing

Driven on an emulator (emulator-5554, Android 12) with a local fixture
provider. 56 checks across four suites, all passing:

suite checks covers
share 33 menus, biometric gate, copy, QR decode, file export, both row types
subscription 13 add, unchanged, update-in-place, removal, isolation, failure safety, cascade delete
formats 4 .conf, .zip, JSON, unparseable payload
spaces 6 filtering, per-space empty state

Two assertions are worth calling out because weaker ones would have passed:

  • The QR is decoded, not just rendered. The suite pulls the bitmap off the
    device and runs zxing on it, then checks the decoded text is a complete
    configuration. An ImageView existing proves nothing about whether the code
    scans.
  • The exported file is compared by checksum against the tunnel's own config,
    and the exact filename is asserted — this caught Android's MediaStore appending
    .txt to DE-FRA.conf because no MIME type maps to .conf.

The biometric gate was tested with a real device PIN: the prompt appears, nothing
is copied before authenticating, and the copy happens after. On a device with no
credential it degrades to allowing the share, matching the existing
ZipExporterPreference behaviour.

Known gaps

  • The TV layout has no share button. tv_tunnel_list_item.xml is a separate
    layout and this only covers the phone layout. I skipped it because the TV list
    is remote-control driven with a fixed-size card, so a per-row share button is a
    real design problem rather than a copy of the phone one. Happy to take it on as
    a follow-up.
  • Startup sync is not wired up. Sync is on demand only, from the toolbar and
    from each subscription row.
  • Translations other than the default locale fall back to English for the new
    strings, same as any other string added to this project.

Note on the commits

This PR has two commits, and the first is unrelated to the feature:

  1. fix: bump libwg-go toolchain to Go 1.25.0 — go.mod requires 1.25.0 but the
    Makefile pinned 1.24.2, so the NDK cross build does not start. The feature
    does not build without this
    , which is why it is here rather than in a
    separate PR. If you would rather merge it on its own, it is a clean
    one-commit cherry-pick and I will rebase the feature on top.
  2. feat: add subscription sync, spaces, and per-item sharing — everything else.

Provider contract

The wire format the app expects is documented in a separate spec I have not
included here. It is a JSON array of {name, config} or a .zip of .conf
files, and a sync is a plain unauthenticated GET unless the URL carries its own
token. Happy to add the document to this repo if it is useful.


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/amnezia-vpn/amneziawg-android/pull/106 **Author:** [@uussoop](https://github.com/uussoop) **Created:** 9/29/2026 **Status:** 🔄 Open **Base:** `master` ← **Head:** `master` --- ### 📝 Commits (2) - [`72a461f`](https://github.com/amnezia-vpn/amneziawg-android/commit/72a461f29eb8518f487e62db5d23ab89f194359e) fix: bump libwg-go toolchain to Go 1.25.0 - [`07ba1b8`](https://github.com/amnezia-vpn/amneziawg-android/commit/07ba1b87111637b6454d1ff3b03f0e8106d4e3d2) feat: add subscription sync, spaces, and per-item sharing ### 📊 Changes **34 files changed** (+2365 additions, -65 deletions) <details> <summary>View changed files</summary> 📝 `gradle/libs.versions.toml` (+1 -0) 📝 `tunnel/tools/libwg-go/Makefile` (+5 -4) 📝 `ui/build.gradle.kts` (+1 -0) 📝 `ui/src/main/AndroidManifest.xml` (+1 -0) 📝 `ui/src/main/java/org/amnezia/awg/activity/MainActivity.kt` (+38 -0) 📝 `ui/src/main/java/org/amnezia/awg/fragment/AddTunnelsSheet.kt` (+9 -0) ➕ `ui/src/main/java/org/amnezia/awg/fragment/QrCodeDialogFragment.kt` (+121 -0) ➕ `ui/src/main/java/org/amnezia/awg/fragment/SubscriptionDialogFragment.kt` (+331 -0) 📝 `ui/src/main/java/org/amnezia/awg/fragment/TunnelListFragment.kt` (+189 -14) ➕ `ui/src/main/java/org/amnezia/awg/fragment/TunnelShare.kt` (+108 -0) ➕ `ui/src/main/java/org/amnezia/awg/model/Subscription.kt` (+28 -0) ➕ `ui/src/main/java/org/amnezia/awg/subscription/SubscriptionFetcher.kt` (+286 -0) ➕ `ui/src/main/java/org/amnezia/awg/subscription/SubscriptionStore.kt` (+123 -0) ➕ `ui/src/main/java/org/amnezia/awg/subscription/SubscriptionSyncManager.kt` (+336 -0) 📝 `ui/src/main/java/org/amnezia/awg/util/ClipboardUtils.kt` (+23 -0) ➕ `ui/src/main/java/org/amnezia/awg/util/QrCodeRenderer.kt` (+88 -0) ➕ `ui/src/main/java/org/amnezia/awg/util/ShareUtils.kt` (+160 -0) ➕ `ui/src/main/res/drawable/ic_action_more.xml` (+10 -0) ➕ `ui/src/main/res/drawable/ic_action_subscription.xml` (+10 -0) ➕ `ui/src/main/res/drawable/ic_action_sync.xml` (+10 -0) _...and 14 more files_ </details> ### 📄 Description ## Summary Adds three related things to the tunnel list: importing tunnels from a provider subscription URL, grouping them into spaces, and a per-item share action. Everything here hands out credentials, so the security behaviour is called out in detail below rather than buried. ## Subscriptions Import a tunnel list from a URL. Accepted payloads are a plain `.conf` concatenation, a `.zip` of configs, or a JSON array of `{name, config}`. They are stored in the existing DataStore. - **Sync reconciles rather than duplicates.** A tunnel that already exists is updated in place, so the switch position and state survive a sync. Tunnels the provider has dropped are removed. - **A failed fetch changes nothing.** A provider outage, a 500, or a truncated body leaves the existing tunnel list exactly as it was. There is no path where a bad response empties the list. - **Hand-made tunnels are never touched.** Sync only acts on tunnels the subscription owns. - **Name collisions get a numeric suffix** rather than overwriting an existing tunnel. - **Removing a subscription removes its tunnels**, permanently, after a confirmation that states how many. ## Spaces A `Default` space for hand-made tunnels, one per subscription, plus `All`. The tabs filter the list; they are not containers, so a tunnel can be moved between spaces without a re-sync. ## Sharing Every tunnel row and every subscription row has a share action offering **copy**, **QR code**, and **save to file**. - A tunnel configuration **contains a private key**, so all three actions authenticate first. Nothing is read from disk until authentication succeeds, so a declined or cancelled prompt never touches the key. - A subscription URL is a credential but **not** a private key, so it is confirmed with a warning rather than put behind biometrics. I think this is the right line to draw — locking a URL behind a fingerprint is friction the threat model does not justify — but it is a judgement call and easy to change. - The QR dialog states what the code actually contains, and keeps the text selectable as a fallback for payloads too long to encode. The rendered code has been verified to actually decode, not just render. Config export honours the existing `disableConfigExport` app restriction, so a policy that forbids zipping configurations also forbids handing them out one at a time. ## Security behaviour change — please look at this one `network_security_config.xml` **refuses cleartext HTTP by default**, which is a behaviour change for any user whose provider is currently served over plain HTTP. I think this belongs here rather than being left to the provider: a subscription URL is a bearer token for the user's private keys, and sending it in the clear is a real exposure. But it will break HTTP-only providers, and it is the kind of change a maintainer should agree to deliberately. The exceptions are loopback and the emulator host aliases (`localhost`, `127.0.0.1`, `10.0.2.2`, `10.0.3.2`), so a self-hosted provider on a local network can still be tested. Android's config cannot express CIDR ranges, so arbitrary RFC1918 addresses are *not* covered — a provider at, say, `192.168.1.10` over HTTP will still be rejected. That is a deliberate limit rather than an oversight. If you would rather not take this on, it is self-contained in one new XML file plus a manifest line, and the feature works without it — it would just be less safe by default. ## Testing Driven on an emulator (`emulator-5554`, Android 12) with a local fixture provider. 56 checks across four suites, all passing: | suite | checks | covers | |---|---|---| | share | 33 | menus, biometric gate, copy, QR decode, file export, both row types | | subscription | 13 | add, unchanged, update-in-place, removal, isolation, failure safety, cascade delete | | formats | 4 | `.conf`, `.zip`, JSON, unparseable payload | | spaces | 6 | filtering, per-space empty state | Two assertions are worth calling out because weaker ones would have passed: - The **QR is decoded, not just rendered.** The suite pulls the bitmap off the device and runs zxing on it, then checks the decoded text is a complete configuration. An `ImageView` existing proves nothing about whether the code scans. - The **exported file is compared by checksum** against the tunnel's own config, and the exact filename is asserted — this caught Android's MediaStore appending `.txt` to `DE-FRA.conf` because no MIME type maps to `.conf`. The biometric gate was tested with a real device PIN: the prompt appears, nothing is copied before authenticating, and the copy happens after. On a device with no credential it degrades to allowing the share, matching the existing `ZipExporterPreference` behaviour. ## Known gaps - **The TV layout has no share button.** `tv_tunnel_list_item.xml` is a separate layout and this only covers the phone layout. I skipped it because the TV list is remote-control driven with a fixed-size card, so a per-row share button is a real design problem rather than a copy of the phone one. Happy to take it on as a follow-up. - **Startup sync is not wired up.** Sync is on demand only, from the toolbar and from each subscription row. - Translations other than the default locale fall back to English for the new strings, same as any other string added to this project. ## Note on the commits This PR has two commits, and the first is unrelated to the feature: 1. `fix: bump libwg-go toolchain to Go 1.25.0` — `go.mod` requires 1.25.0 but the Makefile pinned 1.24.2, so the NDK cross build does not start. **The feature does not build without this**, which is why it is here rather than in a separate PR. If you would rather merge it on its own, it is a clean one-commit cherry-pick and I will rebase the feature on top. 2. `feat: add subscription sync, spaces, and per-item sharing` — everything else. ## Provider contract The wire format the app expects is documented in a separate spec I have not included here. It is a JSON array of `{name, config}` or a `.zip` of `.conf` files, and a sync is a plain unauthenticated `GET` unless the URL carries its own token. Happy to add the document to this repo if it is useful. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
amnezia-vpn/amneziawg-android#103
No description provided.