parse(final CharSequence line) {
+ final Matcher matcher = LINE_PATTERN.matcher(line);
+ if (!matcher.matches())
+ return Optional.empty();
+ return Optional.of(new Attribute(matcher.group(1), matcher.group(2)));
+ }
+
+ public static String[] split(final CharSequence value) {
+ return LIST_SEPARATOR.split(value);
+ }
+
+ public String getKey() {
+ return key;
+ }
+
+ public String getValue() {
+ return value;
+ }
+}
diff --git a/tunnel/src/main/java/com/wireguard/config/BadConfigException.java b/tunnel/src/main/java/com/wireguard/config/BadConfigException.java
new file mode 100644
index 0000000..e70418c
--- /dev/null
+++ b/tunnel/src/main/java/com/wireguard/config/BadConfigException.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.config;
+
+import com.wireguard.crypto.KeyFormatException;
+import com.wireguard.util.NonNullForAll;
+
+import androidx.annotation.Nullable;
+
+@NonNullForAll
+public class BadConfigException extends Exception {
+ private final Location location;
+ private final Reason reason;
+ private final Section section;
+ @Nullable private final CharSequence text;
+
+ private BadConfigException(final Section section, final Location location,
+ final Reason reason, @Nullable final CharSequence text,
+ @Nullable final Throwable cause) {
+ super(cause);
+ this.section = section;
+ this.location = location;
+ this.reason = reason;
+ this.text = text;
+ }
+
+ public BadConfigException(final Section section, final Location location,
+ final Reason reason, @Nullable final CharSequence text) {
+ this(section, location, reason, text, null);
+ }
+
+ public BadConfigException(final Section section, final Location location,
+ final KeyFormatException cause) {
+ this(section, location, Reason.INVALID_KEY, null, cause);
+ }
+
+ public BadConfigException(final Section section, final Location location,
+ @Nullable final CharSequence text,
+ final NumberFormatException cause) {
+ this(section, location, Reason.INVALID_NUMBER, text, cause);
+ }
+
+ public BadConfigException(final Section section, final Location location,
+ final ParseException cause) {
+ this(section, location, Reason.INVALID_VALUE, cause.getText(), cause);
+ }
+
+ public Location getLocation() {
+ return location;
+ }
+
+ public Reason getReason() {
+ return reason;
+ }
+
+ public Section getSection() {
+ return section;
+ }
+
+ @Nullable
+ public CharSequence getText() {
+ return text;
+ }
+
+ public enum Location {
+ TOP_LEVEL(""),
+ ADDRESS("Address"),
+ ALLOWED_IPS("AllowedIPs"),
+ DNS("DNS"),
+ ENDPOINT("Endpoint"),
+ EXCLUDED_APPLICATIONS("ExcludedApplications"),
+ INCLUDED_APPLICATIONS("IncludedApplications"),
+ LISTEN_PORT("ListenPort"),
+ MTU("MTU"),
+ PERSISTENT_KEEPALIVE("PersistentKeepalive"),
+ PRE_SHARED_KEY("PresharedKey"),
+ PRIVATE_KEY("PrivateKey"),
+ PUBLIC_KEY("PublicKey");
+
+ private final String name;
+
+ Location(final String name) {
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+ }
+
+ public enum Reason {
+ INVALID_KEY,
+ INVALID_NUMBER,
+ INVALID_VALUE,
+ MISSING_ATTRIBUTE,
+ MISSING_SECTION,
+ SYNTAX_ERROR,
+ UNKNOWN_ATTRIBUTE,
+ UNKNOWN_SECTION
+ }
+
+ public enum Section {
+ CONFIG("Config"),
+ INTERFACE("Interface"),
+ PEER("Peer");
+
+ private final String name;
+
+ Section(final String name) {
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+ }
+}
diff --git a/tunnel/src/main/java/com/wireguard/config/Config.java b/tunnel/src/main/java/com/wireguard/config/Config.java
new file mode 100644
index 0000000..21e45f5
--- /dev/null
+++ b/tunnel/src/main/java/com/wireguard/config/Config.java
@@ -0,0 +1,223 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.config;
+
+import com.wireguard.config.BadConfigException.Location;
+import com.wireguard.config.BadConfigException.Reason;
+import com.wireguard.config.BadConfigException.Section;
+import com.wireguard.util.NonNullForAll;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import androidx.annotation.Nullable;
+
+/**
+ * Represents the contents of a wg-quick configuration file, made up of one or more "Interface"
+ * sections (combined together), and zero or more "Peer" sections (treated individually).
+ *
+ * Instances of this class are immutable.
+ */
+@NonNullForAll
+public final class Config {
+ private final Interface interfaze;
+ private final List peers;
+
+ private Config(final Builder builder) {
+ interfaze = Objects.requireNonNull(builder.interfaze, "An [Interface] section is required");
+ // Defensively copy to ensure immutability even if the Builder is reused.
+ peers = Collections.unmodifiableList(new ArrayList<>(builder.peers));
+ }
+
+ /**
+ * Parses an series of "Interface" and "Peer" sections into a {@code Config}. Throws
+ * {@link BadConfigException} if the input is not well-formed or contains data that cannot
+ * be parsed.
+ *
+ * @param stream a stream of UTF-8 text that is interpreted as a WireGuard configuration
+ * @return a {@code Config} instance representing the supplied configuration
+ */
+ public static Config parse(final InputStream stream)
+ throws IOException, BadConfigException {
+ return parse(new BufferedReader(new InputStreamReader(stream)));
+ }
+
+ /**
+ * Parses an series of "Interface" and "Peer" sections into a {@code Config}. Throws
+ * {@link BadConfigException} if the input is not well-formed or contains data that cannot
+ * be parsed.
+ *
+ * @param reader a BufferedReader of UTF-8 text that is interpreted as a WireGuard configuration
+ * @return a {@code Config} instance representing the supplied configuration
+ */
+ public static Config parse(final BufferedReader reader)
+ throws IOException, BadConfigException {
+ final Builder builder = new Builder();
+ final Collection interfaceLines = new ArrayList<>();
+ final Collection peerLines = new ArrayList<>();
+ boolean inInterfaceSection = false;
+ boolean inPeerSection = false;
+ boolean seenInterfaceSection = false;
+ @Nullable String line;
+ while ((line = reader.readLine()) != null) {
+ final int commentIndex = line.indexOf('#');
+ if (commentIndex != -1)
+ line = line.substring(0, commentIndex);
+ line = line.trim();
+ if (line.isEmpty())
+ continue;
+ if (line.startsWith("[")) {
+ // Consume all [Peer] lines read so far.
+ if (inPeerSection) {
+ builder.parsePeer(peerLines);
+ peerLines.clear();
+ }
+ if ("[Interface]".equalsIgnoreCase(line)) {
+ inInterfaceSection = true;
+ inPeerSection = false;
+ seenInterfaceSection = true;
+ } else if ("[Peer]".equalsIgnoreCase(line)) {
+ inInterfaceSection = false;
+ inPeerSection = true;
+ } else {
+ throw new BadConfigException(Section.CONFIG, Location.TOP_LEVEL,
+ Reason.UNKNOWN_SECTION, line);
+ }
+ } else if (inInterfaceSection) {
+ interfaceLines.add(line);
+ } else if (inPeerSection) {
+ peerLines.add(line);
+ } else {
+ throw new BadConfigException(Section.CONFIG, Location.TOP_LEVEL,
+ Reason.UNKNOWN_SECTION, line);
+ }
+ }
+ if (inPeerSection)
+ builder.parsePeer(peerLines);
+ if (!seenInterfaceSection)
+ throw new BadConfigException(Section.CONFIG, Location.TOP_LEVEL,
+ Reason.MISSING_SECTION, null);
+ // Combine all [Interface] sections in the file.
+ builder.parseInterface(interfaceLines);
+ return builder.build();
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if (!(obj instanceof Config))
+ return false;
+ final Config other = (Config) obj;
+ return interfaze.equals(other.interfaze) && peers.equals(other.peers);
+ }
+
+ /**
+ * Returns the interface section of the configuration.
+ *
+ * @return the interface configuration
+ */
+ public Interface getInterface() {
+ return interfaze;
+ }
+
+ /**
+ * Returns a list of the configuration's peer sections.
+ *
+ * @return a list of {@link Peer}s
+ */
+ public List getPeers() {
+ return peers;
+ }
+
+ @Override
+ public int hashCode() {
+ return 31 * interfaze.hashCode() + peers.hashCode();
+ }
+
+ /**
+ * Converts the {@code Config} into a string suitable for debugging purposes. The {@code Config}
+ * is identified by its interface's public key and the number of peers it has.
+ *
+ * @return a concise single-line identifier for the {@code Config}
+ */
+ @Override
+ public String toString() {
+ return "(Config " + interfaze + " (" + peers.size() + " peers))";
+ }
+
+ /**
+ * Converts the {@code Config} into a string suitable for use as a {@code wg-quick}
+ * configuration file.
+ *
+ * @return the {@code Config} represented as one [Interface] and zero or more [Peer] sections
+ */
+ public String toWgQuickString() {
+ final StringBuilder sb = new StringBuilder();
+ sb.append("[Interface]\n").append(interfaze.toWgQuickString());
+ for (final Peer peer : peers)
+ sb.append("\n[Peer]\n").append(peer.toWgQuickString());
+ return sb.toString();
+ }
+
+ /**
+ * Serializes the {@code Config} for use with the WireGuard cross-platform userspace API.
+ *
+ * @return the {@code Config} represented as a series of "key=value" lines
+ */
+ public String toWgUserspaceString() {
+ final StringBuilder sb = new StringBuilder();
+ sb.append(interfaze.toWgUserspaceString());
+ sb.append("replace_peers=true\n");
+ for (final Peer peer : peers)
+ sb.append(peer.toWgUserspaceString());
+ return sb.toString();
+ }
+
+ @SuppressWarnings("UnusedReturnValue")
+ public static final class Builder {
+ // Defaults to an empty set.
+ private final ArrayList peers = new ArrayList<>();
+ // No default; must be provided before building.
+ @Nullable private Interface interfaze;
+
+ public Builder addPeer(final Peer peer) {
+ peers.add(peer);
+ return this;
+ }
+
+ public Builder addPeers(final Collection peers) {
+ this.peers.addAll(peers);
+ return this;
+ }
+
+ public Config build() {
+ if (interfaze == null)
+ throw new IllegalArgumentException("An [Interface] section is required");
+ return new Config(this);
+ }
+
+ public Builder parseInterface(final Iterable extends CharSequence> lines)
+ throws BadConfigException {
+ return setInterface(Interface.parse(lines));
+ }
+
+ public Builder parsePeer(final Iterable extends CharSequence> lines)
+ throws BadConfigException {
+ return addPeer(Peer.parse(lines));
+ }
+
+ public Builder setInterface(final Interface interfaze) {
+ this.interfaze = interfaze;
+ return this;
+ }
+ }
+}
diff --git a/tunnel/src/main/java/com/wireguard/config/InetAddresses.java b/tunnel/src/main/java/com/wireguard/config/InetAddresses.java
new file mode 100644
index 0000000..165a702
--- /dev/null
+++ b/tunnel/src/main/java/com/wireguard/config/InetAddresses.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.config;
+
+import com.wireguard.util.NonNullForAll;
+
+import java.lang.reflect.Method;
+import java.net.Inet4Address;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.regex.Pattern;
+
+import androidx.annotation.Nullable;
+
+/**
+ * Utility methods for creating instances of {@link InetAddress}.
+ */
+@NonNullForAll
+public final class InetAddresses {
+ @Nullable private static final Method PARSER_METHOD;
+ private static final Pattern WONT_TOUCH_RESOLVER = Pattern.compile("^(((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:)))(%.+)?)|((?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))$");
+ private static final Pattern VALID_HOSTNAME = Pattern.compile("^(?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?(?:\\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?)*\\.?$");
+
+ static {
+ Method m = null;
+ try {
+ if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q)
+ // noinspection JavaReflectionMemberAccess
+ m = InetAddress.class.getMethod("parseNumericAddress", String.class);
+ } catch (final Exception ignored) {
+ }
+ PARSER_METHOD = m;
+ }
+
+ private InetAddresses() {
+ }
+
+ /**
+ * Determines whether input is a valid DNS hostname.
+ *
+ * @param maybeHostname a string that is possibly a DNS hostname
+ * @return whether or not maybeHostname is a valid DNS hostname
+ */
+ public static boolean isHostname(final CharSequence maybeHostname) {
+ return VALID_HOSTNAME.matcher(maybeHostname).matches();
+ }
+
+ /**
+ * Parses a numeric IPv4 or IPv6 address without performing any DNS lookups.
+ *
+ * @param address a string representing the IP address
+ * @return an instance of {@link Inet4Address} or {@link Inet6Address}, as appropriate
+ */
+ public static InetAddress parse(final String address) throws ParseException {
+ if (address.isEmpty())
+ throw new ParseException(InetAddress.class, address, "Empty address");
+ try {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q)
+ return android.net.InetAddresses.parseNumericAddress(address);
+ else if (PARSER_METHOD != null)
+ return (InetAddress) PARSER_METHOD.invoke(null, address);
+ else
+ throw new NoSuchMethodException("parseNumericAddress");
+ } catch (final IllegalArgumentException e) {
+ throw new ParseException(InetAddress.class, address, e);
+ } catch (final Exception e) {
+ final Throwable cause = e.getCause();
+ // Re-throw parsing exceptions with the original type, as callers might try to catch
+ // them. On the other hand, callers cannot be expected to handle reflection failures.
+ if (cause instanceof IllegalArgumentException)
+ throw new ParseException(InetAddress.class, address, cause);
+ try {
+ if (WONT_TOUCH_RESOLVER.matcher(address).matches())
+ return InetAddress.getByName(address);
+ else
+ throw new ParseException(InetAddress.class, address, "Not an IP address");
+ } catch (final UnknownHostException f) {
+ throw new ParseException(InetAddress.class, address, f);
+ }
+ }
+ }
+}
diff --git a/tunnel/src/main/java/com/wireguard/config/InetEndpoint.java b/tunnel/src/main/java/com/wireguard/config/InetEndpoint.java
new file mode 100644
index 0000000..c0ef433
--- /dev/null
+++ b/tunnel/src/main/java/com/wireguard/config/InetEndpoint.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.config;
+
+import com.wireguard.util.NonNullForAll;
+
+import java.net.Inet4Address;
+import java.net.InetAddress;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.UnknownHostException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Optional;
+import java.util.regex.Pattern;
+
+import androidx.annotation.Nullable;
+
+
+/**
+ * An external endpoint (host and port) used to connect to a WireGuard {@link Peer}.
+ *
+ * Instances of this class are externally immutable.
+ */
+@NonNullForAll
+public final class InetEndpoint {
+ private static final Pattern BARE_IPV6 = Pattern.compile("^[^\\[\\]]*:[^\\[\\]]*");
+ private static final Pattern FORBIDDEN_CHARACTERS = Pattern.compile("[/?#]");
+
+ private final String host;
+ private final boolean isResolved;
+ private final Object lock = new Object();
+ private final int port;
+ private Instant lastResolution = Instant.EPOCH;
+ @Nullable private InetEndpoint resolved;
+
+ private InetEndpoint(final String host, final boolean isResolved, final int port) {
+ this.host = host;
+ this.isResolved = isResolved;
+ this.port = port;
+ }
+
+ public static InetEndpoint parse(final String endpoint) throws ParseException {
+ if (FORBIDDEN_CHARACTERS.matcher(endpoint).find())
+ throw new ParseException(InetEndpoint.class, endpoint, "Forbidden characters");
+ final URI uri;
+ try {
+ uri = new URI("wg://" + endpoint);
+ } catch (final URISyntaxException e) {
+ throw new ParseException(InetEndpoint.class, endpoint, e);
+ }
+ if (uri.getPort() < 0 || uri.getPort() > 65535)
+ throw new ParseException(InetEndpoint.class, endpoint, "Missing/invalid port number");
+ try {
+ InetAddresses.parse(uri.getHost());
+ // Parsing ths host as a numeric address worked, so we don't need to do DNS lookups.
+ return new InetEndpoint(uri.getHost(), true, uri.getPort());
+ } catch (final ParseException ignored) {
+ // Failed to parse the host as a numeric address, so it must be a DNS hostname/FQDN.
+ return new InetEndpoint(uri.getHost(), false, uri.getPort());
+ }
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if (!(obj instanceof InetEndpoint))
+ return false;
+ final InetEndpoint other = (InetEndpoint) obj;
+ return host.equals(other.host) && port == other.port;
+ }
+
+ public String getHost() {
+ return host;
+ }
+
+ public int getPort() {
+ return port;
+ }
+
+ /**
+ * Generate an {@code InetEndpoint} instance with the same port and the host resolved using DNS
+ * to a numeric address. If the host is already numeric, the existing instance may be returned.
+ * Because this function may perform network I/O, it must not be called from the main thread.
+ *
+ * @return the resolved endpoint, or {@link Optional#empty()}
+ */
+ public Optional getResolved() {
+ if (isResolved)
+ return Optional.of(this);
+ synchronized (lock) {
+ //TODO(zx2c4): Implement a real timeout mechanism using DNS TTL
+ if (Duration.between(lastResolution, Instant.now()).toMinutes() > 1) {
+ try {
+ // Prefer v4 endpoints over v6 to work around DNS64 and IPv6 NAT issues.
+ final InetAddress[] candidates = InetAddress.getAllByName(host);
+ InetAddress address = candidates[0];
+ for (final InetAddress candidate : candidates) {
+ if (candidate instanceof Inet4Address) {
+ address = candidate;
+ break;
+ }
+ }
+ resolved = new InetEndpoint(address.getHostAddress(), true, port);
+ lastResolution = Instant.now();
+ } catch (final UnknownHostException e) {
+ resolved = null;
+ }
+ }
+ return Optional.ofNullable(resolved);
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ return host.hashCode() ^ port;
+ }
+
+ @Override
+ public String toString() {
+ final boolean isBareIpv6 = isResolved && BARE_IPV6.matcher(host).matches();
+ return (isBareIpv6 ? '[' + host + ']' : host) + ':' + port;
+ }
+}
diff --git a/tunnel/src/main/java/com/wireguard/config/InetNetwork.java b/tunnel/src/main/java/com/wireguard/config/InetNetwork.java
new file mode 100644
index 0000000..84aea82
--- /dev/null
+++ b/tunnel/src/main/java/com/wireguard/config/InetNetwork.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.config;
+
+import com.wireguard.util.NonNullForAll;
+
+import java.net.Inet4Address;
+import java.net.InetAddress;
+
+/**
+ * An Internet network, denoted by its address and netmask
+ *
+ * Instances of this class are immutable.
+ */
+@NonNullForAll
+public final class InetNetwork {
+ private final InetAddress address;
+ private final int mask;
+
+ private InetNetwork(final InetAddress address, final int mask) {
+ this.address = address;
+ this.mask = mask;
+ }
+
+ public static InetNetwork parse(final String network) throws ParseException {
+ final int slash = network.lastIndexOf('/');
+ final String maskString;
+ final int rawMask;
+ final String rawAddress;
+ if (slash >= 0) {
+ maskString = network.substring(slash + 1);
+ try {
+ rawMask = Integer.parseInt(maskString, 10);
+ } catch (final NumberFormatException ignored) {
+ throw new ParseException(Integer.class, maskString);
+ }
+ rawAddress = network.substring(0, slash);
+ } else {
+ maskString = "";
+ rawMask = -1;
+ rawAddress = network;
+ }
+ final InetAddress address = InetAddresses.parse(rawAddress);
+ final int maxMask = (address instanceof Inet4Address) ? 32 : 128;
+ if (rawMask > maxMask)
+ throw new ParseException(InetNetwork.class, maskString, "Invalid network mask");
+ final int mask = rawMask >= 0 ? rawMask : maxMask;
+ return new InetNetwork(address, mask);
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if (!(obj instanceof InetNetwork))
+ return false;
+ final InetNetwork other = (InetNetwork) obj;
+ return address.equals(other.address) && mask == other.mask;
+ }
+
+ public InetAddress getAddress() {
+ return address;
+ }
+
+ public int getMask() {
+ return mask;
+ }
+
+ @Override
+ public int hashCode() {
+ return address.hashCode() ^ mask;
+ }
+
+ @Override
+ public String toString() {
+ return address.getHostAddress() + '/' + mask;
+ }
+}
diff --git a/tunnel/src/main/java/com/wireguard/config/Interface.java b/tunnel/src/main/java/com/wireguard/config/Interface.java
new file mode 100644
index 0000000..53ca911
--- /dev/null
+++ b/tunnel/src/main/java/com/wireguard/config/Interface.java
@@ -0,0 +1,423 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.config;
+
+import com.wireguard.config.BadConfigException.Location;
+import com.wireguard.config.BadConfigException.Reason;
+import com.wireguard.config.BadConfigException.Section;
+import com.wireguard.crypto.Key;
+import com.wireguard.crypto.KeyFormatException;
+import com.wireguard.crypto.KeyPair;
+import com.wireguard.util.NonNullForAll;
+
+import java.net.InetAddress;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import androidx.annotation.Nullable;
+
+/**
+ * Represents the configuration for a WireGuard interface (an [Interface] block). Interfaces must
+ * have a private key (used to initialize a {@code KeyPair}), and may optionally have several other
+ * attributes.
+ *
+ * Instances of this class are immutable.
+ */
+@NonNullForAll
+public final class Interface {
+ private static final int MAX_UDP_PORT = 65535;
+ private static final int MIN_UDP_PORT = 0;
+
+ private final Set addresses;
+ private final Set dnsServers;
+ private final Set dnsSearchDomains;
+ private final Set excludedApplications;
+ private final Set includedApplications;
+ private final KeyPair keyPair;
+ private final Optional listenPort;
+ private final Optional mtu;
+
+ private Interface(final Builder builder) {
+ // Defensively copy to ensure immutability even if the Builder is reused.
+ addresses = Collections.unmodifiableSet(new LinkedHashSet<>(builder.addresses));
+ dnsServers = Collections.unmodifiableSet(new LinkedHashSet<>(builder.dnsServers));
+ dnsSearchDomains = Collections.unmodifiableSet(new LinkedHashSet<>(builder.dnsSearchDomains));
+ excludedApplications = Collections.unmodifiableSet(new LinkedHashSet<>(builder.excludedApplications));
+ includedApplications = Collections.unmodifiableSet(new LinkedHashSet<>(builder.includedApplications));
+ keyPair = Objects.requireNonNull(builder.keyPair, "Interfaces must have a private key");
+ listenPort = builder.listenPort;
+ mtu = builder.mtu;
+ }
+
+ /**
+ * Parses an series of "KEY = VALUE" lines into an {@code Interface}. Throws
+ * {@link ParseException} if the input is not well-formed or contains unknown attributes.
+ *
+ * @param lines An iterable sequence of lines, containing at least a private key attribute
+ * @return An {@code Interface} with all of the attributes from {@code lines} set
+ */
+ public static Interface parse(final Iterable extends CharSequence> lines)
+ throws BadConfigException {
+ final Builder builder = new Builder();
+ for (final CharSequence line : lines) {
+ final Attribute attribute = Attribute.parse(line).orElseThrow(() ->
+ new BadConfigException(Section.INTERFACE, Location.TOP_LEVEL,
+ Reason.SYNTAX_ERROR, line));
+ switch (attribute.getKey().toLowerCase(Locale.ENGLISH)) {
+ case "address":
+ builder.parseAddresses(attribute.getValue());
+ break;
+ case "dns":
+ builder.parseDnsServers(attribute.getValue());
+ break;
+ case "excludedapplications":
+ builder.parseExcludedApplications(attribute.getValue());
+ break;
+ case "includedapplications":
+ builder.parseIncludedApplications(attribute.getValue());
+ break;
+ case "listenport":
+ builder.parseListenPort(attribute.getValue());
+ break;
+ case "mtu":
+ builder.parseMtu(attribute.getValue());
+ break;
+ case "privatekey":
+ builder.parsePrivateKey(attribute.getValue());
+ break;
+ default:
+ throw new BadConfigException(Section.INTERFACE, Location.TOP_LEVEL,
+ Reason.UNKNOWN_ATTRIBUTE, attribute.getKey());
+ }
+ }
+ return builder.build();
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if (!(obj instanceof Interface))
+ return false;
+ final Interface other = (Interface) obj;
+ return addresses.equals(other.addresses)
+ && dnsServers.equals(other.dnsServers)
+ && dnsSearchDomains.equals(other.dnsSearchDomains)
+ && excludedApplications.equals(other.excludedApplications)
+ && includedApplications.equals(other.includedApplications)
+ && keyPair.equals(other.keyPair)
+ && listenPort.equals(other.listenPort)
+ && mtu.equals(other.mtu);
+ }
+
+ /**
+ * Returns the set of IP addresses assigned to the interface.
+ *
+ * @return a set of {@link InetNetwork}s
+ */
+ public Set getAddresses() {
+ // The collection is already immutable.
+ return addresses;
+ }
+
+ /**
+ * Returns the set of DNS servers associated with the interface.
+ *
+ * @return a set of {@link InetAddress}es
+ */
+ public Set getDnsServers() {
+ // The collection is already immutable.
+ return dnsServers;
+ }
+
+ /**
+ * Returns the set of DNS search domains associated with the interface.
+ *
+ * @return a set of strings
+ */
+ public Set getDnsSearchDomains() {
+ // The collection is already immutable.
+ return dnsSearchDomains;
+ }
+
+ /**
+ * Returns the set of applications excluded from using the interface.
+ *
+ * @return a set of package names
+ */
+ public Set getExcludedApplications() {
+ // The collection is already immutable.
+ return excludedApplications;
+ }
+
+ /**
+ * Returns the set of applications included exclusively for using the interface.
+ *
+ * @return a set of package names
+ */
+ public Set getIncludedApplications() {
+ // The collection is already immutable.
+ return includedApplications;
+ }
+
+ /**
+ * Returns the public/private key pair used by the interface.
+ *
+ * @return a key pair
+ */
+ public KeyPair getKeyPair() {
+ return keyPair;
+ }
+
+ /**
+ * Returns the UDP port number that the WireGuard interface will listen on.
+ *
+ * @return a UDP port number, or {@code Optional.empty()} if none is configured
+ */
+ public Optional getListenPort() {
+ return listenPort;
+ }
+
+ /**
+ * Returns the MTU used for the WireGuard interface.
+ *
+ * @return the MTU, or {@code Optional.empty()} if none is configured
+ */
+ public Optional getMtu() {
+ return mtu;
+ }
+
+ @Override
+ public int hashCode() {
+ int hash = 1;
+ hash = 31 * hash + addresses.hashCode();
+ hash = 31 * hash + dnsServers.hashCode();
+ hash = 31 * hash + excludedApplications.hashCode();
+ hash = 31 * hash + includedApplications.hashCode();
+ hash = 31 * hash + keyPair.hashCode();
+ hash = 31 * hash + listenPort.hashCode();
+ hash = 31 * hash + mtu.hashCode();
+ return hash;
+ }
+
+ /**
+ * Converts the {@code Interface} into a string suitable for debugging purposes. The {@code
+ * Interface} is identified by its public key and (if set) the port used for its UDP socket.
+ *
+ * @return A concise single-line identifier for the {@code Interface}
+ */
+ @Override
+ public String toString() {
+ final StringBuilder sb = new StringBuilder("(Interface ");
+ sb.append(keyPair.getPublicKey().toBase64());
+ listenPort.ifPresent(lp -> sb.append(" @").append(lp));
+ sb.append(')');
+ return sb.toString();
+ }
+
+ /**
+ * Converts the {@code Interface} into a string suitable for inclusion in a {@code wg-quick}
+ * configuration file.
+ *
+ * @return The {@code Interface} represented as a series of "Key = Value" lines
+ */
+ public String toWgQuickString() {
+ final StringBuilder sb = new StringBuilder();
+ if (!addresses.isEmpty())
+ sb.append("Address = ").append(Attribute.join(addresses)).append('\n');
+ if (!dnsServers.isEmpty()) {
+ final List dnsServerStrings = dnsServers.stream().map(InetAddress::getHostAddress).collect(Collectors.toList());
+ dnsServerStrings.addAll(dnsSearchDomains);
+ sb.append("DNS = ").append(Attribute.join(dnsServerStrings)).append('\n');
+ }
+ if (!excludedApplications.isEmpty())
+ sb.append("ExcludedApplications = ").append(Attribute.join(excludedApplications)).append('\n');
+ if (!includedApplications.isEmpty())
+ sb.append("IncludedApplications = ").append(Attribute.join(includedApplications)).append('\n');
+ listenPort.ifPresent(lp -> sb.append("ListenPort = ").append(lp).append('\n'));
+ mtu.ifPresent(m -> sb.append("MTU = ").append(m).append('\n'));
+ sb.append("PrivateKey = ").append(keyPair.getPrivateKey().toBase64()).append('\n');
+ return sb.toString();
+ }
+
+ /**
+ * Serializes the {@code Interface} for use with the WireGuard cross-platform userspace API.
+ * Note that not all attributes are included in this representation.
+ *
+ * @return the {@code Interface} represented as a series of "KEY=VALUE" lines
+ */
+ public String toWgUserspaceString() {
+ final StringBuilder sb = new StringBuilder();
+ sb.append("private_key=").append(keyPair.getPrivateKey().toHex()).append('\n');
+ listenPort.ifPresent(lp -> sb.append("listen_port=").append(lp).append('\n'));
+ return sb.toString();
+ }
+
+ @SuppressWarnings("UnusedReturnValue")
+ public static final class Builder {
+ // Defaults to an empty set.
+ private final Set addresses = new LinkedHashSet<>();
+ // Defaults to an empty set.
+ private final Set dnsServers = new LinkedHashSet<>();
+ // Defaults to an empty set.
+ private final Set dnsSearchDomains = new LinkedHashSet<>();
+ // Defaults to an empty set.
+ private final Set excludedApplications = new LinkedHashSet<>();
+ // Defaults to an empty set.
+ private final Set includedApplications = new LinkedHashSet<>();
+ // No default; must be provided before building.
+ @Nullable private KeyPair keyPair;
+ // Defaults to not present.
+ private Optional listenPort = Optional.empty();
+ // Defaults to not present.
+ private Optional mtu = Optional.empty();
+
+ public Builder addAddress(final InetNetwork address) {
+ addresses.add(address);
+ return this;
+ }
+
+ public Builder addAddresses(final Collection addresses) {
+ this.addresses.addAll(addresses);
+ return this;
+ }
+
+ public Builder addDnsServer(final InetAddress dnsServer) {
+ dnsServers.add(dnsServer);
+ return this;
+ }
+
+ public Builder addDnsServers(final Collection extends InetAddress> dnsServers) {
+ this.dnsServers.addAll(dnsServers);
+ return this;
+ }
+
+ public Builder addDnsSearchDomain(final String dnsSearchDomain) {
+ dnsSearchDomains.add(dnsSearchDomain);
+ return this;
+ }
+
+ public Builder addDnsSearchDomains(final Collection dnsSearchDomains) {
+ this.dnsSearchDomains.addAll(dnsSearchDomains);
+ return this;
+ }
+
+ public Interface build() throws BadConfigException {
+ if (keyPair == null)
+ throw new BadConfigException(Section.INTERFACE, Location.PRIVATE_KEY,
+ Reason.MISSING_ATTRIBUTE, null);
+ if (!includedApplications.isEmpty() && !excludedApplications.isEmpty())
+ throw new BadConfigException(Section.INTERFACE, Location.INCLUDED_APPLICATIONS,
+ Reason.INVALID_KEY, null);
+ return new Interface(this);
+ }
+
+ public Builder excludeApplication(final String application) {
+ excludedApplications.add(application);
+ return this;
+ }
+
+ public Builder excludeApplications(final Collection applications) {
+ excludedApplications.addAll(applications);
+ return this;
+ }
+
+ public Builder includeApplication(final String application) {
+ includedApplications.add(application);
+ return this;
+ }
+
+ public Builder includeApplications(final Collection applications) {
+ includedApplications.addAll(applications);
+ return this;
+ }
+
+ public Builder parseAddresses(final CharSequence addresses) throws BadConfigException {
+ try {
+ for (final String address : Attribute.split(addresses))
+ addAddress(InetNetwork.parse(address));
+ return this;
+ } catch (final ParseException e) {
+ throw new BadConfigException(Section.INTERFACE, Location.ADDRESS, e);
+ }
+ }
+
+ public Builder parseDnsServers(final CharSequence dnsServers) throws BadConfigException {
+ try {
+ for (final String dnsServer : Attribute.split(dnsServers)) {
+ try {
+ addDnsServer(InetAddresses.parse(dnsServer));
+ } catch (final ParseException e) {
+ if (e.getParsingClass() != InetAddress.class || !InetAddresses.isHostname(dnsServer))
+ throw e;
+ addDnsSearchDomain(dnsServer);
+ }
+ }
+ return this;
+ } catch (final ParseException e) {
+ throw new BadConfigException(Section.INTERFACE, Location.DNS, e);
+ }
+ }
+
+ public Builder parseExcludedApplications(final CharSequence apps) {
+ return excludeApplications(List.of(Attribute.split(apps)));
+ }
+
+ public Builder parseIncludedApplications(final CharSequence apps) {
+ return includeApplications(List.of(Attribute.split(apps)));
+ }
+
+ public Builder parseListenPort(final String listenPort) throws BadConfigException {
+ try {
+ return setListenPort(Integer.parseInt(listenPort));
+ } catch (final NumberFormatException e) {
+ throw new BadConfigException(Section.INTERFACE, Location.LISTEN_PORT, listenPort, e);
+ }
+ }
+
+ public Builder parseMtu(final String mtu) throws BadConfigException {
+ try {
+ return setMtu(Integer.parseInt(mtu));
+ } catch (final NumberFormatException e) {
+ throw new BadConfigException(Section.INTERFACE, Location.MTU, mtu, e);
+ }
+ }
+
+ public Builder parsePrivateKey(final String privateKey) throws BadConfigException {
+ try {
+ return setKeyPair(new KeyPair(Key.fromBase64(privateKey)));
+ } catch (final KeyFormatException e) {
+ throw new BadConfigException(Section.INTERFACE, Location.PRIVATE_KEY, e);
+ }
+ }
+
+ public Builder setKeyPair(final KeyPair keyPair) {
+ this.keyPair = keyPair;
+ return this;
+ }
+
+ public Builder setListenPort(final int listenPort) throws BadConfigException {
+ if (listenPort < MIN_UDP_PORT || listenPort > MAX_UDP_PORT)
+ throw new BadConfigException(Section.INTERFACE, Location.LISTEN_PORT,
+ Reason.INVALID_VALUE, String.valueOf(listenPort));
+ this.listenPort = listenPort == 0 ? Optional.empty() : Optional.of(listenPort);
+ return this;
+ }
+
+ public Builder setMtu(final int mtu) throws BadConfigException {
+ if (mtu < 0)
+ throw new BadConfigException(Section.INTERFACE, Location.LISTEN_PORT,
+ Reason.INVALID_VALUE, String.valueOf(mtu));
+ this.mtu = mtu == 0 ? Optional.empty() : Optional.of(mtu);
+ return this;
+ }
+ }
+}
diff --git a/tunnel/src/main/java/com/wireguard/config/ParseException.java b/tunnel/src/main/java/com/wireguard/config/ParseException.java
new file mode 100644
index 0000000..ff430e6
--- /dev/null
+++ b/tunnel/src/main/java/com/wireguard/config/ParseException.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.config;
+
+import com.wireguard.util.NonNullForAll;
+
+import androidx.annotation.Nullable;
+
+/**
+ *
+ */
+@NonNullForAll
+public class ParseException extends Exception {
+ private final Class> parsingClass;
+ private final CharSequence text;
+
+ public ParseException(final Class> parsingClass, final CharSequence text,
+ @Nullable final String message, @Nullable final Throwable cause) {
+ super(message, cause);
+ this.parsingClass = parsingClass;
+ this.text = text;
+ }
+
+ public ParseException(final Class> parsingClass, final CharSequence text,
+ @Nullable final String message) {
+ this(parsingClass, text, message, null);
+ }
+
+ public ParseException(final Class> parsingClass, final CharSequence text,
+ @Nullable final Throwable cause) {
+ this(parsingClass, text, null, cause);
+ }
+
+ public ParseException(final Class> parsingClass, final CharSequence text) {
+ this(parsingClass, text, null, null);
+ }
+
+ public Class> getParsingClass() {
+ return parsingClass;
+ }
+
+ public CharSequence getText() {
+ return text;
+ }
+}
diff --git a/tunnel/src/main/java/com/wireguard/config/Peer.java b/tunnel/src/main/java/com/wireguard/config/Peer.java
new file mode 100644
index 0000000..b308a93
--- /dev/null
+++ b/tunnel/src/main/java/com/wireguard/config/Peer.java
@@ -0,0 +1,307 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.config;
+
+import com.wireguard.config.BadConfigException.Location;
+import com.wireguard.config.BadConfigException.Reason;
+import com.wireguard.config.BadConfigException.Section;
+import com.wireguard.crypto.Key;
+import com.wireguard.crypto.KeyFormatException;
+import com.wireguard.util.NonNullForAll;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+import androidx.annotation.Nullable;
+
+/**
+ * Represents the configuration for a WireGuard peer (a [Peer] block). Peers must have a public key,
+ * and may optionally have several other attributes.
+ *
+ * Instances of this class are immutable.
+ */
+@NonNullForAll
+public final class Peer {
+ private final Set allowedIps;
+ private final Optional endpoint;
+ private final Optional persistentKeepalive;
+ private final Optional preSharedKey;
+ private final Key publicKey;
+
+ private Peer(final Builder builder) {
+ // Defensively copy to ensure immutability even if the Builder is reused.
+ allowedIps = Collections.unmodifiableSet(new LinkedHashSet<>(builder.allowedIps));
+ endpoint = builder.endpoint;
+ persistentKeepalive = builder.persistentKeepalive;
+ preSharedKey = builder.preSharedKey;
+ publicKey = Objects.requireNonNull(builder.publicKey, "Peers must have a public key");
+ }
+
+ /**
+ * Parses an series of "KEY = VALUE" lines into a {@code Peer}. Throws {@link ParseException} if
+ * the input is not well-formed or contains unknown attributes.
+ *
+ * @param lines an iterable sequence of lines, containing at least a public key attribute
+ * @return a {@code Peer} with all of its attributes set from {@code lines}
+ */
+ public static Peer parse(final Iterable extends CharSequence> lines)
+ throws BadConfigException {
+ final Builder builder = new Builder();
+ for (final CharSequence line : lines) {
+ final Attribute attribute = Attribute.parse(line).orElseThrow(() ->
+ new BadConfigException(Section.PEER, Location.TOP_LEVEL,
+ Reason.SYNTAX_ERROR, line));
+ switch (attribute.getKey().toLowerCase(Locale.ENGLISH)) {
+ case "allowedips":
+ builder.parseAllowedIPs(attribute.getValue());
+ break;
+ case "endpoint":
+ builder.parseEndpoint(attribute.getValue());
+ break;
+ case "persistentkeepalive":
+ builder.parsePersistentKeepalive(attribute.getValue());
+ break;
+ case "presharedkey":
+ builder.parsePreSharedKey(attribute.getValue());
+ break;
+ case "publickey":
+ builder.parsePublicKey(attribute.getValue());
+ break;
+ default:
+ throw new BadConfigException(Section.PEER, Location.TOP_LEVEL,
+ Reason.UNKNOWN_ATTRIBUTE, attribute.getKey());
+ }
+ }
+ return builder.build();
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if (!(obj instanceof Peer))
+ return false;
+ final Peer other = (Peer) obj;
+ return allowedIps.equals(other.allowedIps)
+ && endpoint.equals(other.endpoint)
+ && persistentKeepalive.equals(other.persistentKeepalive)
+ && preSharedKey.equals(other.preSharedKey)
+ && publicKey.equals(other.publicKey);
+ }
+
+ /**
+ * Returns the peer's set of allowed IPs.
+ *
+ * @return the set of allowed IPs
+ */
+ public Set getAllowedIps() {
+ // The collection is already immutable.
+ return allowedIps;
+ }
+
+ /**
+ * Returns the peer's endpoint.
+ *
+ * @return the endpoint, or {@code Optional.empty()} if none is configured
+ */
+ public Optional getEndpoint() {
+ return endpoint;
+ }
+
+ /**
+ * Returns the peer's persistent keepalive.
+ *
+ * @return the persistent keepalive, or {@code Optional.empty()} if none is configured
+ */
+ public Optional getPersistentKeepalive() {
+ return persistentKeepalive;
+ }
+
+ /**
+ * Returns the peer's pre-shared key.
+ *
+ * @return the pre-shared key, or {@code Optional.empty()} if none is configured
+ */
+ public Optional getPreSharedKey() {
+ return preSharedKey;
+ }
+
+ /**
+ * Returns the peer's public key.
+ *
+ * @return the public key
+ */
+ public Key getPublicKey() {
+ return publicKey;
+ }
+
+ @Override
+ public int hashCode() {
+ int hash = 1;
+ hash = 31 * hash + allowedIps.hashCode();
+ hash = 31 * hash + endpoint.hashCode();
+ hash = 31 * hash + persistentKeepalive.hashCode();
+ hash = 31 * hash + preSharedKey.hashCode();
+ hash = 31 * hash + publicKey.hashCode();
+ return hash;
+ }
+
+ /**
+ * Converts the {@code Peer} into a string suitable for debugging purposes. The {@code Peer} is
+ * identified by its public key and (if known) its endpoint.
+ *
+ * @return a concise single-line identifier for the {@code Peer}
+ */
+ @Override
+ public String toString() {
+ final StringBuilder sb = new StringBuilder("(Peer ");
+ sb.append(publicKey.toBase64());
+ endpoint.ifPresent(ep -> sb.append(" @").append(ep));
+ sb.append(')');
+ return sb.toString();
+ }
+
+ /**
+ * Converts the {@code Peer} into a string suitable for inclusion in a {@code wg-quick}
+ * configuration file.
+ *
+ * @return the {@code Peer} represented as a series of "Key = Value" lines
+ */
+ public String toWgQuickString() {
+ final StringBuilder sb = new StringBuilder();
+ if (!allowedIps.isEmpty())
+ sb.append("AllowedIPs = ").append(Attribute.join(allowedIps)).append('\n');
+ endpoint.ifPresent(ep -> sb.append("Endpoint = ").append(ep).append('\n'));
+ persistentKeepalive.ifPresent(pk -> sb.append("PersistentKeepalive = ").append(pk).append('\n'));
+ preSharedKey.ifPresent(psk -> sb.append("PreSharedKey = ").append(psk.toBase64()).append('\n'));
+ sb.append("PublicKey = ").append(publicKey.toBase64()).append('\n');
+ return sb.toString();
+ }
+
+ /**
+ * Serializes the {@code Peer} for use with the WireGuard cross-platform userspace API. Note
+ * that not all attributes are included in this representation.
+ *
+ * @return the {@code Peer} represented as a series of "key=value" lines
+ */
+ public String toWgUserspaceString() {
+ final StringBuilder sb = new StringBuilder();
+ // The order here is important: public_key signifies the beginning of a new peer.
+ sb.append("public_key=").append(publicKey.toHex()).append('\n');
+ for (final InetNetwork allowedIp : allowedIps)
+ sb.append("allowed_ip=").append(allowedIp).append('\n');
+ endpoint.flatMap(InetEndpoint::getResolved).ifPresent(ep -> sb.append("endpoint=").append(ep).append('\n'));
+ persistentKeepalive.ifPresent(pk -> sb.append("persistent_keepalive_interval=").append(pk).append('\n'));
+ preSharedKey.ifPresent(psk -> sb.append("preshared_key=").append(psk.toHex()).append('\n'));
+ return sb.toString();
+ }
+
+ @SuppressWarnings("UnusedReturnValue")
+ public static final class Builder {
+ // See wg(8)
+ private static final int MAX_PERSISTENT_KEEPALIVE = 65535;
+
+ // Defaults to an empty set.
+ private final Set allowedIps = new LinkedHashSet<>();
+ // Defaults to not present.
+ private Optional endpoint = Optional.empty();
+ // Defaults to not present.
+ private Optional persistentKeepalive = Optional.empty();
+ // Defaults to not present.
+ private Optional preSharedKey = Optional.empty();
+ // No default; must be provided before building.
+ @Nullable private Key publicKey;
+
+ public Builder addAllowedIp(final InetNetwork allowedIp) {
+ allowedIps.add(allowedIp);
+ return this;
+ }
+
+ public Builder addAllowedIps(final Collection allowedIps) {
+ this.allowedIps.addAll(allowedIps);
+ return this;
+ }
+
+ public Peer build() throws BadConfigException {
+ if (publicKey == null)
+ throw new BadConfigException(Section.PEER, Location.PUBLIC_KEY,
+ Reason.MISSING_ATTRIBUTE, null);
+ return new Peer(this);
+ }
+
+ public Builder parseAllowedIPs(final CharSequence allowedIps) throws BadConfigException {
+ try {
+ for (final String allowedIp : Attribute.split(allowedIps))
+ addAllowedIp(InetNetwork.parse(allowedIp));
+ return this;
+ } catch (final ParseException e) {
+ throw new BadConfigException(Section.PEER, Location.ALLOWED_IPS, e);
+ }
+ }
+
+ public Builder parseEndpoint(final String endpoint) throws BadConfigException {
+ try {
+ return setEndpoint(InetEndpoint.parse(endpoint));
+ } catch (final ParseException e) {
+ throw new BadConfigException(Section.PEER, Location.ENDPOINT, e);
+ }
+ }
+
+ public Builder parsePersistentKeepalive(final String persistentKeepalive)
+ throws BadConfigException {
+ try {
+ return setPersistentKeepalive(Integer.parseInt(persistentKeepalive));
+ } catch (final NumberFormatException e) {
+ throw new BadConfigException(Section.PEER, Location.PERSISTENT_KEEPALIVE,
+ persistentKeepalive, e);
+ }
+ }
+
+ public Builder parsePreSharedKey(final String preSharedKey) throws BadConfigException {
+ try {
+ return setPreSharedKey(Key.fromBase64(preSharedKey));
+ } catch (final KeyFormatException e) {
+ throw new BadConfigException(Section.PEER, Location.PRE_SHARED_KEY, e);
+ }
+ }
+
+ public Builder parsePublicKey(final String publicKey) throws BadConfigException {
+ try {
+ return setPublicKey(Key.fromBase64(publicKey));
+ } catch (final KeyFormatException e) {
+ throw new BadConfigException(Section.PEER, Location.PUBLIC_KEY, e);
+ }
+ }
+
+ public Builder setEndpoint(final InetEndpoint endpoint) {
+ this.endpoint = Optional.of(endpoint);
+ return this;
+ }
+
+ public Builder setPersistentKeepalive(final int persistentKeepalive)
+ throws BadConfigException {
+ if (persistentKeepalive < 0 || persistentKeepalive > MAX_PERSISTENT_KEEPALIVE)
+ throw new BadConfigException(Section.PEER, Location.PERSISTENT_KEEPALIVE,
+ Reason.INVALID_VALUE, String.valueOf(persistentKeepalive));
+ this.persistentKeepalive = persistentKeepalive == 0 ?
+ Optional.empty() : Optional.of(persistentKeepalive);
+ return this;
+ }
+
+ public Builder setPreSharedKey(final Key preSharedKey) {
+ this.preSharedKey = Optional.of(preSharedKey);
+ return this;
+ }
+
+ public Builder setPublicKey(final Key publicKey) {
+ this.publicKey = publicKey;
+ return this;
+ }
+ }
+}
diff --git a/tunnel/src/main/java/com/wireguard/crypto/Curve25519.java b/tunnel/src/main/java/com/wireguard/crypto/Curve25519.java
new file mode 100644
index 0000000..c9a592f
--- /dev/null
+++ b/tunnel/src/main/java/com/wireguard/crypto/Curve25519.java
@@ -0,0 +1,500 @@
+/*
+ * Copyright © 2016 Southern Storm Software, Pty Ltd.
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.crypto;
+
+import com.wireguard.util.NonNullForAll;
+
+import java.util.Arrays;
+
+import androidx.annotation.Nullable;
+
+/**
+ * Implementation of Curve25519 ECDH.
+ *
+ * This implementation was imported to WireGuard from noise-java:
+ * https://github.com/rweather/noise-java
+ *
+ * This implementation is based on that from arduinolibs:
+ * https://github.com/rweather/arduinolibs
+ *
+ * Differences in this version are due to using 26-bit limbs for the
+ * representation instead of the 8/16/32-bit limbs in the original.
+ *
+ * References: http://cr.yp.to/ecdh.html, RFC 7748
+ */
+@SuppressWarnings({"MagicNumber", "NonConstantFieldWithUpperCaseName", "SuspiciousNameCombination"})
+@NonNullForAll
+public final class Curve25519 {
+ // Numbers modulo 2^255 - 19 are broken up into ten 26-bit words.
+ private static final int NUM_LIMBS_255BIT = 10;
+ private static final int NUM_LIMBS_510BIT = 20;
+
+ private final int[] A;
+ private final int[] AA;
+ private final int[] B;
+ private final int[] BB;
+ private final int[] C;
+ private final int[] CB;
+ private final int[] D;
+ private final int[] DA;
+ private final int[] E;
+ private final long[] t1;
+ private final int[] t2;
+ private final int[] x_1;
+ private final int[] x_2;
+ private final int[] x_3;
+ private final int[] z_2;
+ private final int[] z_3;
+
+ /**
+ * Constructs the temporary state holder for Curve25519 evaluation.
+ */
+ private Curve25519() {
+ // Allocate memory for all of the temporary variables we will need.
+ x_1 = new int[NUM_LIMBS_255BIT];
+ x_2 = new int[NUM_LIMBS_255BIT];
+ x_3 = new int[NUM_LIMBS_255BIT];
+ z_2 = new int[NUM_LIMBS_255BIT];
+ z_3 = new int[NUM_LIMBS_255BIT];
+ A = new int[NUM_LIMBS_255BIT];
+ B = new int[NUM_LIMBS_255BIT];
+ C = new int[NUM_LIMBS_255BIT];
+ D = new int[NUM_LIMBS_255BIT];
+ E = new int[NUM_LIMBS_255BIT];
+ AA = new int[NUM_LIMBS_255BIT];
+ BB = new int[NUM_LIMBS_255BIT];
+ DA = new int[NUM_LIMBS_255BIT];
+ CB = new int[NUM_LIMBS_255BIT];
+ t1 = new long[NUM_LIMBS_510BIT];
+ t2 = new int[NUM_LIMBS_510BIT];
+ }
+
+ /**
+ * Conditional swap of two values.
+ *
+ * @param select Set to 1 to swap, 0 to leave as-is.
+ * @param x The first value.
+ * @param y The second value.
+ */
+ private static void cswap(int select, final int[] x, final int[] y) {
+ select = -select;
+ for (int index = 0; index < NUM_LIMBS_255BIT; ++index) {
+ final int dummy = select & (x[index] ^ y[index]);
+ x[index] ^= dummy;
+ y[index] ^= dummy;
+ }
+ }
+
+ /**
+ * Evaluates the Curve25519 curve.
+ *
+ * @param result Buffer to place the result of the evaluation into.
+ * @param offset Offset into the result buffer.
+ * @param privateKey The private key to use in the evaluation.
+ * @param publicKey The public key to use in the evaluation, or null
+ * if the base point of the curve should be used.
+ */
+ public static void eval(final byte[] result, final int offset,
+ final byte[] privateKey, @Nullable final byte[] publicKey) {
+ final Curve25519 state = new Curve25519();
+ try {
+ // Unpack the public key value. If null, use 9 as the base point.
+ Arrays.fill(state.x_1, 0);
+ if (publicKey != null) {
+ // Convert the input value from little-endian into 26-bit limbs.
+ for (int index = 0; index < 32; ++index) {
+ final int bit = (index * 8) % 26;
+ final int word = (index * 8) / 26;
+ final int value = publicKey[index] & 0xFF;
+ if (bit <= (26 - 8)) {
+ state.x_1[word] |= value << bit;
+ } else {
+ state.x_1[word] |= value << bit;
+ state.x_1[word] &= 0x03FFFFFF;
+ state.x_1[word + 1] |= value >> (26 - bit);
+ }
+ }
+
+ // Just in case, we reduce the number modulo 2^255 - 19 to
+ // make sure that it is in range of the field before we start.
+ // This eliminates values between 2^255 - 19 and 2^256 - 1.
+ state.reduceQuick(state.x_1);
+ state.reduceQuick(state.x_1);
+ } else {
+ state.x_1[0] = 9;
+ }
+
+ // Initialize the other temporary variables.
+ Arrays.fill(state.x_2, 0); // x_2 = 1
+ state.x_2[0] = 1;
+ Arrays.fill(state.z_2, 0); // z_2 = 0
+ System.arraycopy(state.x_1, 0, state.x_3, 0, state.x_1.length); // x_3 = x_1
+ Arrays.fill(state.z_3, 0); // z_3 = 1
+ state.z_3[0] = 1;
+
+ // Evaluate the curve for every bit of the private key.
+ state.evalCurve(privateKey);
+
+ // Compute x_2 * (z_2 ^ (p - 2)) where p = 2^255 - 19.
+ state.recip(state.z_3, state.z_2);
+ state.mul(state.x_2, state.x_2, state.z_3);
+
+ // Convert x_2 into little-endian in the result buffer.
+ for (int index = 0; index < 32; ++index) {
+ final int bit = (index * 8) % 26;
+ final int word = (index * 8) / 26;
+ if (bit <= (26 - 8))
+ result[offset + index] = (byte) (state.x_2[word] >> bit);
+ else
+ result[offset + index] = (byte) ((state.x_2[word] >> bit) | (state.x_2[word + 1] << (26 - bit)));
+ }
+ } finally {
+ // Clean up all temporary state before we exit.
+ state.destroy();
+ }
+ }
+
+ /**
+ * Subtracts two numbers modulo 2^255 - 19.
+ *
+ * @param result The result.
+ * @param x The first number to subtract.
+ * @param y The second number to subtract.
+ */
+ private static void sub(final int[] result, final int[] x, final int[] y) {
+ int index;
+ int borrow;
+
+ // Subtract y from x to generate the intermediate result.
+ borrow = 0;
+ for (index = 0; index < NUM_LIMBS_255BIT; ++index) {
+ borrow = x[index] - y[index] - ((borrow >> 26) & 0x01);
+ result[index] = borrow & 0x03FFFFFF;
+ }
+
+ // If we had a borrow, then the result has gone negative and we
+ // have to add 2^255 - 19 to the result to make it positive again.
+ // The top bits of "borrow" will be all 1's if there is a borrow
+ // or it will be all 0's if there was no borrow. Easiest is to
+ // conditionally subtract 19 and then mask off the high bits.
+ borrow = result[0] - ((-((borrow >> 26) & 0x01)) & 19);
+ result[0] = borrow & 0x03FFFFFF;
+ for (index = 1; index < NUM_LIMBS_255BIT; ++index) {
+ borrow = result[index] - ((borrow >> 26) & 0x01);
+ result[index] = borrow & 0x03FFFFFF;
+ }
+ result[NUM_LIMBS_255BIT - 1] &= 0x001FFFFF;
+ }
+
+ /**
+ * Adds two numbers modulo 2^255 - 19.
+ *
+ * @param result The result.
+ * @param x The first number to add.
+ * @param y The second number to add.
+ */
+ private void add(final int[] result, final int[] x, final int[] y) {
+ int carry = x[0] + y[0];
+ result[0] = carry & 0x03FFFFFF;
+ for (int index = 1; index < NUM_LIMBS_255BIT; ++index) {
+ carry = (carry >> 26) + x[index] + y[index];
+ result[index] = carry & 0x03FFFFFF;
+ }
+ reduceQuick(result);
+ }
+
+ /**
+ * Destroy all sensitive data in this object.
+ */
+ private void destroy() {
+ // Destroy all temporary variables.
+ Arrays.fill(x_1, 0);
+ Arrays.fill(x_2, 0);
+ Arrays.fill(x_3, 0);
+ Arrays.fill(z_2, 0);
+ Arrays.fill(z_3, 0);
+ Arrays.fill(A, 0);
+ Arrays.fill(B, 0);
+ Arrays.fill(C, 0);
+ Arrays.fill(D, 0);
+ Arrays.fill(E, 0);
+ Arrays.fill(AA, 0);
+ Arrays.fill(BB, 0);
+ Arrays.fill(DA, 0);
+ Arrays.fill(CB, 0);
+ Arrays.fill(t1, 0L);
+ Arrays.fill(t2, 0);
+ }
+
+ /**
+ * Evaluates the curve for every bit in a secret key.
+ *
+ * @param s The 32-byte secret key.
+ */
+ private void evalCurve(final byte[] s) {
+ int sposn = 31;
+ int sbit = 6;
+ int svalue = s[sposn] | 0x40;
+ int swap = 0;
+
+ // Iterate over all 255 bits of "s" from the highest to the lowest.
+ // We ignore the high bit of the 256-bit representation of "s".
+ while (true) {
+ // Conditional swaps on entry to this bit but only if we
+ // didn't swap on the previous bit.
+ final int select = (svalue >> sbit) & 0x01;
+ swap ^= select;
+ cswap(swap, x_2, x_3);
+ cswap(swap, z_2, z_3);
+ swap = select;
+
+ // Evaluate the curve.
+ add(A, x_2, z_2); // A = x_2 + z_2
+ square(AA, A); // AA = A^2
+ sub(B, x_2, z_2); // B = x_2 - z_2
+ square(BB, B); // BB = B^2
+ sub(E, AA, BB); // E = AA - BB
+ add(C, x_3, z_3); // C = x_3 + z_3
+ sub(D, x_3, z_3); // D = x_3 - z_3
+ mul(DA, D, A); // DA = D * A
+ mul(CB, C, B); // CB = C * B
+ add(x_3, DA, CB); // x_3 = (DA + CB)^2
+ square(x_3, x_3);
+ sub(z_3, DA, CB); // z_3 = x_1 * (DA - CB)^2
+ square(z_3, z_3);
+ mul(z_3, z_3, x_1);
+ mul(x_2, AA, BB); // x_2 = AA * BB
+ mulA24(z_2, E); // z_2 = E * (AA + a24 * E)
+ add(z_2, z_2, AA);
+ mul(z_2, z_2, E);
+
+ // Move onto the next lower bit of "s".
+ if (sbit > 0) {
+ --sbit;
+ } else if (sposn == 0) {
+ break;
+ } else if (sposn == 1) {
+ --sposn;
+ svalue = s[sposn] & 0xF8;
+ sbit = 7;
+ } else {
+ --sposn;
+ svalue = s[sposn];
+ sbit = 7;
+ }
+ }
+
+ // Final conditional swaps.
+ cswap(swap, x_2, x_3);
+ cswap(swap, z_2, z_3);
+ }
+
+ /**
+ * Multiplies two numbers modulo 2^255 - 19.
+ *
+ * @param result The result.
+ * @param x The first number to multiply.
+ * @param y The second number to multiply.
+ */
+ private void mul(final int[] result, final int[] x, final int[] y) {
+ // Multiply the two numbers to create the intermediate result.
+ long v = x[0];
+ for (int i = 0; i < NUM_LIMBS_255BIT; ++i) {
+ t1[i] = v * y[i];
+ }
+ for (int i = 1; i < NUM_LIMBS_255BIT; ++i) {
+ v = x[i];
+ for (int j = 0; j < (NUM_LIMBS_255BIT - 1); ++j) {
+ t1[i + j] += v * y[j];
+ }
+ t1[i + NUM_LIMBS_255BIT - 1] = v * y[NUM_LIMBS_255BIT - 1];
+ }
+
+ // Propagate carries and convert back into 26-bit words.
+ v = t1[0];
+ t2[0] = ((int) v) & 0x03FFFFFF;
+ for (int i = 1; i < NUM_LIMBS_510BIT; ++i) {
+ v = (v >> 26) + t1[i];
+ t2[i] = ((int) v) & 0x03FFFFFF;
+ }
+
+ // Reduce the result modulo 2^255 - 19.
+ reduce(result, t2, NUM_LIMBS_255BIT);
+ }
+
+ /**
+ * Multiplies a number by the a24 constant, modulo 2^255 - 19.
+ *
+ * @param result The result.
+ * @param x The number to multiply by a24.
+ */
+ private void mulA24(final int[] result, final int[] x) {
+ final long a24 = 121665;
+ long carry = 0;
+ for (int index = 0; index < NUM_LIMBS_255BIT; ++index) {
+ carry += a24 * x[index];
+ t2[index] = ((int) carry) & 0x03FFFFFF;
+ carry >>= 26;
+ }
+ t2[NUM_LIMBS_255BIT] = ((int) carry) & 0x03FFFFFF;
+ reduce(result, t2, 1);
+ }
+
+ /**
+ * Raise x to the power of (2^250 - 1).
+ *
+ * @param result The result. Must not overlap with x.
+ * @param x The argument.
+ */
+ private void pow250(final int[] result, final int[] x) {
+ // The big-endian hexadecimal expansion of (2^250 - 1) is:
+ // 03FFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF
+ //
+ // The naive implementation needs to do 2 multiplications per 1 bit and
+ // 1 multiplication per 0 bit. We can improve upon this by creating a
+ // pattern 0000000001 ... 0000000001. If we square and multiply the
+ // pattern by itself we can turn the pattern into the partial results
+ // 0000000011 ... 0000000011, 0000000111 ... 0000000111, etc.
+ // This averages out to about 1.1 multiplications per 1 bit instead of 2.
+
+ // Build a pattern of 250 bits in length of repeated copies of 0000000001.
+ square(A, x);
+ for (int j = 0; j < 9; ++j)
+ square(A, A);
+ mul(result, A, x);
+ for (int i = 0; i < 23; ++i) {
+ for (int j = 0; j < 10; ++j)
+ square(A, A);
+ mul(result, result, A);
+ }
+
+ // Multiply bit-shifted versions of the 0000000001 pattern into
+ // the result to "fill in" the gaps in the pattern.
+ square(A, result);
+ mul(result, result, A);
+ for (int j = 0; j < 8; ++j) {
+ square(A, A);
+ mul(result, result, A);
+ }
+ }
+
+ /**
+ * Computes the reciprocal of a number modulo 2^255 - 19.
+ *
+ * @param result The result. Must not overlap with x.
+ * @param x The argument.
+ */
+ private void recip(final int[] result, final int[] x) {
+ // The reciprocal is the same as x ^ (p - 2) where p = 2^255 - 19.
+ // The big-endian hexadecimal expansion of (p - 2) is:
+ // 7FFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFEB
+ // Start with the 250 upper bits of the expansion of (p - 2).
+ pow250(result, x);
+
+ // Deal with the 5 lowest bits of (p - 2), 01011, from highest to lowest.
+ square(result, result);
+ square(result, result);
+ mul(result, result, x);
+ square(result, result);
+ square(result, result);
+ mul(result, result, x);
+ square(result, result);
+ mul(result, result, x);
+ }
+
+ /**
+ * Reduce a number modulo 2^255 - 19.
+ *
+ * @param result The result.
+ * @param x The value to be reduced. This array will be
+ * modified during the reduction.
+ * @param size The number of limbs in the high order half of x.
+ */
+ private void reduce(final int[] result, final int[] x, final int size) {
+ // Calculate (x mod 2^255) + ((x / 2^255) * 19) which will
+ // either produce the answer we want or it will produce a
+ // value of the form "answer + j * (2^255 - 19)". There are
+ // 5 left-over bits in the top-most limb of the bottom half.
+ int carry = 0;
+ int limb = x[NUM_LIMBS_255BIT - 1] >> 21;
+ x[NUM_LIMBS_255BIT - 1] &= 0x001FFFFF;
+ for (int index = 0; index < size; ++index) {
+ limb += x[NUM_LIMBS_255BIT + index] << 5;
+ carry += (limb & 0x03FFFFFF) * 19 + x[index];
+ x[index] = carry & 0x03FFFFFF;
+ limb >>= 26;
+ carry >>= 26;
+ }
+ if (size < NUM_LIMBS_255BIT) {
+ // The high order half of the number is short; e.g. for mulA24().
+ // Propagate the carry through the rest of the low order part.
+ for (int index = size; index < NUM_LIMBS_255BIT; ++index) {
+ carry += x[index];
+ x[index] = carry & 0x03FFFFFF;
+ carry >>= 26;
+ }
+ }
+
+ // The "j" value may still be too large due to the final carry-out.
+ // We must repeat the reduction. If we already have the answer,
+ // then this won't do any harm but we must still do the calculation
+ // to preserve the overall timing. The "j" value will be between
+ // 0 and 19, which means that the carry we care about is in the
+ // top 5 bits of the highest limb of the bottom half.
+ carry = (x[NUM_LIMBS_255BIT - 1] >> 21) * 19;
+ x[NUM_LIMBS_255BIT - 1] &= 0x001FFFFF;
+ for (int index = 0; index < NUM_LIMBS_255BIT; ++index) {
+ carry += x[index];
+ result[index] = carry & 0x03FFFFFF;
+ carry >>= 26;
+ }
+
+ // At this point "x" will either be the answer or it will be the
+ // answer plus (2^255 - 19). Perform a trial subtraction to
+ // complete the reduction process.
+ reduceQuick(result);
+ }
+
+ /**
+ * Reduces a number modulo 2^255 - 19 where it is known that the
+ * number can be reduced with only 1 trial subtraction.
+ *
+ * @param x The number to reduce, and the result.
+ */
+ private void reduceQuick(final int[] x) {
+ // Perform a trial subtraction of (2^255 - 19) from "x" which is
+ // equivalent to adding 19 and subtracting 2^255. We add 19 here;
+ // the subtraction of 2^255 occurs in the next step.
+ int carry = 19;
+ for (int index = 0; index < NUM_LIMBS_255BIT; ++index) {
+ carry += x[index];
+ t2[index] = carry & 0x03FFFFFF;
+ carry >>= 26;
+ }
+
+ // If there was a borrow, then the original "x" is the correct answer.
+ // If there was no borrow, then "t2" is the correct answer. Select the
+ // correct answer but do it in a way that instruction timing will not
+ // reveal which value was selected. Borrow will occur if bit 21 of
+ // "t2" is zero. Turn the bit into a selection mask.
+ final int mask = -((t2[NUM_LIMBS_255BIT - 1] >> 21) & 0x01);
+ final int nmask = ~mask;
+ t2[NUM_LIMBS_255BIT - 1] &= 0x001FFFFF;
+ for (int index = 0; index < NUM_LIMBS_255BIT; ++index)
+ x[index] = (x[index] & nmask) | (t2[index] & mask);
+ }
+
+ /**
+ * Squares a number modulo 2^255 - 19.
+ *
+ * @param result The result.
+ * @param x The number to square.
+ */
+ private void square(final int[] result, final int[] x) {
+ mul(result, x, x);
+ }
+}
diff --git a/tunnel/src/main/java/com/wireguard/crypto/Key.java b/tunnel/src/main/java/com/wireguard/crypto/Key.java
new file mode 100644
index 0000000..1aff670
--- /dev/null
+++ b/tunnel/src/main/java/com/wireguard/crypto/Key.java
@@ -0,0 +1,290 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.crypto;
+
+import com.wireguard.crypto.KeyFormatException.Type;
+import com.wireguard.util.NonNullForAll;
+
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.util.Arrays;
+
+/**
+ * Represents a WireGuard public or private key. This class uses specialized constant-time base64
+ * and hexadecimal codec implementations that resist side-channel attacks.
+ *
+ * Instances of this class are immutable.
+ */
+@SuppressWarnings("MagicNumber")
+@NonNullForAll
+public final class Key {
+ private final byte[] key;
+
+ /**
+ * Constructs an object encapsulating the supplied key.
+ *
+ * @param key an array of bytes containing a binary key. Callers of this constructor are
+ * responsible for ensuring that the array is of the correct length.
+ */
+ private Key(final byte[] key) {
+ // Defensively copy to ensure immutability.
+ this.key = Arrays.copyOf(key, key.length);
+ }
+
+ /**
+ * Decodes a single 4-character base64 chunk to an integer in constant time.
+ *
+ * @param src an array of at least 4 characters in base64 format
+ * @param srcOffset the offset of the beginning of the chunk in {@code src}
+ * @return the decoded 3-byte integer, or some arbitrary integer value if the input was not
+ * valid base64
+ */
+ private static int decodeBase64(final char[] src, final int srcOffset) {
+ int val = 0;
+ for (int i = 0; i < 4; ++i) {
+ final char c = src[i + srcOffset];
+ val |= (-1
+ + ((((('A' - 1) - c) & (c - ('Z' + 1))) >>> 8) & (c - 64))
+ + ((((('a' - 1) - c) & (c - ('z' + 1))) >>> 8) & (c - 70))
+ + ((((('0' - 1) - c) & (c - ('9' + 1))) >>> 8) & (c + 5))
+ + ((((('+' - 1) - c) & (c - ('+' + 1))) >>> 8) & 63)
+ + ((((('/' - 1) - c) & (c - ('/' + 1))) >>> 8) & 64)
+ ) << (18 - 6 * i);
+ }
+ return val;
+ }
+
+ /**
+ * Encodes a single 4-character base64 chunk from 3 consecutive bytes in constant time.
+ *
+ * @param src an array of at least 3 bytes
+ * @param srcOffset the offset of the beginning of the chunk in {@code src}
+ * @param dest an array of at least 4 characters
+ * @param destOffset the offset of the beginning of the chunk in {@code dest}
+ */
+ private static void encodeBase64(final byte[] src, final int srcOffset,
+ final char[] dest, final int destOffset) {
+ final byte[] input = {
+ (byte) ((src[srcOffset] >>> 2) & 63),
+ (byte) ((src[srcOffset] << 4 | ((src[1 + srcOffset] & 0xff) >>> 4)) & 63),
+ (byte) ((src[1 + srcOffset] << 2 | ((src[2 + srcOffset] & 0xff) >>> 6)) & 63),
+ (byte) ((src[2 + srcOffset]) & 63),
+ };
+ for (int i = 0; i < 4; ++i) {
+ dest[i + destOffset] = (char) (input[i] + 'A'
+ + (((25 - input[i]) >>> 8) & 6)
+ - (((51 - input[i]) >>> 8) & 75)
+ - (((61 - input[i]) >>> 8) & 15)
+ + (((62 - input[i]) >>> 8) & 3));
+ }
+ }
+
+ /**
+ * Decodes a WireGuard public or private key from its base64 string representation. This
+ * function throws a {@link KeyFormatException} if the source string is not well-formed.
+ *
+ * @param str the base64 string representation of a WireGuard key
+ * @return the decoded key encapsulated in an immutable container
+ */
+ public static Key fromBase64(final String str) throws KeyFormatException {
+ final char[] input = str.toCharArray();
+ if (input.length != Format.BASE64.length || input[Format.BASE64.length - 1] != '=')
+ throw new KeyFormatException(Format.BASE64, Type.LENGTH);
+ final byte[] key = new byte[Format.BINARY.length];
+ int i;
+ int ret = 0;
+ for (i = 0; i < key.length / 3; ++i) {
+ final int val = decodeBase64(input, i * 4);
+ ret |= val >>> 31;
+ key[i * 3] = (byte) ((val >>> 16) & 0xff);
+ key[i * 3 + 1] = (byte) ((val >>> 8) & 0xff);
+ key[i * 3 + 2] = (byte) (val & 0xff);
+ }
+ final char[] endSegment = {
+ input[i * 4],
+ input[i * 4 + 1],
+ input[i * 4 + 2],
+ 'A',
+ };
+ final int val = decodeBase64(endSegment, 0);
+ ret |= (val >>> 31) | (val & 0xff);
+ key[i * 3] = (byte) ((val >>> 16) & 0xff);
+ key[i * 3 + 1] = (byte) ((val >>> 8) & 0xff);
+
+ if (ret != 0)
+ throw new KeyFormatException(Format.BASE64, Type.CONTENTS);
+ return new Key(key);
+ }
+
+ /**
+ * Wraps a WireGuard public or private key in an immutable container. This function throws a
+ * {@link KeyFormatException} if the source data is not the correct length.
+ *
+ * @param bytes an array of bytes containing a WireGuard key in binary format
+ * @return the key encapsulated in an immutable container
+ */
+ public static Key fromBytes(final byte[] bytes) throws KeyFormatException {
+ if (bytes.length != Format.BINARY.length)
+ throw new KeyFormatException(Format.BINARY, Type.LENGTH);
+ return new Key(bytes);
+ }
+
+ /**
+ * Decodes a WireGuard public or private key from its hexadecimal string representation. This
+ * function throws a {@link KeyFormatException} if the source string is not well-formed.
+ *
+ * @param str the hexadecimal string representation of a WireGuard key
+ * @return the decoded key encapsulated in an immutable container
+ */
+ public static Key fromHex(final String str) throws KeyFormatException {
+ final char[] input = str.toCharArray();
+ if (input.length != Format.HEX.length)
+ throw new KeyFormatException(Format.HEX, Type.LENGTH);
+ final byte[] key = new byte[Format.BINARY.length];
+ int ret = 0;
+ for (int i = 0; i < key.length; ++i) {
+ int c;
+ int cNum;
+ int cNum0;
+ int cAlpha;
+ int cAlpha0;
+ int cVal;
+ final int cAcc;
+
+ c = input[i * 2];
+ cNum = c ^ 48;
+ cNum0 = ((cNum - 10) >>> 8) & 0xff;
+ cAlpha = (c & ~32) - 55;
+ cAlpha0 = (((cAlpha - 10) ^ (cAlpha - 16)) >>> 8) & 0xff;
+ ret |= ((cNum0 | cAlpha0) - 1) >>> 8;
+ cVal = (cNum0 & cNum) | (cAlpha0 & cAlpha);
+ cAcc = cVal * 16;
+
+ c = input[i * 2 + 1];
+ cNum = c ^ 48;
+ cNum0 = ((cNum - 10) >>> 8) & 0xff;
+ cAlpha = (c & ~32) - 55;
+ cAlpha0 = (((cAlpha - 10) ^ (cAlpha - 16)) >>> 8) & 0xff;
+ ret |= ((cNum0 | cAlpha0) - 1) >>> 8;
+ cVal = (cNum0 & cNum) | (cAlpha0 & cAlpha);
+ key[i] = (byte) (cAcc | cVal);
+ }
+ if (ret != 0)
+ throw new KeyFormatException(Format.HEX, Type.CONTENTS);
+ return new Key(key);
+ }
+
+ /**
+ * Generates a private key using the system's {@link SecureRandom} number generator.
+ *
+ * @return a well-formed random private key
+ */
+ static Key generatePrivateKey() {
+ final SecureRandom secureRandom = new SecureRandom();
+ final byte[] privateKey = new byte[Format.BINARY.getLength()];
+ secureRandom.nextBytes(privateKey);
+ privateKey[0] &= 248;
+ privateKey[31] &= 127;
+ privateKey[31] |= 64;
+ return new Key(privateKey);
+ }
+
+ /**
+ * Generates a public key from an existing private key.
+ *
+ * @param privateKey a private key
+ * @return a well-formed public key that corresponds to the supplied private key
+ */
+ static Key generatePublicKey(final Key privateKey) {
+ final byte[] publicKey = new byte[Format.BINARY.getLength()];
+ Curve25519.eval(publicKey, 0, privateKey.getBytes(), null);
+ return new Key(publicKey);
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if (obj == this)
+ return true;
+ if (obj == null || obj.getClass() != getClass())
+ return false;
+ final Key other = (Key) obj;
+ return MessageDigest.isEqual(key, other.key);
+ }
+
+ /**
+ * Returns the key as an array of bytes.
+ *
+ * @return an array of bytes containing the raw binary key
+ */
+ public byte[] getBytes() {
+ // Defensively copy to ensure immutability.
+ return Arrays.copyOf(key, key.length);
+ }
+
+ @Override
+ public int hashCode() {
+ int ret = 0;
+ for (int i = 0; i < key.length / 4; ++i)
+ ret ^= (key[i * 4 + 0] >> 0) + (key[i * 4 + 1] >> 8) + (key[i * 4 + 2] >> 16) + (key[i * 4 + 3] >> 24);
+ return ret;
+ }
+
+ /**
+ * Encodes the key to base64.
+ *
+ * @return a string containing the encoded key
+ */
+ public String toBase64() {
+ final char[] output = new char[Format.BASE64.length];
+ int i;
+ for (i = 0; i < key.length / 3; ++i)
+ encodeBase64(key, i * 3, output, i * 4);
+ final byte[] endSegment = {
+ key[i * 3],
+ key[i * 3 + 1],
+ 0,
+ };
+ encodeBase64(endSegment, 0, output, i * 4);
+ output[Format.BASE64.length - 1] = '=';
+ return new String(output);
+ }
+
+ /**
+ * Encodes the key to hexadecimal ASCII characters.
+ *
+ * @return a string containing the encoded key
+ */
+ public String toHex() {
+ final char[] output = new char[Format.HEX.length];
+ for (int i = 0; i < key.length; ++i) {
+ output[i * 2] = (char) (87 + (key[i] >> 4 & 0xf)
+ + ((((key[i] >> 4 & 0xf) - 10) >> 8) & ~38));
+ output[i * 2 + 1] = (char) (87 + (key[i] & 0xf)
+ + ((((key[i] & 0xf) - 10) >> 8) & ~38));
+ }
+ return new String(output);
+ }
+
+ /**
+ * The supported formats for encoding a WireGuard key.
+ */
+ public enum Format {
+ BASE64(44),
+ BINARY(32),
+ HEX(64);
+
+ private final int length;
+
+ Format(final int length) {
+ this.length = length;
+ }
+
+ public int getLength() {
+ return length;
+ }
+ }
+
+}
diff --git a/tunnel/src/main/java/com/wireguard/crypto/KeyFormatException.java b/tunnel/src/main/java/com/wireguard/crypto/KeyFormatException.java
new file mode 100644
index 0000000..b1503f2
--- /dev/null
+++ b/tunnel/src/main/java/com/wireguard/crypto/KeyFormatException.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.crypto;
+
+import com.wireguard.util.NonNullForAll;
+
+/**
+ * An exception thrown when attempting to parse an invalid key (too short, too long, or byte
+ * data inappropriate for the format). The format being parsed can be accessed with the
+ * {@link #getFormat} method.
+ */
+@NonNullForAll
+public final class KeyFormatException extends Exception {
+ private final Key.Format format;
+ private final Type type;
+
+ KeyFormatException(final Key.Format format, final Type type) {
+ this.format = format;
+ this.type = type;
+ }
+
+ public Key.Format getFormat() {
+ return format;
+ }
+
+ public Type getType() {
+ return type;
+ }
+
+ public enum Type {
+ CONTENTS,
+ LENGTH
+ }
+}
diff --git a/tunnel/src/main/java/com/wireguard/crypto/KeyPair.java b/tunnel/src/main/java/com/wireguard/crypto/KeyPair.java
new file mode 100644
index 0000000..85f94ca
--- /dev/null
+++ b/tunnel/src/main/java/com/wireguard/crypto/KeyPair.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.crypto;
+
+import com.wireguard.util.NonNullForAll;
+
+/**
+ * Represents a Curve25519 key pair as used by WireGuard.
+ *
+ * Instances of this class are immutable.
+ */
+@NonNullForAll
+public class KeyPair {
+ private final Key privateKey;
+ private final Key publicKey;
+
+ /**
+ * Creates a key pair using a newly-generated private key.
+ */
+ public KeyPair() {
+ this(Key.generatePrivateKey());
+ }
+
+ /**
+ * Creates a key pair using an existing private key.
+ *
+ * @param privateKey a private key, used to derive the public key
+ */
+ public KeyPair(final Key privateKey) {
+ this.privateKey = privateKey;
+ publicKey = Key.generatePublicKey(privateKey);
+ }
+
+ /**
+ * Returns the private key from the key pair.
+ *
+ * @return the private key
+ */
+ public Key getPrivateKey() {
+ return privateKey;
+ }
+
+ /**
+ * Returns the public key from the key pair.
+ *
+ * @return the public key
+ */
+ public Key getPublicKey() {
+ return publicKey;
+ }
+}
diff --git a/tunnel/src/main/java/com/wireguard/util/NonNullForAll.java b/tunnel/src/main/java/com/wireguard/util/NonNullForAll.java
new file mode 100644
index 0000000..a6598fd
--- /dev/null
+++ b/tunnel/src/main/java/com/wireguard/util/NonNullForAll.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.util;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+import javax.annotation.Nonnull;
+import javax.annotation.meta.TypeQualifierDefault;
+
+import androidx.annotation.RestrictTo;
+import androidx.annotation.RestrictTo.Scope;
+
+/**
+ * This annotation can be applied to a package, class or method to indicate that all
+ * class fields and method parameters and return values in that element are nonnull
+ * by default unless overridden.
+ */
+@RestrictTo(Scope.LIBRARY_GROUP)
+@Nonnull
+@TypeQualifierDefault({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER})
+@Retention(RetentionPolicy.RUNTIME)
+
+public @interface NonNullForAll {
+}
diff --git a/tunnel/src/test/java/com/wireguard/config/BadConfigExceptionTest.java b/tunnel/src/test/java/com/wireguard/config/BadConfigExceptionTest.java
new file mode 100644
index 0000000..59badad
--- /dev/null
+++ b/tunnel/src/test/java/com/wireguard/config/BadConfigExceptionTest.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.config;
+
+import com.wireguard.config.BadConfigException.Location;
+import com.wireguard.config.BadConfigException.Reason;
+import com.wireguard.config.BadConfigException.Section;
+
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+public class BadConfigExceptionTest {
+ private static final Map CONFIG_MAP = new HashMap<>();
+ private static final String[] CONFIG_NAMES = {
+ "invalid-key",
+ "invalid-number",
+ "invalid-value",
+ "missing-attribute",
+ "missing-section",
+ "syntax-error",
+ "unknown-attribute",
+ "unknown-section"
+ };
+
+ @AfterClass
+ public static void closeStreams() {
+ for (final InputStream inputStream : CONFIG_MAP.values()) {
+ try {
+ inputStream.close();
+ } catch (final IOException ignored) {
+ }
+ }
+ }
+
+ @BeforeClass
+ public static void readConfigs() {
+ for (final String config : CONFIG_NAMES) {
+ CONFIG_MAP.put(config, BadConfigExceptionTest.class.getClassLoader().getResourceAsStream(config + ".conf"));
+ }
+ }
+
+ @Test
+ public void throws_correctly_with_INVALID_KEY_reason() {
+ try {
+ Config.parse(CONFIG_MAP.get("invalid-key"));
+ fail("Config parsing must fail in this test");
+ } catch (final BadConfigException e) {
+ assertEquals(e.getReason(), Reason.INVALID_KEY);
+ assertEquals(e.getLocation(), Location.PUBLIC_KEY);
+ assertEquals(e.getSection(), Section.PEER);
+ } catch (final IOException e) {
+ e.printStackTrace();
+ fail("IOException thrown during test");
+ }
+ }
+
+ @Test
+ public void throws_correctly_with_INVALID_NUMBER_reason() {
+ try {
+ Config.parse(CONFIG_MAP.get("invalid-number"));
+ fail("Config parsing must fail in this test");
+ } catch (final BadConfigException e) {
+ assertEquals(e.getReason(), Reason.INVALID_NUMBER);
+ assertEquals(e.getLocation(), Location.PERSISTENT_KEEPALIVE);
+ assertEquals(e.getSection(), Section.PEER);
+ } catch (final IOException e) {
+ e.printStackTrace();
+ fail("IOException thrown during test");
+ }
+ }
+
+ @Test
+ public void throws_correctly_with_INVALID_VALUE_reason() {
+ try {
+ Config.parse(CONFIG_MAP.get("invalid-value"));
+ fail("Config parsing must fail in this test");
+ } catch (final BadConfigException e) {
+ assertEquals(e.getReason(), Reason.INVALID_VALUE);
+ assertEquals(e.getLocation(), Location.DNS);
+ assertEquals(e.getSection(), Section.INTERFACE);
+ } catch (final IOException e) {
+ e.printStackTrace();
+ fail("IOException throwing during test");
+ }
+ }
+
+ @Test
+ public void throws_correctly_with_MISSING_ATTRIBUTE_reason() {
+ try {
+ Config.parse(CONFIG_MAP.get("missing-attribute"));
+ fail("Config parsing must fail in this test");
+ } catch (final BadConfigException e) {
+ assertEquals(e.getReason(), Reason.MISSING_ATTRIBUTE);
+ assertEquals(e.getLocation(), Location.PUBLIC_KEY);
+ assertEquals(e.getSection(), Section.PEER);
+ } catch (final IOException e) {
+ e.printStackTrace();
+ fail("IOException throwing during test");
+ }
+ }
+
+ @Test
+ public void throws_correctly_with_MISSING_SECTION_reason() {
+ try {
+ Config.parse(CONFIG_MAP.get("missing-section"));
+ fail("Config parsing must fail in this test");
+ } catch (final BadConfigException e) {
+ assertEquals(e.getReason(), Reason.MISSING_SECTION);
+ assertEquals(e.getLocation(), Location.TOP_LEVEL);
+ assertEquals(e.getSection(), Section.CONFIG);
+ } catch (final IOException e) {
+ e.printStackTrace();
+ fail("IOException throwing during test");
+ }
+ }
+
+ @Test
+ public void throws_correctly_with_SYNTAX_ERROR_reason() {
+ try {
+ Config.parse(CONFIG_MAP.get("syntax-error"));
+ fail("Config parsing must fail in this test");
+ } catch (final BadConfigException e) {
+ assertEquals(e.getReason(), Reason.SYNTAX_ERROR);
+ assertEquals(e.getLocation(), Location.TOP_LEVEL);
+ assertEquals(e.getSection(), Section.PEER);
+ } catch (final IOException e) {
+ e.printStackTrace();
+ fail("IOException throwing during test");
+ }
+ }
+
+ @Test
+ public void throws_correctly_with_UNKNOWN_ATTRIBUTE_reason() {
+ try {
+ Config.parse(CONFIG_MAP.get("unknown-attribute"));
+ fail("Config parsing must fail in this test");
+ } catch (final BadConfigException e) {
+ assertEquals(e.getReason(), Reason.UNKNOWN_ATTRIBUTE);
+ assertEquals(e.getLocation(), Location.TOP_LEVEL);
+ assertEquals(e.getSection(), Section.PEER);
+ } catch (final IOException e) {
+ e.printStackTrace();
+ fail("IOException throwing during test");
+ }
+ }
+
+ @Test
+ public void throws_correctly_with_UNKNOWN_SECTION_reason() {
+ try {
+ Config.parse(CONFIG_MAP.get("unknown-section"));
+ fail("Config parsing must fail in this test");
+ } catch (final BadConfigException e) {
+ assertEquals(e.getReason(), Reason.UNKNOWN_SECTION);
+ assertEquals(e.getLocation(), Location.TOP_LEVEL);
+ assertEquals(e.getSection(), Section.CONFIG);
+ } catch (final IOException e) {
+ e.printStackTrace();
+ fail("IOException throwing during test");
+ }
+ }
+}
diff --git a/tunnel/src/test/java/com/wireguard/config/ConfigTest.java b/tunnel/src/test/java/com/wireguard/config/ConfigTest.java
new file mode 100644
index 0000000..582c7ac
--- /dev/null
+++ b/tunnel/src/test/java/com/wireguard/config/ConfigTest.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.config;
+
+import org.junit.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Objects;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class ConfigTest {
+
+ @Test(expected = BadConfigException.class)
+ public void invalid_config_throws() throws IOException, BadConfigException {
+ try (final InputStream is = Objects.requireNonNull(getClass().getClassLoader()).getResourceAsStream("broken.conf")) {
+ Config.parse(is);
+ }
+ }
+
+ @Test
+ public void valid_config_parses_correctly() throws IOException, ParseException {
+ Config config = null;
+ final Collection expectedAllowedIps = new HashSet<>(Arrays.asList(InetNetwork.parse("0.0.0.0/0"), InetNetwork.parse("::0/0")));
+ try (final InputStream is = Objects.requireNonNull(getClass().getClassLoader()).getResourceAsStream("working.conf")) {
+ config = Config.parse(is);
+ } catch (final BadConfigException e) {
+ fail("'working.conf' should never fail to parse");
+ }
+ assertNotNull("config cannot be null after parsing", config);
+ assertTrue(
+ "No applications should be excluded by default",
+ config.getInterface().getExcludedApplications().isEmpty()
+ );
+ assertEquals("Test config has exactly one peer", 1, config.getPeers().size());
+ assertEquals("Test config's allowed IPs are 0.0.0.0/0 and ::0/0", config.getPeers().get(0).getAllowedIps(), expectedAllowedIps);
+ assertEquals("Test config has one DNS server", 1, config.getInterface().getDnsServers().size());
+ }
+}
diff --git a/tunnel/src/test/resources/broken.conf b/tunnel/src/test/resources/broken.conf
new file mode 100644
index 0000000..753c971
--- /dev/null
+++ b/tunnel/src/test/resources/broken.conf
@@ -0,0 +1,9 @@
+[Interface]
+PrivateKey = l0lth1s1sd3f1n1t3lybr0k3n=
+Address = 192.0.2.2/32,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff/128
+DNS = 192.0.2.0
+
+[Peer]
+PublicKey = vBN7qyUTb5lJtWYJ8LhbPio1Z4RcyBPGnqFBGn6O6Qg=
+AllowedIPs = 0.0.0.0/0,::0/0
+Endpoint = 192.0.2.1:51820
diff --git a/tunnel/src/test/resources/invalid-key.conf b/tunnel/src/test/resources/invalid-key.conf
new file mode 100644
index 0000000..215bec3
--- /dev/null
+++ b/tunnel/src/test/resources/invalid-key.conf
@@ -0,0 +1,9 @@
+[Interface]
+Address = 192.0.2.2/32,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff/128
+DNS = 192.0.2.0
+PrivateKey = TFlmmEUC7V7VtiDYLKsbP5rySTKLIZq1yn8lMqK83wo=
+[Peer]
+AllowedIPs = 0.0.0.0/0, ::0/0
+Endpoint = 192.0.2.1:51820
+PersistentKeepalive = 0
+PublicKey = vBN7qyUTb5lJtWYJ8LhbPio1Z4RcyBPGnqFBGn6Og=
diff --git a/tunnel/src/test/resources/invalid-number.conf b/tunnel/src/test/resources/invalid-number.conf
new file mode 100644
index 0000000..f05fe32
--- /dev/null
+++ b/tunnel/src/test/resources/invalid-number.conf
@@ -0,0 +1,9 @@
+[Interface]
+Address = 192.0.2.2/32,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff/128
+DNS = 192.0.2.0
+PrivateKey = TFlmmEUC7V7VtiDYLKsbP5rySTKLIZq1yn8lMqK83wo=
+[Peer]
+AllowedIPs = 0.0.0.0/0, ::0/0
+Endpoint = 192.0.2.1:51820
+PersistentKeepalive = 0L
+PublicKey = vBN7qyUTb5lJtWYJ8LhbPio1Z4RcyBPGnqFBGn6O6Qg=
diff --git a/tunnel/src/test/resources/invalid-value.conf b/tunnel/src/test/resources/invalid-value.conf
new file mode 100644
index 0000000..6a1e3b6
--- /dev/null
+++ b/tunnel/src/test/resources/invalid-value.conf
@@ -0,0 +1,9 @@
+[Interface]
+Address = 192.0.2.2/32,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff/128
+DNS = 192.0.2.0,invalid_value
+PrivateKey = TFlmmEUC7V7VtiDYLKsbP5rySTKLIZq1yn8lMqK83wo=
+[Peer]
+AllowedIPs = 0.0.0.0/0, ::0/0
+Endpoint = 192.0.2.1:51820
+PersistentKeepalive = 0
+PublicKey = vBN7qyUTb5lJtWYJ8LhbPio1Z4RcyBPGnqFBGn6O6Qg=
diff --git a/tunnel/src/test/resources/missing-attribute.conf b/tunnel/src/test/resources/missing-attribute.conf
new file mode 100644
index 0000000..ddf8cbb
--- /dev/null
+++ b/tunnel/src/test/resources/missing-attribute.conf
@@ -0,0 +1,8 @@
+[Interface]
+Address = 192.0.2.2/32,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff/128
+DNS = 192.0.2.0
+PrivateKey = TFlmmEUC7V7VtiDYLKsbP5rySTKLIZq1yn8lMqK83wo=
+[Peer]
+AllowedIPs = 0.0.0.0/0, ::0/0
+Endpoint = 192.0.2.1:51820
+PersistentKeepalive = 0
diff --git a/tunnel/src/test/resources/missing-section.conf b/tunnel/src/test/resources/missing-section.conf
new file mode 100644
index 0000000..676199a
--- /dev/null
+++ b/tunnel/src/test/resources/missing-section.conf
@@ -0,0 +1,5 @@
+[Peer]
+AllowedIPs = 0.0.0.0/0, ::0/0
+Endpoint = 192.0.2.1:51820
+PersistentKeepalive = 0
+PublicKey = vBN7qyUTb5lJtWYJ8LhbPio1Z4RcyBPGnqFBGn6O6Qg=
diff --git a/tunnel/src/test/resources/syntax-error.conf b/tunnel/src/test/resources/syntax-error.conf
new file mode 100644
index 0000000..38b8ec9
--- /dev/null
+++ b/tunnel/src/test/resources/syntax-error.conf
@@ -0,0 +1,9 @@
+[Interface]
+Address = 192.0.2.2/32,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff/128
+DNS = 192.0.2.0
+PrivateKey = TFlmmEUC7V7VtiDYLKsbP5rySTKLIZq1yn8lMqK83wo=
+[Peer]
+AllowedIPs = 0.0.0.0/0, ::0/0
+Endpoint =
+PersistentKeepalive = 0
+PublicKey = vBN7qyUTb5lJtWYJ8LhbPio1Z4RcyBPGnqFBGn6O6Qg=
diff --git a/tunnel/src/test/resources/unknown-attribute.conf b/tunnel/src/test/resources/unknown-attribute.conf
new file mode 100644
index 0000000..f311161
--- /dev/null
+++ b/tunnel/src/test/resources/unknown-attribute.conf
@@ -0,0 +1,9 @@
+[Interface]
+Address = 192.0.2.2/32,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff/128
+DNS = 192.0.2.0
+PrivateKey = TFlmmEUC7V7VtiDYLKsbP5rySTKLIZq1yn8lMqK83wo=
+[Peer]
+AllowedIPs = 0.0.0.0/0, ::0/0
+Endpoint = 192.0.2.1:51820
+DontLetTheFeelingFade = 1
+PublicKey = vBN7qyUTb5lJtWYJ8LhbPio1Z4RcyBPGnqFBGn6O6Qg=
diff --git a/tunnel/src/test/resources/unknown-section.conf b/tunnel/src/test/resources/unknown-section.conf
new file mode 100644
index 0000000..579d971
--- /dev/null
+++ b/tunnel/src/test/resources/unknown-section.conf
@@ -0,0 +1,9 @@
+[Interface]
+Address = 192.0.2.2/32,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff/128
+DNS = 192.0.2.0
+PrivateKey = TFlmmEUC7V7VtiDYLKsbP5rySTKLIZq1yn8lMqK83wo=
+[Peers]
+AllowedIPs = 0.0.0.0/0, ::0/0
+Endpoint = 192.0.2.1:51820
+PersistentKeepalive = 0
+PublicKey = vBN7qyUTb5lJtWYJ8LhbPio1Z4RcyBPGnqFBGn6O6Qg=
diff --git a/tunnel/src/test/resources/working.conf b/tunnel/src/test/resources/working.conf
new file mode 100644
index 0000000..3f9665c
--- /dev/null
+++ b/tunnel/src/test/resources/working.conf
@@ -0,0 +1,9 @@
+[Interface]
+Address = 192.0.2.2/32,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff/128
+DNS = 192.0.2.0
+PrivateKey = TFlmmEUC7V7VtiDYLKsbP5rySTKLIZq1yn8lMqK83wo=
+[Peer]
+AllowedIPs = 0.0.0.0/0, ::0/0
+Endpoint = 192.0.2.1:51820
+PersistentKeepalive = 0
+PublicKey = vBN7qyUTb5lJtWYJ8LhbPio1Z4RcyBPGnqFBGn6O6Qg=
diff --git a/tunnel/tools/CMakeLists.txt b/tunnel/tools/CMakeLists.txt
new file mode 100644
index 0000000..b62a163
--- /dev/null
+++ b/tunnel/tools/CMakeLists.txt
@@ -0,0 +1,44 @@
+# SPDX-License-Identifier: Apache-2.0
+#
+# Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+
+cmake_minimum_required(VERSION 3.4.1)
+project("WireGuard")
+set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_LIBRARY_OUTPUT_DIRECTORY}")
+add_link_options(LINKER:--build-id=none)
+add_compile_options(-Wall -Werror)
+
+add_executable(libwg-quick.so wireguard-tools/src/wg-quick/android.c ndk-compat/compat.c)
+target_compile_options(libwg-quick.so PUBLIC -std=gnu11 -include ${CMAKE_CURRENT_SOURCE_DIR}/ndk-compat/compat.h -DWG_PACKAGE_NAME=\"${ANDROID_PACKAGE_NAME}\")
+target_link_libraries(libwg-quick.so -ldl)
+
+file(GLOB WG_SOURCES wireguard-tools/src/*.c ndk-compat/compat.c)
+add_executable(libwg.so ${WG_SOURCES})
+target_include_directories(libwg.so PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/wireguard-tools/src/uapi/linux/" "${CMAKE_CURRENT_SOURCE_DIR}/wireguard-tools/src/")
+target_compile_options(libwg.so PUBLIC -std=gnu11 -include ${CMAKE_CURRENT_SOURCE_DIR}/ndk-compat/compat.h -DRUNSTATEDIR=\"/data/data/${ANDROID_PACKAGE_NAME}/cache\")
+
+add_custom_target(libwg-go.so WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/libwg-go" COMMENT "Building wireguard-go" VERBATIM COMMAND "${ANDROID_HOST_PREBUILTS}/bin/make"
+ ANDROID_ARCH_NAME=${ANDROID_ARCH_NAME}
+ ANDROID_PACKAGE_NAME=${ANDROID_PACKAGE_NAME}
+ GRADLE_USER_HOME=${GRADLE_USER_HOME}
+ CC=${CMAKE_C_COMPILER}
+ CFLAGS=${CMAKE_C_FLAGS}
+ LDFLAGS=${CMAKE_SHARED_LINKER_FLAGS}
+ SYSROOT=${CMAKE_SYSROOT}
+ TARGET=${CMAKE_C_COMPILER_TARGET}
+ DESTDIR=${CMAKE_LIBRARY_OUTPUT_DIRECTORY}
+ BUILDDIR=${CMAKE_LIBRARY_OUTPUT_DIRECTORY}/../generated-src
+)
+
+# Strip unwanted ELF sections to prevent DT_FLAGS_1 warnings on old Android versions
+file(GLOB ELF_CLEANER_SOURCES elf-cleaner/*.c elf-cleaner/*.cpp)
+add_custom_target(elf-cleaner COMMENT "Building elf-cleaner" VERBATIM COMMAND cc
+ -O2 -DPACKAGE_NAME="elf-cleaner" -DPACKAGE_VERSION="" -DCOPYRIGHT=""
+ -o "${CMAKE_CURRENT_BINARY_DIR}/elf-cleaner" ${ELF_CLEANER_SOURCES}
+)
+add_custom_command(TARGET libwg.so POST_BUILD VERBATIM COMMAND "${CMAKE_CURRENT_BINARY_DIR}/elf-cleaner"
+ --api-level "${ANDROID_NATIVE_API_LEVEL}" "$")
+add_dependencies(libwg.so elf-cleaner)
+add_custom_command(TARGET libwg-quick.so POST_BUILD VERBATIM COMMAND "${CMAKE_CURRENT_BINARY_DIR}/elf-cleaner"
+ --api-level "${ANDROID_NATIVE_API_LEVEL}" "$")
+add_dependencies(libwg-quick.so elf-cleaner)
diff --git a/tunnel/tools/libwg-go/.gitignore b/tunnel/tools/libwg-go/.gitignore
new file mode 100644
index 0000000..d163863
--- /dev/null
+++ b/tunnel/tools/libwg-go/.gitignore
@@ -0,0 +1 @@
+build/
\ No newline at end of file
diff --git a/tunnel/tools/libwg-go/Makefile b/tunnel/tools/libwg-go/Makefile
new file mode 100644
index 0000000..5b34355
--- /dev/null
+++ b/tunnel/tools/libwg-go/Makefile
@@ -0,0 +1,52 @@
+# SPDX-License-Identifier: Apache-2.0
+#
+# Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+
+BUILDDIR ?= $(CURDIR)/build
+DESTDIR ?= $(CURDIR)/out
+
+NDK_GO_ARCH_MAP_x86 := 386
+NDK_GO_ARCH_MAP_x86_64 := amd64
+NDK_GO_ARCH_MAP_arm := arm
+NDK_GO_ARCH_MAP_arm64 := arm64
+NDK_GO_ARCH_MAP_mips := mipsx
+NDK_GO_ARCH_MAP_mips64 := mips64x
+
+comma := ,
+CLANG_FLAGS := --target=$(TARGET) --sysroot=$(SYSROOT)
+export CGO_CFLAGS := $(CLANG_FLAGS) $(subst -mthumb,-marm,$(CFLAGS))
+export CGO_LDFLAGS := $(CLANG_FLAGS) $(patsubst -Wl$(comma)--build-id=%,-Wl$(comma)--build-id=none,$(LDFLAGS)) -Wl,-soname=libwg-go.so
+export GOARCH := $(NDK_GO_ARCH_MAP_$(ANDROID_ARCH_NAME))
+export GOOS := android
+export CGO_ENABLED := 1
+
+GO_VERSION := 1.24.3
+GO_PLATFORM := $(shell uname -s | tr '[:upper:]' '[:lower:]')-$(NDK_GO_ARCH_MAP_$(shell uname -m))
+GO_TARBALL := go$(GO_VERSION).$(GO_PLATFORM).tar.gz
+GO_HASH_darwin-amd64 := 13e6fe3fcf65689d77d40e633de1e31c6febbdbcb846eb05fc2434ed2213e92b
+GO_HASH_darwin-arm64 := 64a3fa22142f627e78fac3018ce3d4aeace68b743eff0afda8aae0411df5e4fb
+GO_HASH_linux-amd64 := 3333f6ea53afa971e9078895eaa4ac7204a8c6b5c68c10e6bc9a33e8e391bdd8
+
+default: $(DESTDIR)/libwg-go.so
+
+$(GRADLE_USER_HOME)/caches/golang/$(GO_TARBALL):
+ mkdir -p "$(dir $@)"
+ flock "$@.lock" -c ' \
+ [ -f "$@" ] && exit 0; \
+ curl -o "$@.tmp" "https://dl.google.com/go/$(GO_TARBALL)" && \
+ echo "$(GO_HASH_$(GO_PLATFORM)) $@.tmp" | sha256sum -c && \
+ mv "$@.tmp" "$@"'
+
+$(BUILDDIR)/go-$(GO_VERSION)/.prepared: $(GRADLE_USER_HOME)/caches/golang/$(GO_TARBALL)
+ mkdir -p "$(dir $@)"
+ flock "$@.lock" -c ' \
+ [ -f "$@" ] && exit 0; \
+ tar -C "$(dir $@)" --strip-components=1 -xzf "$^" && \
+ patch -p1 -f -N -r- -d "$(dir $@)" < goruntime-boottime-over-monotonic.diff && \
+ touch "$@"'
+
+$(DESTDIR)/libwg-go.so: export PATH := $(BUILDDIR)/go-$(GO_VERSION)/bin/:$(PATH)
+$(DESTDIR)/libwg-go.so: $(BUILDDIR)/go-$(GO_VERSION)/.prepared go.mod
+ go build -tags linux -ldflags="-X golang.zx2c4.com/wireguard/ipc.socketDirectory=/data/data/$(ANDROID_PACKAGE_NAME)/cache/wireguard -buildid=" -v -trimpath -buildvcs=false -o "$@" -buildmode c-shared
+
+.DELETE_ON_ERROR:
diff --git a/tunnel/tools/libwg-go/api-android.go b/tunnel/tools/libwg-go/api-android.go
new file mode 100644
index 0000000..d47c5d7
--- /dev/null
+++ b/tunnel/tools/libwg-go/api-android.go
@@ -0,0 +1,227 @@
+/* SPDX-License-Identifier: Apache-2.0
+ *
+ * Copyright © 2017-2022 Jason A. Donenfeld . All Rights Reserved.
+ */
+
+package main
+
+// #cgo LDFLAGS: -llog
+// #include
+import "C"
+
+import (
+ "fmt"
+ "math"
+ "net"
+ "os"
+ "os/signal"
+ "runtime"
+ "runtime/debug"
+ "strings"
+ "unsafe"
+
+ "golang.org/x/sys/unix"
+ "golang.zx2c4.com/wireguard/conn"
+ "golang.zx2c4.com/wireguard/device"
+ "golang.zx2c4.com/wireguard/ipc"
+ "golang.zx2c4.com/wireguard/tun"
+)
+
+type AndroidLogger struct {
+ level C.int
+ tag *C.char
+}
+
+func cstring(s string) *C.char {
+ b, err := unix.BytePtrFromString(s)
+ if err != nil {
+ b := [1]C.char{}
+ return &b[0]
+ }
+ return (*C.char)(unsafe.Pointer(b))
+}
+
+func (l AndroidLogger) Printf(format string, args ...interface{}) {
+ C.__android_log_write(l.level, l.tag, cstring(fmt.Sprintf(format, args...)))
+}
+
+type TunnelHandle struct {
+ device *device.Device
+ uapi net.Listener
+}
+
+var tunnelHandles map[int32]TunnelHandle
+
+func init() {
+ tunnelHandles = make(map[int32]TunnelHandle)
+ signals := make(chan os.Signal)
+ signal.Notify(signals, unix.SIGUSR2)
+ go func() {
+ buf := make([]byte, os.Getpagesize())
+ for {
+ select {
+ case <-signals:
+ n := runtime.Stack(buf, true)
+ if n == len(buf) {
+ n--
+ }
+ buf[n] = 0
+ C.__android_log_write(C.ANDROID_LOG_ERROR, cstring("WireGuard/GoBackend/Stacktrace"), (*C.char)(unsafe.Pointer(&buf[0])))
+ }
+ }
+ }()
+}
+
+//export wgTurnOn
+func wgTurnOn(interfaceName string, tunFd int32, settings string) int32 {
+ tag := cstring("WireGuard/GoBackend/" + interfaceName)
+ logger := &device.Logger{
+ Verbosef: AndroidLogger{level: C.ANDROID_LOG_DEBUG, tag: tag}.Printf,
+ Errorf: AndroidLogger{level: C.ANDROID_LOG_ERROR, tag: tag}.Printf,
+ }
+
+ tun, name, err := tun.CreateUnmonitoredTUNFromFD(int(tunFd))
+ if err != nil {
+ unix.Close(int(tunFd))
+ logger.Errorf("CreateUnmonitoredTUNFromFD: %v", err)
+ return -1
+ }
+
+ logger.Verbosef("Attaching to interface %v", name)
+ device := device.NewDevice(tun, conn.NewStdNetBind(), logger)
+
+ err = device.IpcSet(settings)
+ if err != nil {
+ unix.Close(int(tunFd))
+ logger.Errorf("IpcSet: %v", err)
+ return -1
+ }
+ device.DisableSomeRoamingForBrokenMobileSemantics()
+
+ var uapi net.Listener
+
+ uapiFile, err := ipc.UAPIOpen(name)
+ if err != nil {
+ logger.Errorf("UAPIOpen: %v", err)
+ } else {
+ uapi, err = ipc.UAPIListen(name, uapiFile)
+ if err != nil {
+ uapiFile.Close()
+ logger.Errorf("UAPIListen: %v", err)
+ } else {
+ go func() {
+ for {
+ conn, err := uapi.Accept()
+ if err != nil {
+ return
+ }
+ go device.IpcHandle(conn)
+ }
+ }()
+ }
+ }
+
+ err = device.Up()
+ if err != nil {
+ logger.Errorf("Unable to bring up device: %v", err)
+ uapiFile.Close()
+ device.Close()
+ return -1
+ }
+ logger.Verbosef("Device started")
+
+ var i int32
+ for i = 0; i < math.MaxInt32; i++ {
+ if _, exists := tunnelHandles[i]; !exists {
+ break
+ }
+ }
+ if i == math.MaxInt32 {
+ logger.Errorf("Unable to find empty handle")
+ uapiFile.Close()
+ device.Close()
+ return -1
+ }
+ tunnelHandles[i] = TunnelHandle{device: device, uapi: uapi}
+ return i
+}
+
+//export wgTurnOff
+func wgTurnOff(tunnelHandle int32) {
+ handle, ok := tunnelHandles[tunnelHandle]
+ if !ok {
+ return
+ }
+ delete(tunnelHandles, tunnelHandle)
+ if handle.uapi != nil {
+ handle.uapi.Close()
+ }
+ handle.device.Close()
+}
+
+//export wgGetSocketV4
+func wgGetSocketV4(tunnelHandle int32) int32 {
+ handle, ok := tunnelHandles[tunnelHandle]
+ if !ok {
+ return -1
+ }
+ bind, _ := handle.device.Bind().(conn.PeekLookAtSocketFd)
+ if bind == nil {
+ return -1
+ }
+ fd, err := bind.PeekLookAtSocketFd4()
+ if err != nil {
+ return -1
+ }
+ return int32(fd)
+}
+
+//export wgGetSocketV6
+func wgGetSocketV6(tunnelHandle int32) int32 {
+ handle, ok := tunnelHandles[tunnelHandle]
+ if !ok {
+ return -1
+ }
+ bind, _ := handle.device.Bind().(conn.PeekLookAtSocketFd)
+ if bind == nil {
+ return -1
+ }
+ fd, err := bind.PeekLookAtSocketFd6()
+ if err != nil {
+ return -1
+ }
+ return int32(fd)
+}
+
+//export wgGetConfig
+func wgGetConfig(tunnelHandle int32) *C.char {
+ handle, ok := tunnelHandles[tunnelHandle]
+ if !ok {
+ return nil
+ }
+ settings, err := handle.device.IpcGet()
+ if err != nil {
+ return nil
+ }
+ return C.CString(settings)
+}
+
+//export wgVersion
+func wgVersion() *C.char {
+ info, ok := debug.ReadBuildInfo()
+ if !ok {
+ return C.CString("unknown")
+ }
+ for _, dep := range info.Deps {
+ if dep.Path == "golang.zx2c4.com/wireguard" {
+ parts := strings.Split(dep.Version, "-")
+ if len(parts) == 3 && len(parts[2]) == 12 {
+ return C.CString(parts[2][:7])
+ }
+ return C.CString(dep.Version)
+ }
+ }
+ return C.CString("unknown")
+}
+
+func main() {}
diff --git a/tunnel/tools/libwg-go/go.mod b/tunnel/tools/libwg-go/go.mod
new file mode 100644
index 0000000..f6de8e1
--- /dev/null
+++ b/tunnel/tools/libwg-go/go.mod
@@ -0,0 +1,14 @@
+module golang.zx2c4.com/wireguard/android
+
+go 1.23.1
+
+require (
+ golang.org/x/sys v0.33.0
+ golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
+)
+
+require (
+ golang.org/x/crypto v0.38.0 // indirect
+ golang.org/x/net v0.40.0 // indirect
+ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
+)
diff --git a/tunnel/tools/libwg-go/go.sum b/tunnel/tools/libwg-go/go.sum
new file mode 100644
index 0000000..416d266
--- /dev/null
+++ b/tunnel/tools/libwg-go/go.sum
@@ -0,0 +1,16 @@
+github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
+github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
+golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
+golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
+golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
+golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
+golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
+golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
+golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
+golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
+golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
+gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
+gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
diff --git a/tunnel/tools/libwg-go/goruntime-boottime-over-monotonic.diff b/tunnel/tools/libwg-go/goruntime-boottime-over-monotonic.diff
new file mode 100644
index 0000000..5d78242
--- /dev/null
+++ b/tunnel/tools/libwg-go/goruntime-boottime-over-monotonic.diff
@@ -0,0 +1,171 @@
+From 61f3ae8298d1c503cbc31539e0f3a73446c7db9d Mon Sep 17 00:00:00 2001
+From: "Jason A. Donenfeld"
+Date: Tue, 21 Mar 2023 15:33:56 +0100
+Subject: [PATCH] [release-branch.go1.20] runtime: use CLOCK_BOOTTIME in
+ nanotime on Linux
+
+This makes timers account for having expired while a computer was
+asleep, which is quite common on mobile devices. Note that BOOTTIME is
+identical to MONOTONIC, except that it takes into account time spent
+in suspend. In Linux 4.17, the kernel will actually make MONOTONIC act
+like BOOTTIME anyway, so this switch will additionally unify the
+timer behavior across kernels.
+
+BOOTTIME was introduced into Linux 2.6.39-rc1 with 70a08cca1227d in
+2011.
+
+Fixes #24595
+
+Change-Id: I7b2a6ca0c5bc5fce57ec0eeafe7b68270b429321
+---
+ src/runtime/sys_linux_386.s | 4 ++--
+ src/runtime/sys_linux_amd64.s | 2 +-
+ src/runtime/sys_linux_arm.s | 4 ++--
+ src/runtime/sys_linux_arm64.s | 4 ++--
+ src/runtime/sys_linux_mips64x.s | 4 ++--
+ src/runtime/sys_linux_mipsx.s | 2 +-
+ src/runtime/sys_linux_ppc64x.s | 2 +-
+ src/runtime/sys_linux_s390x.s | 2 +-
+ 8 files changed, 12 insertions(+), 12 deletions(-)
+
+diff --git a/src/runtime/sys_linux_386.s b/src/runtime/sys_linux_386.s
+index 12a294153d..17e3524b40 100644
+--- a/src/runtime/sys_linux_386.s
++++ b/src/runtime/sys_linux_386.s
+@@ -352,13 +352,13 @@ noswitch:
+
+ LEAL 8(SP), BX // &ts (struct timespec)
+ MOVL BX, 4(SP)
+- MOVL $1, 0(SP) // CLOCK_MONOTONIC
++ MOVL $7, 0(SP) // CLOCK_BOOTTIME
+ CALL AX
+ JMP finish
+
+ fallback:
+ MOVL $SYS_clock_gettime, AX
+- MOVL $1, BX // CLOCK_MONOTONIC
++ MOVL $7, BX // CLOCK_BOOTTIME
+ LEAL 8(SP), CX
+ INVOKE_SYSCALL
+
+diff --git a/src/runtime/sys_linux_amd64.s b/src/runtime/sys_linux_amd64.s
+index c7a89ba536..01f0a6a26e 100644
+--- a/src/runtime/sys_linux_amd64.s
++++ b/src/runtime/sys_linux_amd64.s
+@@ -255,7 +255,7 @@ noswitch:
+ SUBQ $16, SP // Space for results
+ ANDQ $~15, SP // Align for C code
+
+- MOVL $1, DI // CLOCK_MONOTONIC
++ MOVL $7, DI // CLOCK_BOOTTIME
+ LEAQ 0(SP), SI
+ MOVQ runtime·vdsoClockgettimeSym(SB), AX
+ CMPQ AX, $0
+diff --git a/src/runtime/sys_linux_arm.s b/src/runtime/sys_linux_arm.s
+index 7b8c4f0e04..9798a1334e 100644
+--- a/src/runtime/sys_linux_arm.s
++++ b/src/runtime/sys_linux_arm.s
+@@ -11,7 +11,7 @@
+ #include "textflag.h"
+
+ #define CLOCK_REALTIME 0
+-#define CLOCK_MONOTONIC 1
++#define CLOCK_BOOTTIME 7
+
+ // for EABI, as we don't support OABI
+ #define SYS_BASE 0x0
+@@ -374,7 +374,7 @@ finish:
+
+ // func nanotime1() int64
+ TEXT runtime·nanotime1(SB),NOSPLIT,$12-8
+- MOVW $CLOCK_MONOTONIC, R0
++ MOVW $CLOCK_BOOTTIME, R0
+ MOVW $spec-12(SP), R1 // timespec
+
+ MOVW runtime·vdsoClockgettimeSym(SB), R4
+diff --git a/src/runtime/sys_linux_arm64.s b/src/runtime/sys_linux_arm64.s
+index 38ff6ac330..6b819c5441 100644
+--- a/src/runtime/sys_linux_arm64.s
++++ b/src/runtime/sys_linux_arm64.s
+@@ -14,7 +14,7 @@
+ #define AT_FDCWD -100
+
+ #define CLOCK_REALTIME 0
+-#define CLOCK_MONOTONIC 1
++#define CLOCK_BOOTTIME 7
+
+ #define SYS_exit 93
+ #define SYS_read 63
+@@ -338,7 +338,7 @@ noswitch:
+ BIC $15, R1
+ MOVD R1, RSP
+
+- MOVW $CLOCK_MONOTONIC, R0
++ MOVW $CLOCK_BOOTTIME, R0
+ MOVD runtime·vdsoClockgettimeSym(SB), R2
+ CBZ R2, fallback
+
+diff --git a/src/runtime/sys_linux_mips64x.s b/src/runtime/sys_linux_mips64x.s
+index 47f2da524d..a8b387f193 100644
+--- a/src/runtime/sys_linux_mips64x.s
++++ b/src/runtime/sys_linux_mips64x.s
+@@ -326,7 +326,7 @@ noswitch:
+ AND $~15, R1 // Align for C code
+ MOVV R1, R29
+
+- MOVW $1, R4 // CLOCK_MONOTONIC
++ MOVW $7, R4 // CLOCK_BOOTTIME
+ MOVV $0(R29), R5
+
+ MOVV runtime·vdsoClockgettimeSym(SB), R25
+@@ -336,7 +336,7 @@ noswitch:
+ // see walltime for detail
+ BEQ R2, R0, finish
+ MOVV R0, runtime·vdsoClockgettimeSym(SB)
+- MOVW $1, R4 // CLOCK_MONOTONIC
++ MOVW $7, R4 // CLOCK_BOOTTIME
+ MOVV $0(R29), R5
+ JMP fallback
+
+diff --git a/src/runtime/sys_linux_mipsx.s b/src/runtime/sys_linux_mipsx.s
+index 5e6b6c1504..7f5fd2a80e 100644
+--- a/src/runtime/sys_linux_mipsx.s
++++ b/src/runtime/sys_linux_mipsx.s
+@@ -243,7 +243,7 @@ TEXT runtime·walltime(SB),NOSPLIT,$8-12
+ RET
+
+ TEXT runtime·nanotime1(SB),NOSPLIT,$8-8
+- MOVW $1, R4 // CLOCK_MONOTONIC
++ MOVW $7, R4 // CLOCK_BOOTTIME
+ MOVW $4(R29), R5
+ MOVW $SYS_clock_gettime, R2
+ SYSCALL
+diff --git a/src/runtime/sys_linux_ppc64x.s b/src/runtime/sys_linux_ppc64x.s
+index d0427a4807..05ee9fede9 100644
+--- a/src/runtime/sys_linux_ppc64x.s
++++ b/src/runtime/sys_linux_ppc64x.s
+@@ -298,7 +298,7 @@ fallback:
+ JMP return
+
+ TEXT runtime·nanotime1(SB),NOSPLIT,$16-8
+- MOVD $1, R3 // CLOCK_MONOTONIC
++ MOVD $7, R3 // CLOCK_BOOTTIME
+
+ MOVD R1, R15 // R15 is unchanged by C code
+ MOVD g_m(g), R21 // R21 = m
+diff --git a/src/runtime/sys_linux_s390x.s b/src/runtime/sys_linux_s390x.s
+index 1448670b91..7d2ee3231c 100644
+--- a/src/runtime/sys_linux_s390x.s
++++ b/src/runtime/sys_linux_s390x.s
+@@ -296,7 +296,7 @@ fallback:
+ RET
+
+ TEXT runtime·nanotime1(SB),NOSPLIT,$32-8
+- MOVW $1, R2 // CLOCK_MONOTONIC
++ MOVW $7, R2 // CLOCK_BOOTTIME
+
+ MOVD R15, R7 // Backup stack pointer
+
+--
+2.17.1
+
diff --git a/tunnel/tools/libwg-go/jni.c b/tunnel/tools/libwg-go/jni.c
new file mode 100644
index 0000000..7ad94d3
--- /dev/null
+++ b/tunnel/tools/libwg-go/jni.c
@@ -0,0 +1,71 @@
+/* SPDX-License-Identifier: Apache-2.0
+ *
+ * Copyright © 2017-2021 Jason A. Donenfeld . All Rights Reserved.
+ */
+
+#include
+#include
+#include
+
+struct go_string { const char *str; long n; };
+extern int wgTurnOn(struct go_string ifname, int tun_fd, struct go_string settings);
+extern void wgTurnOff(int handle);
+extern int wgGetSocketV4(int handle);
+extern int wgGetSocketV6(int handle);
+extern char *wgGetConfig(int handle);
+extern char *wgVersion();
+
+JNIEXPORT jint JNICALL Java_com_wireguard_android_backend_GoBackend_wgTurnOn(JNIEnv *env, jclass c, jstring ifname, jint tun_fd, jstring settings)
+{
+ const char *ifname_str = (*env)->GetStringUTFChars(env, ifname, 0);
+ size_t ifname_len = (*env)->GetStringUTFLength(env, ifname);
+ const char *settings_str = (*env)->GetStringUTFChars(env, settings, 0);
+ size_t settings_len = (*env)->GetStringUTFLength(env, settings);
+ int ret = wgTurnOn((struct go_string){
+ .str = ifname_str,
+ .n = ifname_len
+ }, tun_fd, (struct go_string){
+ .str = settings_str,
+ .n = settings_len
+ });
+ (*env)->ReleaseStringUTFChars(env, ifname, ifname_str);
+ (*env)->ReleaseStringUTFChars(env, settings, settings_str);
+ return ret;
+}
+
+JNIEXPORT void JNICALL Java_com_wireguard_android_backend_GoBackend_wgTurnOff(JNIEnv *env, jclass c, jint handle)
+{
+ wgTurnOff(handle);
+}
+
+JNIEXPORT jint JNICALL Java_com_wireguard_android_backend_GoBackend_wgGetSocketV4(JNIEnv *env, jclass c, jint handle)
+{
+ return wgGetSocketV4(handle);
+}
+
+JNIEXPORT jint JNICALL Java_com_wireguard_android_backend_GoBackend_wgGetSocketV6(JNIEnv *env, jclass c, jint handle)
+{
+ return wgGetSocketV6(handle);
+}
+
+JNIEXPORT jstring JNICALL Java_com_wireguard_android_backend_GoBackend_wgGetConfig(JNIEnv *env, jclass c, jint handle)
+{
+ jstring ret;
+ char *config = wgGetConfig(handle);
+ if (!config)
+ return NULL;
+ ret = (*env)->NewStringUTF(env, config);
+ free(config);
+ return ret;
+}
+
+JNIEXPORT jstring JNICALL Java_com_wireguard_android_backend_GoBackend_wgVersion(JNIEnv *env, jclass c)
+{
+ jstring ret;
+ char *version = wgVersion();
+ if (!version)
+ return NULL;
+ ret = (*env)->NewStringUTF(env, version);
+ free(version);
+ return ret;
+}
diff --git a/tunnel/tools/ndk-compat/compat.c b/tunnel/tools/ndk-compat/compat.c
new file mode 100644
index 0000000..3c293e7
--- /dev/null
+++ b/tunnel/tools/ndk-compat/compat.c
@@ -0,0 +1,25 @@
+/* SPDX-License-Identifier: BSD
+ *
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ *
+ */
+
+#define FILE_IS_EMPTY
+
+#if defined(__ANDROID_MIN_SDK_VERSION__) && __ANDROID_MIN_SDK_VERSION__ < 24
+#undef FILE_IS_EMPTY
+#include
+
+char *strchrnul(const char *s, int c)
+{
+ char *x = strchr(s, c);
+ if (!x)
+ return (char *)s + strlen(s);
+ return x;
+}
+#endif
+
+#ifdef FILE_IS_EMPTY
+#undef FILE_IS_EMPTY
+static char ____x __attribute__((unused));
+#endif
diff --git a/tunnel/tools/ndk-compat/compat.h b/tunnel/tools/ndk-compat/compat.h
new file mode 100644
index 0000000..9931c70
--- /dev/null
+++ b/tunnel/tools/ndk-compat/compat.h
@@ -0,0 +1,10 @@
+/* SPDX-License-Identifier: BSD
+ *
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ *
+ */
+
+#if defined(__ANDROID_MIN_SDK_VERSION__) && __ANDROID_MIN_SDK_VERSION__ < 24
+char *strchrnul(const char *s, int c);
+#endif
+
diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts
new file mode 100644
index 0000000..39bc753
--- /dev/null
+++ b/ui/build.gradle.kts
@@ -0,0 +1,93 @@
+@file:Suppress("UnstableApiUsage")
+
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+val pkg: String = providers.gradleProperty("wireguardPackageName").get()
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.kapt)
+}
+
+android {
+ compileSdk = 36
+ buildFeatures {
+ buildConfig = true
+ dataBinding = true
+ viewBinding = true
+ }
+ namespace = pkg
+ defaultConfig {
+ applicationId = pkg
+ minSdk = 24
+ targetSdk = 36
+ versionCode = providers.gradleProperty("wireguardVersionCode").get().toInt()
+ versionName = providers.gradleProperty("wireguardVersionName").get()
+ buildConfigField("int", "MIN_SDK_VERSION", minSdk.toString())
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ isCoreLibraryDesugaringEnabled = true
+ }
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles("proguard-android-optimize.txt")
+ packaging {
+ resources {
+ excludes += "DebugProbesKt.bin"
+ excludes += "kotlin-tooling-metadata.json"
+ excludes += "META-INF/*.version"
+ }
+ }
+ }
+ debug {
+ applicationIdSuffix = ".debug"
+ versionNameSuffix = "-debug"
+ }
+ create("googleplay") {
+ initWith(getByName("release"))
+ matchingFallbacks += "release"
+ }
+ }
+ androidResources {
+ generateLocaleConfig = true
+ }
+ lint {
+ disable += "LongLogTag"
+ warning += "MissingTranslation"
+ warning += "ImpliedQuantity"
+ }
+}
+
+dependencies {
+ implementation(project(":tunnel"))
+ implementation(libs.androidx.activity.ktx)
+ implementation(libs.androidx.annotation)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.constraintlayout)
+ implementation(libs.androidx.coordinatorlayout)
+ implementation(libs.androidx.biometric)
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.fragment.ktx)
+ implementation(libs.androidx.preference.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.google.material)
+ implementation(libs.zxing.android.embedded)
+ implementation(libs.kotlinx.coroutines.android)
+ coreLibraryDesugaring(libs.desugarJdkLibs)
+}
+
+tasks.withType().configureEach {
+ options.compilerArgs.add("-Xlint:unchecked")
+ options.isDeprecation = true
+}
+
+tasks.withType().configureEach {
+ compilerOptions.jvmTarget = JvmTarget.JVM_17
+}
diff --git a/ui/proguard-android-optimize.txt b/ui/proguard-android-optimize.txt
new file mode 100644
index 0000000..7bbc2b8
--- /dev/null
+++ b/ui/proguard-android-optimize.txt
@@ -0,0 +1,35 @@
+-allowaccessmodification
+-dontusemixedcaseclassnames
+-dontobfuscate
+-verbose
+
+-keepattributes *Annotation*
+
+-keepclasseswithmembernames class * {
+ native ;
+}
+
+-keepclassmembers enum * {
+ public static **[] values();
+ public static ** valueOf(java.lang.String);
+}
+
+-keepclassmembers class * implements android.os.Parcelable {
+ public static final ** CREATOR;
+}
+
+-keep class androidx.annotation.Keep
+
+-keep @androidx.annotation.Keep class * {*;}
+
+-keepclasseswithmembers class * {
+ @androidx.annotation.Keep ;
+}
+
+-keepclasseswithmembers class * {
+ @androidx.annotation.Keep ;
+}
+
+-keepclasseswithmembers class * {
+ @androidx.annotation.Keep (...);
+}
diff --git a/ui/sampledata/interface_names.json b/ui/sampledata/interface_names.json
new file mode 100644
index 0000000..1c41cb2
--- /dev/null
+++ b/ui/sampledata/interface_names.json
@@ -0,0 +1,34 @@
+{
+ "comment": "Interface names",
+ "names": [
+ {
+ "names": [
+ { "name": "wg0" },
+ { "name": "wg1" },
+ { "name": "wg2" },
+ { "name": "wg3" },
+ { "name": "wg4" },
+ { "name": "wg5" },
+ { "name": "wg6" },
+ { "name": "wg7" },
+ { "name": "wg8" },
+ { "name": "wg9" },
+ { "name": "wg10" },
+ { "name": "wg11" }
+ ],
+ "checked": [
+ { "checked": true },
+ { "checked": false },
+ { "checked": true },
+ { "checked": false },
+ { "checked": true },
+ { "checked": false },
+ { "checked": true },
+ { "checked": false },
+ { "checked": true },
+ { "checked": false },
+ { "checked": true }
+ ]
+ }
+ ]
+}
diff --git a/ui/src/debug/res/values/strings.xml b/ui/src/debug/res/values/strings.xml
new file mode 100644
index 0000000..947b738
--- /dev/null
+++ b/ui/src/debug/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ WireGuard β
+
diff --git a/ui/src/googleplay/AndroidManifest.xml b/ui/src/googleplay/AndroidManifest.xml
new file mode 100644
index 0000000..28372d5
--- /dev/null
+++ b/ui/src/googleplay/AndroidManifest.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..86c989b
--- /dev/null
+++ b/ui/src/main/AndroidManifest.xml
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/main/java/com/wireguard/android/Application.kt b/ui/src/main/java/com/wireguard/android/Application.kt
new file mode 100644
index 0000000..74eaccf
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/Application.kt
@@ -0,0 +1,157 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android
+
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.StrictMode
+import android.os.StrictMode.ThreadPolicy
+import android.os.StrictMode.VmPolicy
+import android.util.Log
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStoreFile
+import com.google.android.material.color.DynamicColors
+import com.wireguard.android.backend.Backend
+import com.wireguard.android.backend.GoBackend
+import com.wireguard.android.backend.WgQuickBackend
+import com.wireguard.android.configStore.FileConfigStore
+import com.wireguard.android.model.TunnelManager
+import com.wireguard.android.updater.Updater
+import com.wireguard.android.util.RootShell
+import com.wireguard.android.util.ToolsInstaller
+import com.wireguard.android.util.UserKnobs
+import com.wireguard.android.util.applicationScope
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import java.lang.ref.WeakReference
+import java.util.Locale
+
+class Application : android.app.Application() {
+ private val futureBackend = CompletableDeferred()
+ private val coroutineScope = CoroutineScope(Job() + Dispatchers.Main.immediate)
+ private var backend: Backend? = null
+ private lateinit var rootShell: RootShell
+ private lateinit var preferencesDataStore: DataStore
+ private lateinit var toolsInstaller: ToolsInstaller
+ private lateinit var tunnelManager: TunnelManager
+
+ override fun attachBaseContext(context: Context) {
+ super.attachBaseContext(context)
+ if (BuildConfig.MIN_SDK_VERSION > Build.VERSION.SDK_INT) {
+ val intent = Intent(Intent.ACTION_MAIN)
+ intent.addCategory(Intent.CATEGORY_HOME)
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ startActivity(intent)
+ System.exit(0)
+ }
+ }
+
+ private suspend fun determineBackend(): Backend {
+ var backend: Backend? = null
+ if (UserKnobs.enableKernelModule.first() && WgQuickBackend.hasKernelSupport()) {
+ try {
+ rootShell.start()
+ val wgQuickBackend = WgQuickBackend(applicationContext, rootShell, toolsInstaller)
+ wgQuickBackend.setMultipleTunnels(UserKnobs.multipleTunnels.first())
+ backend = wgQuickBackend
+ UserKnobs.multipleTunnels.onEach {
+ wgQuickBackend.setMultipleTunnels(it)
+ }.launchIn(coroutineScope)
+ } catch (ignored: Exception) {
+ }
+ }
+ if (backend == null) {
+ backend = GoBackend(applicationContext)
+ GoBackend.setAlwaysOnCallback { get().applicationScope.launch { get().tunnelManager.restoreState(true) } }
+ }
+ return backend
+ }
+
+ override fun onCreate() {
+ Log.i(TAG, USER_AGENT)
+ super.onCreate()
+ DynamicColors.applyToActivitiesIfAvailable(this)
+ rootShell = RootShell(applicationContext)
+ toolsInstaller = ToolsInstaller(applicationContext, rootShell)
+ preferencesDataStore = PreferenceDataStoreFactory.create { applicationContext.preferencesDataStoreFile("settings") }
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ runBlocking {
+ AppCompatDelegate.setDefaultNightMode(if (UserKnobs.darkTheme.first()) AppCompatDelegate.MODE_NIGHT_YES else AppCompatDelegate.MODE_NIGHT_NO)
+ }
+ UserKnobs.darkTheme.onEach {
+ val newMode = if (it) {
+ AppCompatDelegate.MODE_NIGHT_YES
+ } else {
+ AppCompatDelegate.MODE_NIGHT_NO
+ }
+ if (AppCompatDelegate.getDefaultNightMode() != newMode) {
+ AppCompatDelegate.setDefaultNightMode(newMode)
+ }
+ }.launchIn(coroutineScope)
+ } else {
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
+ }
+ tunnelManager = TunnelManager(FileConfigStore(applicationContext))
+ tunnelManager.onCreate()
+ coroutineScope.launch(Dispatchers.IO) {
+ try {
+ backend = determineBackend()
+ futureBackend.complete(backend!!)
+ } catch (e: Throwable) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ }
+ }
+ Updater.monitorForUpdates()
+
+ if (BuildConfig.DEBUG) {
+ StrictMode.setVmPolicy(VmPolicy.Builder().detectAll().penaltyLog().build())
+ StrictMode.setThreadPolicy(ThreadPolicy.Builder().detectAll().penaltyLog().build())
+ }
+ }
+
+ override fun onTerminate() {
+ coroutineScope.cancel()
+ super.onTerminate()
+ }
+
+ companion object {
+ val USER_AGENT = String.format(Locale.ENGLISH, "WireGuard/%s (Android %d; %s; %s; %s %s; %s)", BuildConfig.VERSION_NAME, Build.VERSION.SDK_INT, if (Build.SUPPORTED_ABIS.isNotEmpty()) Build.SUPPORTED_ABIS[0] else "unknown ABI", Build.BOARD, Build.MANUFACTURER, Build.MODEL, Build.FINGERPRINT)
+ private const val TAG = "WireGuard/Application"
+ private lateinit var weakSelf: WeakReference
+
+ fun get(): Application {
+ return weakSelf.get()!!
+ }
+
+ suspend fun getBackend() = get().futureBackend.await()
+
+ fun getRootShell() = get().rootShell
+
+ fun getPreferencesDataStore() = get().preferencesDataStore
+
+ fun getToolsInstaller() = get().toolsInstaller
+
+ fun getTunnelManager() = get().tunnelManager
+
+ fun getCoroutineScope() = get().coroutineScope
+ }
+
+ init {
+ weakSelf = WeakReference(this)
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/BootShutdownReceiver.kt b/ui/src/main/java/com/wireguard/android/BootShutdownReceiver.kt
new file mode 100644
index 0000000..59769df
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/BootShutdownReceiver.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import com.wireguard.android.backend.WgQuickBackend
+import com.wireguard.android.util.applicationScope
+import kotlinx.coroutines.launch
+
+class BootShutdownReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val action = intent.action ?: return
+ applicationScope.launch {
+ if (Application.getBackend() !is WgQuickBackend) return@launch
+ val tunnelManager = Application.getTunnelManager()
+ if (Intent.ACTION_BOOT_COMPLETED == action) {
+ Log.i(TAG, "Broadcast receiver restoring state (boot)")
+ tunnelManager.restoreState(false)
+ } else if (Intent.ACTION_SHUTDOWN == action) {
+ Log.i(TAG, "Broadcast receiver saving state (shutdown)")
+ tunnelManager.saveState()
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "WireGuard/BootShutdownReceiver"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/QuickTileService.kt b/ui/src/main/java/com/wireguard/android/QuickTileService.kt
new file mode 100644
index 0000000..a849c48
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/QuickTileService.kt
@@ -0,0 +1,203 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android
+
+import android.app.PendingIntent
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.drawable.Icon
+import android.net.Uri
+import android.os.Build
+import android.os.IBinder
+import android.provider.Settings
+import android.service.quicksettings.Tile
+import android.service.quicksettings.TileService
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.databinding.Observable
+import androidx.databinding.Observable.OnPropertyChangedCallback
+import com.wireguard.android.activity.MainActivity
+import com.wireguard.android.activity.TunnelToggleActivity
+import com.wireguard.android.backend.Tunnel
+import com.wireguard.android.model.ObservableTunnel
+import com.wireguard.android.util.applicationScope
+import com.wireguard.android.widget.SlashDrawable
+import kotlinx.coroutines.launch
+
+/**
+ * Service that maintains the application's custom Quick Settings tile. This service is bound by the
+ * system framework as necessary to update the appearance of the tile in the system UI, and to
+ * forward click events to the application.
+ */
+class QuickTileService : TileService() {
+ private val onStateChangedCallback = OnStateChangedCallback()
+ private val onTunnelChangedCallback = OnTunnelChangedCallback()
+ private var iconOff: Icon? = null
+ private var iconOn: Icon? = null
+ private var tunnel: ObservableTunnel? = null
+
+ /* This works around an annoying unsolved frameworks bug some people are hitting. */
+ override fun onBind(intent: Intent): IBinder? {
+ var ret: IBinder? = null
+ try {
+ ret = super.onBind(intent)
+ } catch (e: Throwable) {
+ Log.d(TAG, "Failed to bind to TileService", e)
+ }
+ return ret
+ }
+
+ override fun onClick() {
+ applicationScope.launch {
+ if (tunnel == null) {
+ Application.getTunnelManager().getTunnels()
+ updateTile()
+ }
+ when (val tunnel = tunnel) {
+ null -> {
+ Log.d(TAG, "No tunnel set, so launching main activity")
+ val intent = Intent(this@QuickTileService, MainActivity::class.java)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ startActivityAndCollapse(PendingIntent.getActivity(this@QuickTileService, 0, intent, PendingIntent.FLAG_IMMUTABLE))
+ } else {
+ @Suppress("DEPRECATION")
+ startActivityAndCollapse(intent)
+ }
+ }
+
+ else -> {
+ unlockAndRun {
+ applicationScope.launch {
+ try {
+ tunnel.setStateAsync(Tunnel.State.TOGGLE)
+ updateTile()
+ } catch (e: Throwable) {
+ Log.d(TAG, "Failed to set state, so falling back", e)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && !Settings.canDrawOverlays(this@QuickTileService)) {
+ Log.d(TAG, "Need overlay permissions")
+ val permissionIntent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:$packageName"))
+ permissionIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ startActivityAndCollapse(
+ PendingIntent.getActivity(
+ this@QuickTileService,
+ 0,
+ permissionIntent,
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ )
+ return@launch
+ }
+ val toggleIntent = Intent(this@QuickTileService, TunnelToggleActivity::class.java)
+ toggleIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ startActivity(toggleIntent)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ override fun onCreate() {
+ isAdded = true
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ iconOn = Icon.createWithResource(this, R.drawable.ic_tile)
+ iconOff = iconOn
+ return
+ }
+ val icon = SlashDrawable(resources.getDrawable(R.drawable.ic_tile, Application.get().theme))
+ icon.setAnimationEnabled(false) /* Unfortunately we can't have animations, since Icons are marshaled. */
+ icon.setSlashed(false)
+ var b = Bitmap.createBitmap(icon.intrinsicWidth, icon.intrinsicHeight, Bitmap.Config.ARGB_8888)
+ var c = Canvas(b)
+ icon.setBounds(0, 0, c.width, c.height)
+ icon.draw(c)
+ iconOn = Icon.createWithBitmap(b)
+ icon.setSlashed(true)
+ b = Bitmap.createBitmap(icon.intrinsicWidth, icon.intrinsicHeight, Bitmap.Config.ARGB_8888)
+ c = Canvas(b)
+ icon.setBounds(0, 0, c.width, c.height)
+ icon.draw(c)
+ iconOff = Icon.createWithBitmap(b)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ isAdded = false
+ }
+
+ override fun onStartListening() {
+ Application.getTunnelManager().addOnPropertyChangedCallback(onTunnelChangedCallback)
+ tunnel?.addOnPropertyChangedCallback(onStateChangedCallback)
+ updateTile()
+ }
+
+ override fun onStopListening() {
+ tunnel?.removeOnPropertyChangedCallback(onStateChangedCallback)
+ Application.getTunnelManager().removeOnPropertyChangedCallback(onTunnelChangedCallback)
+ }
+
+ override fun onTileAdded() {
+ isAdded = true
+ }
+
+ override fun onTileRemoved() {
+ isAdded = false
+ }
+
+ private fun updateTile() {
+ // Update the tunnel.
+ val newTunnel = Application.getTunnelManager().lastUsedTunnel
+ if (newTunnel != tunnel) {
+ tunnel?.removeOnPropertyChangedCallback(onStateChangedCallback)
+ tunnel = newTunnel
+ tunnel?.addOnPropertyChangedCallback(onStateChangedCallback)
+ }
+ // Update the tile contents.
+ val tile = qsTile ?: return
+
+ when (val tunnel = tunnel) {
+ null -> {
+ tile.label = getString(R.string.app_name)
+ tile.state = Tile.STATE_INACTIVE
+ tile.icon = iconOff
+ }
+ else -> {
+ tile.label = tunnel.name
+ tile.state = if (tunnel.state == Tunnel.State.UP) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
+ tile.icon = if (tunnel.state == Tunnel.State.UP) iconOn else iconOff
+ }
+ }
+ tile.updateTile()
+ }
+
+ private inner class OnStateChangedCallback : OnPropertyChangedCallback() {
+ override fun onPropertyChanged(sender: Observable, propertyId: Int) {
+ if (sender != tunnel) {
+ sender.removeOnPropertyChangedCallback(this)
+ return
+ }
+ if (propertyId != 0 && propertyId != BR.state)
+ return
+ updateTile()
+ }
+ }
+
+ private inner class OnTunnelChangedCallback : OnPropertyChangedCallback() {
+ override fun onPropertyChanged(sender: Observable, propertyId: Int) {
+ if (propertyId != 0 && propertyId != BR.lastUsedTunnel)
+ return
+ updateTile()
+ }
+ }
+
+ companion object {
+ private const val TAG = "WireGuard/QuickTileService"
+ var isAdded: Boolean = false
+ private set
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/BaseActivity.kt b/ui/src/main/java/com/wireguard/android/activity/BaseActivity.kt
new file mode 100644
index 0000000..5ff1106
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/BaseActivity.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.activity
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.databinding.CallbackRegistry
+import androidx.databinding.CallbackRegistry.NotifierCallback
+import androidx.lifecycle.lifecycleScope
+import com.wireguard.android.Application
+import com.wireguard.android.model.ObservableTunnel
+import kotlinx.coroutines.launch
+
+/**
+ * Base class for activities that need to remember the currently-selected tunnel.
+ */
+abstract class BaseActivity : AppCompatActivity() {
+ private val selectionChangeRegistry = SelectionChangeRegistry()
+ private var created = false
+ var selectedTunnel: ObservableTunnel? = null
+ set(value) {
+ val oldTunnel = field
+ if (oldTunnel == value) return
+ field = value
+ if (created) {
+ if (!onSelectedTunnelChanged(oldTunnel, value)) {
+ field = oldTunnel
+ } else {
+ selectionChangeRegistry.notifyCallbacks(oldTunnel, 0, value)
+ }
+ }
+ }
+
+ fun addOnSelectedTunnelChangedListener(listener: OnSelectedTunnelChangedListener) {
+ selectionChangeRegistry.add(listener)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Restore the saved tunnel if there is one; otherwise grab it from the arguments.
+ val savedTunnelName = when {
+ savedInstanceState != null -> savedInstanceState.getString(KEY_SELECTED_TUNNEL)
+ intent != null -> intent.getStringExtra(KEY_SELECTED_TUNNEL)
+ else -> null
+ }
+ if (savedTunnelName != null) {
+ lifecycleScope.launch {
+ val tunnel = Application.getTunnelManager().getTunnels()[savedTunnelName]
+ if (tunnel == null)
+ created = true
+ selectedTunnel = tunnel
+ created = true
+ }
+ } else {
+ created = true
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ if (selectedTunnel != null) outState.putString(KEY_SELECTED_TUNNEL, selectedTunnel!!.name)
+ super.onSaveInstanceState(outState)
+ }
+
+ protected abstract fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?): Boolean
+
+ fun removeOnSelectedTunnelChangedListener(
+ listener: OnSelectedTunnelChangedListener
+ ) {
+ selectionChangeRegistry.remove(listener)
+ }
+
+ interface OnSelectedTunnelChangedListener {
+ fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?)
+ }
+
+ private class SelectionChangeNotifier : NotifierCallback() {
+ override fun onNotifyCallback(
+ listener: OnSelectedTunnelChangedListener,
+ oldTunnel: ObservableTunnel?,
+ ignored: Int,
+ newTunnel: ObservableTunnel?
+ ) {
+ listener.onSelectedTunnelChanged(oldTunnel, newTunnel)
+ }
+ }
+
+ private class SelectionChangeRegistry :
+ CallbackRegistry(SelectionChangeNotifier())
+
+ companion object {
+ private const val KEY_SELECTED_TUNNEL = "selected_tunnel"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt b/ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt
new file mode 100644
index 0000000..fa16b3c
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt
@@ -0,0 +1,382 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.activity
+
+import android.content.ClipDescription.compareMimeTypes
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.Intent
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.graphics.Typeface.BOLD
+import android.net.Uri
+import android.os.Bundle
+import android.os.ParcelFileDescriptor
+import android.text.Spannable
+import android.text.SpannableString
+import android.text.style.ForegroundColorSpan
+import android.text.style.StyleSpan
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.collection.CircularArray
+import androidx.core.app.ShareCompat
+import androidx.core.content.res.ResourcesCompat
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.snackbar.Snackbar
+import com.google.android.material.textview.MaterialTextView
+import com.wireguard.android.BuildConfig
+import com.wireguard.android.R
+import com.wireguard.android.databinding.LogViewerActivityBinding
+import com.wireguard.android.util.DownloadsFileSaver
+import com.wireguard.android.util.ErrorMessages
+import com.wireguard.android.util.resolveAttribute
+import com.wireguard.crypto.KeyPair
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.BufferedReader
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStreamReader
+import java.nio.charset.StandardCharsets
+import java.text.DateFormat
+import java.text.ParseException
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.concurrent.ConcurrentHashMap
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+class LogViewerActivity : AppCompatActivity() {
+ private lateinit var binding: LogViewerActivityBinding
+ private lateinit var logAdapter: LogEntryAdapter
+ private var logLines = CircularArray()
+ private var rawLogLines = CircularArray()
+ private var recyclerView: RecyclerView? = null
+ private var saveButton: MenuItem? = null
+ private val year by lazy {
+ val yearFormatter: DateFormat = SimpleDateFormat("yyyy", Locale.US)
+ yearFormatter.format(Date())
+ }
+
+ private val defaultColor by lazy { resolveAttribute(com.google.android.material.R.attr.colorOnSurface) }
+
+ private val debugColor by lazy { ResourcesCompat.getColor(resources, R.color.debug_tag_color, theme) }
+
+ private val errorColor by lazy { ResourcesCompat.getColor(resources, R.color.error_tag_color, theme) }
+
+ private val infoColor by lazy { ResourcesCompat.getColor(resources, R.color.info_tag_color, theme) }
+
+ private val warningColor by lazy { ResourcesCompat.getColor(resources, R.color.warning_tag_color, theme) }
+
+ private var lastUri: Uri? = null
+
+ private fun revokeLastUri() {
+ lastUri?.let {
+ LOGS.remove(it.pathSegments.lastOrNull())
+ revokeUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ lastUri = null
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = LogViewerActivityBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ logAdapter = LogEntryAdapter()
+ binding.recyclerView.apply {
+ recyclerView = this
+ layoutManager = LinearLayoutManager(context)
+ adapter = logAdapter
+ addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL))
+ }
+
+ lifecycleScope.launch(Dispatchers.IO) { streamingLog() }
+
+ val revokeLastActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ revokeLastUri()
+ }
+
+ binding.shareFab.setOnClickListener {
+ lifecycleScope.launch {
+ revokeLastUri()
+ val key = KeyPair().privateKey.toHex()
+ LOGS[key] = rawLogBytes()
+ lastUri = Uri.parse("content://${BuildConfig.APPLICATION_ID}.exported-log/$key")
+ val shareIntent = ShareCompat.IntentBuilder(this@LogViewerActivity)
+ .setType("text/plain")
+ .setSubject(getString(R.string.log_export_subject))
+ .setStream(lastUri)
+ .setChooserTitle(R.string.log_export_title)
+ .createChooserIntent()
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ grantUriPermission("android", lastUri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ revokeLastActivityResultLauncher.launch(shareIntent)
+ }
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.log_viewer, menu)
+ saveButton = menu.findItem(R.id.save_log)
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ finish()
+ true
+ }
+
+ R.id.save_log -> {
+ saveButton?.isEnabled = false
+ lifecycleScope.launch { saveLog() }
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ private val downloadsFileSaver = DownloadsFileSaver(this)
+
+ private suspend fun rawLogBytes(): ByteArray {
+ val builder = StringBuilder()
+ withContext(Dispatchers.IO) {
+ for (i in 0 until rawLogLines.size()) {
+ builder.append(rawLogLines[i])
+ builder.append('\n')
+ }
+ }
+ return builder.toString().toByteArray(Charsets.UTF_8)
+ }
+
+ private suspend fun saveLog() {
+ var exception: Throwable? = null
+ var outputFile: DownloadsFileSaver.DownloadsFile? = null
+ withContext(Dispatchers.IO) {
+ try {
+ outputFile = downloadsFileSaver.save("wireguard-log.txt", "text/plain", true)
+ outputFile?.outputStream?.write(rawLogBytes())
+ } catch (e: Throwable) {
+ outputFile?.delete()
+ exception = e
+ }
+ }
+ saveButton?.isEnabled = true
+ if (outputFile == null)
+ return
+ Snackbar.make(
+ findViewById(android.R.id.content),
+ if (exception == null) getString(R.string.log_export_success, outputFile.fileName)
+ else getString(R.string.log_export_error, ErrorMessages[exception]),
+ if (exception == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
+ )
+ .setAnchorView(binding.shareFab)
+ .show()
+ }
+
+ private suspend fun streamingLog() = withContext(Dispatchers.IO) {
+ val builder = ProcessBuilder().command("logcat", "-b", "all", "-v", "threadtime", "*:V")
+ builder.environment()["LC_ALL"] = "C"
+ var process: Process? = null
+ try {
+ process = try {
+ builder.start()
+ } catch (e: IOException) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ return@withContext
+ }
+ val stdout = BufferedReader(InputStreamReader(process!!.inputStream, StandardCharsets.UTF_8))
+
+ var posStart = 0
+ var timeLastNotify = System.nanoTime()
+ var priorModified = false
+ val bufferedLogLines = arrayListOf()
+ var timeout = 1000000000L / 2 // The timeout is initially small so that the view gets populated immediately.
+ val MAX_LINES = (1 shl 16) - 1
+ val MAX_BUFFERED_LINES = (1 shl 14) - 1
+
+ while (true) {
+ val line = stdout.readLine() ?: break
+ if (rawLogLines.size() >= MAX_LINES)
+ rawLogLines.popFirst()
+ rawLogLines.addLast(line)
+ val logLine = parseLine(line)
+ if (logLine != null) {
+ bufferedLogLines.add(logLine)
+ } else {
+ if (bufferedLogLines.isNotEmpty()) {
+ bufferedLogLines.last().msg += "\n$line"
+ } else if (!logLines.isEmpty()) {
+ logLines[logLines.size() - 1].msg += "\n$line"
+ priorModified = true
+ }
+ }
+ val timeNow = System.nanoTime()
+ if (bufferedLogLines.size < MAX_BUFFERED_LINES && (timeNow - timeLastNotify) < timeout && stdout.ready())
+ continue
+ timeout = 1000000000L * 5 / 2 // Increase the timeout after the initial view has something in it.
+ timeLastNotify = timeNow
+
+ withContext(Dispatchers.Main.immediate) {
+ val isScrolledToBottomAlready = recyclerView?.canScrollVertically(1) == false
+ if (priorModified) {
+ logAdapter.notifyItemChanged(posStart - 1)
+ priorModified = false
+ }
+ val fullLen = logLines.size() + bufferedLogLines.size
+ if (fullLen >= MAX_LINES) {
+ val numToRemove = fullLen - MAX_LINES + 1
+ logLines.removeFromStart(numToRemove)
+ logAdapter.notifyItemRangeRemoved(0, numToRemove)
+ posStart -= numToRemove
+
+ }
+ for (bufferedLine in bufferedLogLines) {
+ logLines.addLast(bufferedLine)
+ }
+ bufferedLogLines.clear()
+ logAdapter.notifyItemRangeInserted(posStart, logLines.size() - posStart)
+ posStart = logLines.size()
+
+ if (isScrolledToBottomAlready) {
+ recyclerView?.scrollToPosition(logLines.size() - 1)
+ }
+ }
+ }
+ } finally {
+ process?.destroy()
+ }
+ }
+
+ private fun parseTime(timeStr: String): Date? {
+ val formatter: DateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
+ return try {
+ formatter.parse("$year-$timeStr")
+ } catch (e: ParseException) {
+ null
+ }
+ }
+
+ private fun parseLine(line: String): LogLine? {
+ val m: Matcher = THREADTIME_LINE.matcher(line)
+ return if (m.matches()) {
+ LogLine(m.group(2)!!.toInt(), m.group(3)!!.toInt(), parseTime(m.group(1)!!), m.group(4)!!, m.group(5)!!, m.group(6)!!)
+ } else {
+ null
+ }
+ }
+
+ private data class LogLine(val pid: Int, val tid: Int, val time: Date?, val level: String, val tag: String, var msg: String)
+
+ companion object {
+ /**
+ * Match a single line of `logcat -v threadtime`, such as:
+ *
+ * 05-26 11:02:36.886 5689 5689 D AndroidRuntime: CheckJNI is OFF.
+ */
+ private val THREADTIME_LINE: Pattern =
+ Pattern.compile("^(\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}.\\d{3})(?:\\s+[0-9A-Za-z]+)?\\s+(\\d+)\\s+(\\d+)\\s+([A-Z])\\s+(.+?)\\s*: (.*)$")
+ private val LOGS: MutableMap = ConcurrentHashMap()
+ private const val TAG = "WireGuard/LogViewerActivity"
+ }
+
+ private inner class LogEntryAdapter : RecyclerView.Adapter() {
+
+ private inner class ViewHolder(val layout: View, var isSingleLine: Boolean = true) : RecyclerView.ViewHolder(layout)
+
+ private fun levelToColor(level: String): Int {
+ return when (level) {
+ "V", "D" -> debugColor
+ "E" -> errorColor
+ "I" -> infoColor
+ "W" -> warningColor
+ else -> defaultColor
+ }
+ }
+
+ override fun getItemCount() = logLines.size()
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val view = LayoutInflater.from(parent.context)
+ .inflate(R.layout.log_viewer_entry, parent, false)
+ return ViewHolder(view)
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val line = logLines[position]
+ val spannable = if (position > 0 && logLines[position - 1].tag == line.tag)
+ SpannableString(line.msg)
+ else
+ SpannableString("${line.tag}: ${line.msg}").apply {
+ setSpan(StyleSpan(BOLD), 0, "${line.tag}:".length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ setSpan(
+ ForegroundColorSpan(levelToColor(line.level)),
+ 0, "${line.tag}:".length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
+ )
+ }
+ holder.layout.apply {
+ findViewById(R.id.log_date).text = line.time.toString()
+ findViewById(R.id.log_msg).apply {
+ setSingleLine()
+ text = spannable
+ setOnClickListener {
+ isSingleLine = !holder.isSingleLine
+ holder.isSingleLine = !holder.isSingleLine
+ }
+ }
+ }
+ }
+ }
+
+ class ExportedLogContentProvider : ContentProvider() {
+ private fun logForUri(uri: Uri): ByteArray? = LOGS[uri.pathSegments.lastOrNull()]
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+
+ override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? =
+ logForUri(uri)?.let {
+ val m = MatrixCursor(arrayOf(android.provider.OpenableColumns.DISPLAY_NAME, android.provider.OpenableColumns.SIZE), 1)
+ m.addRow(arrayOf("wireguard-log.txt", it.size.toLong()))
+ m
+ }
+
+ override fun onCreate(): Boolean = true
+
+ override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0
+
+ override fun getType(uri: Uri): String? = logForUri(uri)?.let { "text/plain" }
+
+ override fun getStreamTypes(uri: Uri, mimeTypeFilter: String): Array? =
+ getType(uri)?.let { if (compareMimeTypes(it, mimeTypeFilter)) arrayOf(it) else null }
+
+ override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
+ if (mode != "r") return null
+ val log = logForUri(uri) ?: return null
+ return openPipeHelper(uri, "text/plain", null, log) { output, _, _, _, l ->
+ try {
+ FileOutputStream(output.fileDescriptor).write(l!!)
+ } catch (_: Throwable) {
+ }
+ }
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/MainActivity.kt b/ui/src/main/java/com/wireguard/android/activity/MainActivity.kt
new file mode 100644
index 0000000..087ca08
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/MainActivity.kt
@@ -0,0 +1,129 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.activity
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.addCallback
+import androidx.appcompat.app.ActionBar
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.FragmentTransaction
+import androidx.fragment.app.commit
+import com.wireguard.android.R
+import com.wireguard.android.fragment.TunnelDetailFragment
+import com.wireguard.android.fragment.TunnelEditorFragment
+import com.wireguard.android.model.ObservableTunnel
+
+/**
+ * CRUD interface for WireGuard tunnels. This activity serves as the main entry point to the
+ * WireGuard application, and contains several fragments for listing, viewing details of, and
+ * editing the configuration and interface state of WireGuard tunnels.
+ */
+class MainActivity : BaseActivity(), FragmentManager.OnBackStackChangedListener {
+ private var actionBar: ActionBar? = null
+ private var isTwoPaneLayout = false
+ private var backPressedCallback: OnBackPressedCallback? = null
+
+ private fun handleBackPressed() {
+ val backStackEntries = supportFragmentManager.backStackEntryCount
+ // If the two-pane layout does not have an editor open, going back should exit the app.
+ if (isTwoPaneLayout && backStackEntries <= 1) {
+ finish()
+ return
+ }
+
+ if (backStackEntries >= 1)
+ supportFragmentManager.popBackStack()
+
+ // Deselect the current tunnel on navigating back from the detail pane to the one-pane list.
+ if (backStackEntries == 1)
+ selectedTunnel = null
+ }
+
+ override fun onBackStackChanged() {
+ val backStackEntries = supportFragmentManager.backStackEntryCount
+ backPressedCallback?.isEnabled = backStackEntries >= 1
+ if (actionBar == null) return
+ // Do not show the home menu when the two-pane layout is at the detail view (see above).
+ val minBackStackEntries = if (isTwoPaneLayout) 2 else 1
+ actionBar!!.setDisplayHomeAsUpEnabled(backStackEntries >= minBackStackEntries)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.main_activity)
+ actionBar = supportActionBar
+ isTwoPaneLayout = findViewById(R.id.master_detail_wrapper) != null
+ supportFragmentManager.addOnBackStackChangedListener(this)
+ backPressedCallback = onBackPressedDispatcher.addCallback(this) { handleBackPressed() }
+ onBackStackChanged()
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.main_activity, menu)
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ // The back arrow in the action bar should act the same as the back button.
+ onBackPressedDispatcher.onBackPressed()
+ true
+ }
+
+ R.id.menu_action_edit -> {
+ supportFragmentManager.commit {
+ replace(if (isTwoPaneLayout) R.id.detail_container else R.id.list_detail_container, TunnelEditorFragment())
+ setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
+ addToBackStack(null)
+ }
+ true
+ }
+ // This menu item is handled by the editor fragment.
+ R.id.menu_action_save -> false
+ R.id.menu_settings -> {
+ startActivity(Intent(this, SettingsActivity::class.java))
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ override fun onSelectedTunnelChanged(
+ oldTunnel: ObservableTunnel?,
+ newTunnel: ObservableTunnel?
+ ): Boolean {
+ val fragmentManager = supportFragmentManager
+ if (fragmentManager.isStateSaved) {
+ return false
+ }
+
+ val backStackEntries = fragmentManager.backStackEntryCount
+ if (newTunnel == null) {
+ // Clear everything off the back stack (all editors and detail fragments).
+ fragmentManager.popBackStackImmediate(0, FragmentManager.POP_BACK_STACK_INCLUSIVE)
+ return true
+ }
+ if (backStackEntries == 2) {
+ // Pop the editor off the back stack to reveal the detail fragment. Use the immediate
+ // method to avoid the editor picking up the new tunnel while it is still visible.
+ fragmentManager.popBackStackImmediate()
+ } else if (backStackEntries == 0) {
+ // Create and show a new detail fragment.
+ fragmentManager.commit {
+ add(if (isTwoPaneLayout) R.id.detail_container else R.id.list_detail_container, TunnelDetailFragment())
+ setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
+ addToBackStack(null)
+ }
+ }
+ return true
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.kt b/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.kt
new file mode 100644
index 0000000..33f44cd
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.kt
@@ -0,0 +1,113 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.activity
+
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.commit
+import androidx.lifecycle.lifecycleScope
+import androidx.preference.Preference
+import androidx.preference.PreferenceFragmentCompat
+import com.wireguard.android.Application
+import com.wireguard.android.QuickTileService
+import com.wireguard.android.R
+import com.wireguard.android.backend.WgQuickBackend
+import com.wireguard.android.preference.PreferencesPreferenceDataStore
+import com.wireguard.android.util.AdminKnobs
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * Interface for changing application-global persistent settings.
+ */
+class SettingsActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ if (supportFragmentManager.findFragmentById(android.R.id.content) == null) {
+ supportFragmentManager.commit {
+ add(android.R.id.content, SettingsFragment())
+ }
+ }
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ if (item.itemId == android.R.id.home) {
+ finish()
+ return true
+ }
+ return super.onOptionsItemSelected(item)
+ }
+
+ class SettingsFragment : PreferenceFragmentCompat() {
+
+ // Since this is pretty much abandoned by androidx, it never got updated for proper EdgeToEdge support,
+ // which is enabled everywhere for API 35. So handle the insets manually here.
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ val view = super.onCreateView(inflater, container, savedInstanceState)
+ view.fitsSystemWindows = true
+ return view
+ }
+
+ override fun onCreatePreferences(savedInstanceState: Bundle?, key: String?) {
+ preferenceManager.preferenceDataStore = PreferencesPreferenceDataStore(lifecycleScope, Application.getPreferencesDataStore())
+ addPreferencesFromResource(R.xml.preferences)
+ preferenceScreen.initialExpandedChildrenCount = 5
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || QuickTileService.isAdded) {
+ val quickTile = preferenceManager.findPreference("quick_tile")
+ quickTile?.parent?.removePreference(quickTile)
+ --preferenceScreen.initialExpandedChildrenCount
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val darkTheme = preferenceManager.findPreference("dark_theme")
+ darkTheme?.parent?.removePreference(darkTheme)
+ --preferenceScreen.initialExpandedChildrenCount
+ }
+ if (AdminKnobs.disableConfigExport) {
+ val zipExporter = preferenceManager.findPreference("zip_exporter")
+ zipExporter?.parent?.removePreference(zipExporter)
+ }
+ val wgQuickOnlyPrefs = arrayOf(
+ preferenceManager.findPreference("tools_installer"),
+ preferenceManager.findPreference("restore_on_boot"),
+ preferenceManager.findPreference("multiple_tunnels")
+ ).filterNotNull()
+ wgQuickOnlyPrefs.forEach { it.isVisible = false }
+ lifecycleScope.launch {
+ if (Application.getBackend() is WgQuickBackend) {
+ ++preferenceScreen.initialExpandedChildrenCount
+ wgQuickOnlyPrefs.forEach { it.isVisible = true }
+ } else {
+ wgQuickOnlyPrefs.forEach { it.parent?.removePreference(it) }
+ }
+ }
+ preferenceManager.findPreference("log_viewer")?.setOnPreferenceClickListener {
+ startActivity(Intent(requireContext(), LogViewerActivity::class.java))
+ true
+ }
+ val kernelModuleEnabler = preferenceManager.findPreference("kernel_module_enabler")
+ if (WgQuickBackend.hasKernelSupport()) {
+ lifecycleScope.launch {
+ if (Application.getBackend() !is WgQuickBackend) {
+ try {
+ withContext(Dispatchers.IO) { Application.getRootShell().start() }
+ } catch (_: Throwable) {
+ kernelModuleEnabler?.parent?.removePreference(kernelModuleEnabler)
+ }
+ }
+ }
+ } else {
+ kernelModuleEnabler?.parent?.removePreference(kernelModuleEnabler)
+ }
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.kt b/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.kt
new file mode 100644
index 0000000..8d5f4cf
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.activity
+
+import android.os.Bundle
+import com.wireguard.android.R
+import com.wireguard.android.model.ObservableTunnel
+
+/**
+ * Standalone activity for creating tunnels.
+ */
+class TunnelCreatorActivity : BaseActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.tunnel_creator_activity)
+ }
+
+ override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?): Boolean {
+ finish()
+ return true
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.kt b/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.kt
new file mode 100644
index 0000000..a0e9aa0
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.activity
+
+import android.content.ComponentName
+import android.os.Build
+import android.os.Bundle
+import android.service.quicksettings.TileService
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.lifecycleScope
+import com.wireguard.android.Application
+import com.wireguard.android.QuickTileService
+import com.wireguard.android.R
+import com.wireguard.android.backend.GoBackend
+import com.wireguard.android.backend.Tunnel
+import com.wireguard.android.util.ErrorMessages
+import kotlinx.coroutines.launch
+
+class TunnelToggleActivity : AppCompatActivity() {
+ private val permissionActivityResultLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { toggleTunnelWithPermissionsResult() }
+
+ private fun toggleTunnelWithPermissionsResult() {
+ val tunnel = Application.getTunnelManager().lastUsedTunnel ?: return
+ lifecycleScope.launch {
+ try {
+ tunnel.setStateAsync(Tunnel.State.TOGGLE)
+ } catch (e: Throwable) {
+ TileService.requestListeningState(this@TunnelToggleActivity, ComponentName(this@TunnelToggleActivity, QuickTileService::class.java))
+ val error = ErrorMessages[e]
+ val message = getString(R.string.toggle_error, error)
+ Log.e(TAG, message, e)
+ Toast.makeText(this@TunnelToggleActivity, message, Toast.LENGTH_LONG).show()
+ finishAffinity()
+ return@launch
+ }
+ TileService.requestListeningState(this@TunnelToggleActivity, ComponentName(this@TunnelToggleActivity, QuickTileService::class.java))
+ finishAffinity()
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ lifecycleScope.launch {
+ if (Application.getBackend() is GoBackend) {
+ try {
+ val intent = GoBackend.VpnService.prepare(this@TunnelToggleActivity)
+ if (intent != null) {
+ permissionActivityResultLauncher.launch(intent)
+ return@launch
+ }
+ } catch (e: Exception) {
+ Toast.makeText(this@TunnelToggleActivity, ErrorMessages[e], Toast.LENGTH_LONG).show()
+ }
+ }
+ toggleTunnelWithPermissionsResult()
+ }
+ }
+
+ companion object {
+ private const val TAG = "WireGuard/TunnelToggleActivity"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt b/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt
new file mode 100644
index 0000000..a20c983
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt
@@ -0,0 +1,431 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.activity
+
+import android.Manifest
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.os.Environment
+import android.os.storage.StorageManager
+import android.os.storage.StorageVolume
+import android.util.Log
+import android.view.View
+import android.widget.Toast
+import androidx.activity.addCallback
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.content.ContextCompat
+import androidx.core.content.getSystemService
+import androidx.core.view.forEach
+import androidx.databinding.DataBindingUtil
+import androidx.databinding.Observable
+import androidx.databinding.ObservableBoolean
+import androidx.databinding.ObservableField
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.wireguard.android.Application
+import com.wireguard.android.R
+import com.wireguard.android.backend.GoBackend
+import com.wireguard.android.backend.Tunnel
+import com.wireguard.android.databinding.Keyed
+import com.wireguard.android.databinding.ObservableKeyedArrayList
+import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter
+import com.wireguard.android.databinding.TvActivityBinding
+import com.wireguard.android.databinding.TvFileListItemBinding
+import com.wireguard.android.databinding.TvTunnelListItemBinding
+import com.wireguard.android.model.ObservableTunnel
+import com.wireguard.android.util.ErrorMessages
+import com.wireguard.android.util.QuantityFormatter
+import com.wireguard.android.util.TunnelImporter
+import com.wireguard.android.util.UserKnobs
+import com.wireguard.android.util.applicationScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+
+class TvMainActivity : AppCompatActivity() {
+ private val tunnelFileImportResultLauncher = registerForActivityResult(object : ActivityResultContracts.OpenDocument() {
+ override fun createIntent(context: Context, input: Array): Intent {
+ val intent = super.createIntent(context, input)
+
+ /* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
+ * what we can do, so detect this and throw an exception that we can catch later. */
+ val activitiesToResolveIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ context.packageManager.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong()))
+ } else {
+ @Suppress("DEPRECATION")
+ context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
+ }
+ if (activitiesToResolveIntent.all {
+ val name = it.activityInfo.packageName
+ name.startsWith("com.google.android.tv.frameworkpackagestubs") || name.startsWith("com.android.tv.frameworkpackagestubs")
+ }) {
+ throw ActivityNotFoundException()
+ }
+ return intent
+ }
+ }) { data ->
+ if (data == null) return@registerForActivityResult
+ lifecycleScope.launch {
+ TunnelImporter.importTunnel(contentResolver, data) {
+ Toast.makeText(this@TvMainActivity, it, Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+ private var pendingTunnel: ObservableTunnel? = null
+ private val permissionActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ val tunnel = pendingTunnel
+ if (tunnel != null)
+ setTunnelStateWithPermissionsResult(tunnel)
+ pendingTunnel = null
+ }
+
+ private fun setTunnelStateWithPermissionsResult(tunnel: ObservableTunnel) {
+ lifecycleScope.launch {
+ try {
+ tunnel.setStateAsync(Tunnel.State.TOGGLE)
+ } catch (e: Throwable) {
+ val error = ErrorMessages[e]
+ val message = getString(R.string.error_up, error)
+ Toast.makeText(this@TvMainActivity, message, Toast.LENGTH_LONG).show()
+ Log.e(TAG, message, e)
+ }
+ updateStats()
+ }
+ }
+
+ private lateinit var binding: TvActivityBinding
+ private val isDeleting = ObservableBoolean()
+ private val files = ObservableKeyedArrayList()
+ private val filesRoot = ObservableField("")
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ if (AppCompatDelegate.getDefaultNightMode() != AppCompatDelegate.MODE_NIGHT_YES) {
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ applicationScope.launch {
+ UserKnobs.setDarkTheme(true)
+ }
+ }
+ }
+ super.onCreate(savedInstanceState)
+ binding = TvActivityBinding.inflate(layoutInflater)
+ lifecycleScope.launch {
+ binding.tunnels = Application.getTunnelManager().getTunnels()
+ if (binding.tunnels?.isEmpty() == true)
+ binding.importButton.requestFocus()
+ else
+ binding.tunnelList.requestFocus()
+ }
+ binding.isDeleting = isDeleting
+ binding.files = files
+ binding.filesRoot = filesRoot
+ val gridManager = binding.tunnelList.layoutManager as GridLayoutManager
+ gridManager.spanSizeLookup = SlatedSpanSizeLookup(gridManager)
+ binding.tunnelRowConfigurationHandler = object : ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler {
+ override fun onConfigureRow(binding: TvTunnelListItemBinding, item: ObservableTunnel, position: Int) {
+ binding.isDeleting = isDeleting
+ binding.isFocused = ObservableBoolean()
+ binding.root.setOnFocusChangeListener { _, focused ->
+ binding.isFocused?.set(focused)
+ }
+ binding.root.setOnClickListener {
+ lifecycleScope.launch {
+ if (isDeleting.get()) {
+ try {
+ item.deleteAsync()
+ if (this@TvMainActivity.binding.tunnels?.isEmpty() != false)
+ isDeleting.set(false)
+ } catch (e: Throwable) {
+ val error = ErrorMessages[e]
+ val message = getString(R.string.config_delete_error, error)
+ Toast.makeText(this@TvMainActivity, message, Toast.LENGTH_LONG).show()
+ Log.e(TAG, message, e)
+ }
+ } else {
+ if (Application.getBackend() is GoBackend) {
+ val intent = GoBackend.VpnService.prepare(binding.root.context)
+ if (intent != null) {
+ pendingTunnel = item
+ permissionActivityResultLauncher.launch(intent)
+ return@launch
+ }
+ }
+ setTunnelStateWithPermissionsResult(item)
+ }
+ }
+ }
+ }
+ }
+
+ binding.filesRowConfigurationHandler = object : ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler {
+ override fun onConfigureRow(binding: TvFileListItemBinding, item: KeyedFile, position: Int) {
+ binding.root.setOnClickListener {
+ if (item.file.isDirectory)
+ navigateTo(item.file)
+ else {
+ val uri = Uri.fromFile(item.file)
+ files.clear()
+ filesRoot.set("")
+ lifecycleScope.launch {
+ TunnelImporter.importTunnel(contentResolver, uri) {
+ Toast.makeText(this@TvMainActivity, it, Toast.LENGTH_LONG).show()
+ }
+ }
+ runOnUiThread {
+ this@TvMainActivity.binding.tunnelList.requestFocus()
+ }
+ }
+ }
+ }
+ }
+
+ binding.importButton.setOnClickListener {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ if (filesRoot.get()?.isEmpty() != false) {
+ navigateTo(File("/"))
+ runOnUiThread {
+ binding.filesList.requestFocus()
+ }
+ } else {
+ files.clear()
+ filesRoot.set("")
+ runOnUiThread {
+ binding.tunnelList.requestFocus()
+ }
+ }
+ } else {
+ try {
+ tunnelFileImportResultLauncher.launch(arrayOf("*/*"))
+ } catch (_: Throwable) {
+ MaterialAlertDialogBuilder(binding.root.context).setMessage(R.string.tv_no_file_picker).setCancelable(false)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ try {
+ startActivity(Intent(Intent.ACTION_VIEW).apply {
+ data = Uri.parse("https://play.google.com/store/apps/details?id=com.cxinventor.file.explorer")
+ setPackage("com.android.vending")
+ })
+ } catch (_: Throwable) {
+ }
+ }.show()
+ }
+ }
+ }
+
+ binding.deleteButton.setOnClickListener {
+ isDeleting.set(!isDeleting.get())
+ runOnUiThread {
+ binding.tunnelList.requestFocus()
+ }
+ }
+
+ val backPressedCallback = onBackPressedDispatcher.addCallback(this) { handleBackPressed() }
+ val updateBackPressedCallback = object : Observable.OnPropertyChangedCallback() {
+ override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
+ backPressedCallback.isEnabled = isDeleting.get() || filesRoot.get()?.isNotEmpty() == true
+ }
+ }
+ isDeleting.addOnPropertyChangedCallback(updateBackPressedCallback)
+ filesRoot.addOnPropertyChangedCallback(updateBackPressedCallback)
+ backPressedCallback.isEnabled = false
+
+ binding.executePendingBindings()
+ setContentView(binding.root)
+
+ lifecycleScope.launch {
+ while (true) {
+ updateStats()
+ delay(1000)
+ }
+ }
+ }
+
+ private var pendingNavigation: File? = null
+ private val permissionRequestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
+ val to = pendingNavigation
+ if (it && to != null)
+ navigateTo(to)
+ pendingNavigation = null
+ }
+
+ private var cachedRoots: Collection? = null
+
+ private suspend fun makeStorageRoots(): Collection = withContext(Dispatchers.IO) {
+ cachedRoots?.let { return@withContext it }
+ val list = HashSet()
+ val storageManager: StorageManager = getSystemService() ?: return@withContext list
+ list.addAll(storageManager.storageVolumes.mapNotNull { volume ->
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ volume.directory?.let { KeyedFile(it, volume.getDescription(this@TvMainActivity)) }
+ } else {
+ KeyedFile((StorageVolume::class.java.getMethod("getPathFile").invoke(volume) as File), volume.getDescription(this@TvMainActivity))
+ }
+ })
+ cachedRoots = list
+ list
+ }
+
+ private fun isBelowCachedRoots(maybeChild: File): Boolean {
+ val cachedRoots = cachedRoots ?: return true
+ for (root in cachedRoots) {
+ if (maybeChild.canonicalPath.startsWith(root.file.canonicalPath))
+ return false
+ }
+ return true
+ }
+
+ private fun navigateTo(directory: File) {
+ require(Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
+
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
+ pendingNavigation = directory
+ permissionRequestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
+ return
+ }
+
+ lifecycleScope.launch {
+ if (isBelowCachedRoots(directory)) {
+ val roots = makeStorageRoots()
+ if (roots.count() == 1) {
+ navigateTo(roots.first().file)
+ return@launch
+ }
+ files.clear()
+ files.addAll(roots)
+ filesRoot.set(getString(R.string.tv_select_a_storage_drive))
+ return@launch
+ }
+
+ val newFiles = withContext(Dispatchers.IO) {
+ val newFiles = ArrayList()
+ try {
+ directory.parentFile?.let {
+ newFiles.add(KeyedFile(it, "../"))
+ }
+ val listing = directory.listFiles() ?: return@withContext null
+ listing.forEach {
+ if (it.extension == "conf" || it.extension == "zip" || it.isDirectory)
+ newFiles.add(KeyedFile(it))
+ }
+ newFiles.sortWith { a, b ->
+ if (a.file.isDirectory && !b.file.isDirectory) -1
+ else if (!a.file.isDirectory && b.file.isDirectory) 1
+ else a.file.compareTo(b.file)
+ }
+ } catch (e: Throwable) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ }
+ newFiles
+ }
+ if (newFiles?.isEmpty() != false)
+ return@launch
+ files.clear()
+ files.addAll(newFiles)
+ filesRoot.set(directory.canonicalPath)
+ }
+ }
+
+ private fun handleBackPressed() {
+ when {
+ isDeleting.get() -> {
+ isDeleting.set(false)
+ runOnUiThread {
+ binding.tunnelList.requestFocus()
+ }
+ }
+
+ filesRoot.get()?.isNotEmpty() == true -> {
+ files.clear()
+ filesRoot.set("")
+ runOnUiThread {
+ binding.tunnelList.requestFocus()
+ }
+ }
+ }
+ }
+
+ private suspend fun updateStats() {
+ binding.tunnelList.forEach { viewItem ->
+ val listItem = DataBindingUtil.findBinding(viewItem)
+ ?: return@forEach
+ try {
+ val tunnel = listItem.item!!
+ if (tunnel.state != Tunnel.State.UP || isDeleting.get()) {
+ throw Exception()
+ }
+ val statistics = tunnel.getStatisticsAsync()
+ val rx = statistics.totalRx()
+ val tx = statistics.totalTx()
+ listItem.tunnelTransfer.text = getString(R.string.transfer_rx_tx, QuantityFormatter.formatBytes(rx), QuantityFormatter.formatBytes(tx))
+ listItem.tunnelTransfer.visibility = View.VISIBLE
+ } catch (_: Throwable) {
+ listItem.tunnelTransfer.visibility = View.GONE
+ listItem.tunnelTransfer.text = ""
+ }
+ }
+ }
+
+ class KeyedFile(val file: File, private val forcedKey: String? = null) : Keyed {
+ override val key: String
+ get() = forcedKey ?: if (file.isDirectory) "${file.name}/" else file.name
+ }
+
+ private class SlatedSpanSizeLookup(private val gridManager: GridLayoutManager) : SpanSizeLookup() {
+ private val originalHeight = gridManager.spanCount
+ private var newWidth = 0
+ private lateinit var sizeMap: Array
+
+ private fun emptyUnderIndex(index: Int, size: Int): Int {
+ sizeMap[size - 1]?.let { return it[index] }
+ val sizes = IntArray(size)
+ val oh = originalHeight
+ val nw = newWidth
+ var empties = 0
+ for (i in 0 until size) {
+ val ox = (i + empties) / oh
+ val oy = (i + empties) % oh
+ var empty = 0
+ for (j in oy + 1 until oh) {
+ val ni = nw * j + ox
+ if (ni < size)
+ break
+ empty++
+ }
+ empties += empty
+ sizes[i] = empty
+ }
+ sizeMap[size - 1] = sizes
+ return sizes[index]
+ }
+
+ override fun getSpanSize(position: Int): Int {
+ if (newWidth == 0) {
+ val child = gridManager.getChildAt(0) ?: return 1
+ if (child.width == 0) return 1
+ newWidth = gridManager.width / child.width
+ sizeMap = Array(originalHeight * newWidth - 1) { null }
+ }
+ val total = gridManager.itemCount
+ if (total >= originalHeight * newWidth || total == 0)
+ return 1
+ return emptyUnderIndex(position, total) + 1
+ }
+ }
+
+ companion object {
+ private const val TAG = "WireGuard/TvMainActivity"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/configStore/ConfigStore.kt b/ui/src/main/java/com/wireguard/android/configStore/ConfigStore.kt
new file mode 100644
index 0000000..45f3860
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/configStore/ConfigStore.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.configStore
+
+import com.wireguard.config.Config
+
+/**
+ * Interface for persistent storage providers for WireGuard configurations.
+ */
+interface ConfigStore {
+ /**
+ * Create a persistent tunnel, which must have a unique name within the persistent storage
+ * medium.
+ *
+ * @param name The name of the tunnel to create.
+ * @param config Configuration for the new tunnel.
+ * @return The configuration that was actually saved to persistent storage.
+ */
+ @Throws(Exception::class)
+ fun create(name: String, config: Config): Config
+
+ /**
+ * Delete a persistent tunnel.
+ *
+ * @param name The name of the tunnel to delete.
+ */
+ @Throws(Exception::class)
+ fun delete(name: String)
+
+ /**
+ * Enumerate the names of tunnels present in persistent storage.
+ *
+ * @return The set of present tunnel names.
+ */
+ fun enumerate(): Set
+
+ /**
+ * Load the configuration for the tunnel given by `name`.
+ *
+ * @param name The identifier for the configuration in persistent storage (i.e. the name of the
+ * tunnel).
+ * @return An in-memory representation of the configuration loaded from persistent storage.
+ */
+ @Throws(Exception::class)
+ fun load(name: String): Config
+
+ /**
+ * Rename the configuration for the tunnel given by `name`.
+ *
+ * @param name The identifier for the existing configuration in persistent storage.
+ * @param replacement The new identifier for the configuration in persistent storage.
+ */
+ @Throws(Exception::class)
+ fun rename(name: String, replacement: String)
+
+ /**
+ * Save the configuration for an existing tunnel given by `name`.
+ *
+ * @param name The identifier for the configuration in persistent storage (i.e. the name of
+ * the tunnel).
+ * @param config An updated configuration object for the tunnel.
+ * @return The configuration that was actually saved to persistent storage.
+ */
+ @Throws(Exception::class)
+ fun save(name: String, config: Config): Config
+}
diff --git a/ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.kt b/ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.kt
new file mode 100644
index 0000000..98b738e
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.configStore
+
+import android.content.Context
+import android.util.Log
+import com.wireguard.android.R
+import com.wireguard.config.BadConfigException
+import com.wireguard.config.Config
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileNotFoundException
+import java.io.FileOutputStream
+import java.io.IOException
+import java.nio.charset.StandardCharsets
+
+/**
+ * Configuration store that uses a `wg-quick`-style file for each configured tunnel.
+ */
+class FileConfigStore(private val context: Context) : ConfigStore {
+ @Throws(IOException::class)
+ override fun create(name: String, config: Config): Config {
+ Log.d(TAG, "Creating configuration for tunnel $name")
+ val file = fileFor(name)
+ if (!file.createNewFile())
+ throw IOException(context.getString(R.string.config_file_exists_error, file.name))
+ FileOutputStream(file, false).use { it.write(config.toWgQuickString().toByteArray(StandardCharsets.UTF_8)) }
+ return config
+ }
+
+ @Throws(IOException::class)
+ override fun delete(name: String) {
+ Log.d(TAG, "Deleting configuration for tunnel $name")
+ val file = fileFor(name)
+ if (!file.delete())
+ throw IOException(context.getString(R.string.config_delete_error, file.name))
+ }
+
+ override fun enumerate(): Set {
+ return context.fileList()
+ .filter { it.endsWith(".conf") }
+ .map { it.substring(0, it.length - ".conf".length) }
+ .toSet()
+ }
+
+ private fun fileFor(name: String): File {
+ return File(context.filesDir, "$name.conf")
+ }
+
+ @Throws(BadConfigException::class, IOException::class)
+ override fun load(name: String): Config {
+ FileInputStream(fileFor(name)).use { stream -> return Config.parse(stream) }
+ }
+
+ @Throws(IOException::class)
+ override fun rename(name: String, replacement: String) {
+ Log.d(TAG, "Renaming configuration for tunnel $name to $replacement")
+ val file = fileFor(name)
+ val replacementFile = fileFor(replacement)
+ if (!replacementFile.createNewFile()) throw IOException(context.getString(R.string.config_exists_error, replacement))
+ if (!file.renameTo(replacementFile)) {
+ if (!replacementFile.delete()) Log.w(TAG, "Couldn't delete marker file for new name $replacement")
+ throw IOException(context.getString(R.string.config_rename_error, file.name))
+ }
+ }
+
+ @Throws(IOException::class)
+ override fun save(name: String, config: Config): Config {
+ Log.d(TAG, "Saving configuration for tunnel $name")
+ val file = fileFor(name)
+ if (!file.isFile)
+ throw FileNotFoundException(context.getString(R.string.config_not_found_error, file.name))
+ FileOutputStream(file, false).use { stream -> stream.write(config.toWgQuickString().toByteArray(StandardCharsets.UTF_8)) }
+ return config
+ }
+
+ companion object {
+ private const val TAG = "WireGuard/FileConfigStore"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.kt b/ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.kt
new file mode 100644
index 0000000..df3bd08
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.kt
@@ -0,0 +1,194 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.databinding
+
+import android.text.InputFilter
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.EditText
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.databinding.BindingAdapter
+import androidx.databinding.DataBindingUtil
+import androidx.databinding.ObservableList
+import androidx.databinding.ViewDataBinding
+import androidx.databinding.adapters.ListenerUtil
+import androidx.fragment.app.Fragment
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.wireguard.android.BR
+import com.wireguard.android.R
+import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler
+import com.wireguard.android.widget.ToggleSwitch
+import com.wireguard.android.widget.ToggleSwitch.OnBeforeCheckedChangeListener
+import com.wireguard.android.widget.TvCardView
+import com.wireguard.config.Attribute
+import com.wireguard.config.InetNetwork
+import java.net.InetAddress
+import java.util.Optional
+
+/**
+ * Static methods for use by generated code in the Android data binding library.
+ */
+object BindingAdapters {
+ @JvmStatic
+ @BindingAdapter("checked")
+ fun setChecked(view: ToggleSwitch, checked: Boolean) {
+ view.setCheckedInternal(checked)
+ }
+
+ @JvmStatic
+ @BindingAdapter("filter")
+ fun setFilter(view: TextView, filter: InputFilter) {
+ view.filters = arrayOf(filter)
+ }
+
+ @JvmStatic
+ @BindingAdapter("items", "layout", "fragment")
+ fun setItems(
+ view: LinearLayout,
+ oldList: ObservableList?, oldLayoutId: Int, @Suppress("UNUSED_PARAMETER") oldFragment: Fragment?,
+ newList: ObservableList?, newLayoutId: Int, newFragment: Fragment?
+ ) {
+ if (oldList === newList && oldLayoutId == newLayoutId)
+ return
+ var listener: ItemChangeListener? = ListenerUtil.getListener(view, R.id.item_change_listener)
+ // If the layout changes, any existing listener must be replaced.
+ if (listener != null && oldList != null && oldLayoutId != newLayoutId) {
+ listener.setList(null)
+ listener = null
+ // Stop tracking the old listener.
+ ListenerUtil.trackListener(view, null, R.id.item_change_listener)
+ }
+ // Avoid adding a listener when there is no new list or layout.
+ if (newList == null || newLayoutId == 0)
+ return
+ if (listener == null) {
+ listener = ItemChangeListener(view, newLayoutId, newFragment)
+ ListenerUtil.trackListener(view, listener, R.id.item_change_listener)
+ }
+ // Either the list changed, or this is an entirely new listener because the layout changed.
+ listener.setList(newList)
+ }
+
+ @JvmStatic
+ @BindingAdapter("items", "layout")
+ fun setItems(
+ view: LinearLayout,
+ oldList: Iterable?, oldLayoutId: Int,
+ newList: Iterable?, newLayoutId: Int
+ ) {
+ if (oldList === newList && oldLayoutId == newLayoutId)
+ return
+ view.removeAllViews()
+ if (newList == null)
+ return
+ val layoutInflater = LayoutInflater.from(view.context)
+ for (item in newList) {
+ val binding = DataBindingUtil.inflate(layoutInflater, newLayoutId, view, false)
+ binding.setVariable(BR.collection, newList)
+ binding.setVariable(BR.item, item)
+ binding.executePendingBindings()
+ view.addView(binding.root)
+ }
+ }
+
+ @JvmStatic
+ @BindingAdapter(requireAll = false, value = ["items", "layout", "configurationHandler"])
+ fun > setItems(
+ view: RecyclerView,
+ oldList: ObservableKeyedArrayList?, oldLayoutId: Int,
+ @Suppress("UNUSED_PARAMETER") oldRowConfigurationHandler: RowConfigurationHandler<*, *>?,
+ newList: ObservableKeyedArrayList?, newLayoutId: Int,
+ newRowConfigurationHandler: RowConfigurationHandler<*, *>?
+ ) {
+ if (view.layoutManager == null)
+ view.layoutManager = LinearLayoutManager(view.context, RecyclerView.VERTICAL, false)
+ if (oldList === newList && oldLayoutId == newLayoutId)
+ return
+ // The ListAdapter interface is not generic, so this cannot be checked.
+ @Suppress("UNCHECKED_CAST") var adapter = view.adapter as? ObservableKeyedRecyclerViewAdapter?
+ // If the layout changes, any existing adapter must be replaced.
+ if (adapter != null && oldList != null && oldLayoutId != newLayoutId) {
+ adapter.setList(null)
+ adapter = null
+ }
+ // Avoid setting an adapter when there is no new list or layout.
+ if (newList == null || newLayoutId == 0)
+ return
+ if (adapter == null) {
+ adapter = ObservableKeyedRecyclerViewAdapter(view.context, newLayoutId, newList)
+ view.adapter = adapter
+ }
+ adapter.setRowConfigurationHandler(newRowConfigurationHandler)
+ // Either the list changed, or this is an entirely new listener because the layout changed.
+ adapter.setList(newList)
+ }
+
+ @JvmStatic
+ @BindingAdapter("onBeforeCheckedChanged")
+ fun setOnBeforeCheckedChanged(
+ view: ToggleSwitch,
+ listener: OnBeforeCheckedChangeListener?
+ ) {
+ view.setOnBeforeCheckedChangeListener(listener)
+ }
+
+ @JvmStatic
+ @BindingAdapter("onFocusChange")
+ fun setOnFocusChange(
+ view: EditText,
+ listener: View.OnFocusChangeListener?
+ ) {
+ view.onFocusChangeListener = listener
+ }
+
+ @JvmStatic
+ @BindingAdapter("android:text")
+ fun setOptionalText(view: TextView, text: Optional<*>?) {
+ view.text = text?.map { it.toString() }?.orElse("") ?: ""
+ }
+
+ @JvmStatic
+ @BindingAdapter("android:text")
+ fun setInetNetworkSetText(view: TextView, networks: Iterable?) {
+ view.text = if (networks != null) Attribute.join(networks) else ""
+ }
+
+ @JvmStatic
+ @BindingAdapter("android:text")
+ fun setInetAddressSetText(view: TextView, addresses: Iterable?) {
+ view.text = if (addresses != null) Attribute.join(addresses.map { it?.hostAddress }) else ""
+ }
+
+ @JvmStatic
+ @BindingAdapter("android:text")
+ fun setStringSetText(view: TextView, strings: Iterable?) {
+ view.text = if (strings != null) Attribute.join(strings) else ""
+ }
+
+ @JvmStatic
+ fun tryParseInt(s: String?): Int {
+ if (s == null)
+ return 0
+ return try {
+ Integer.parseInt(s)
+ } catch (_: Throwable) {
+ 0
+ }
+ }
+
+ @JvmStatic
+ @BindingAdapter("isUp")
+ fun setIsUp(card: TvCardView, up: Boolean) {
+ card.isUp = up
+ }
+
+ @JvmStatic
+ @BindingAdapter("isDeleting")
+ fun setIsDeleting(card: TvCardView, deleting: Boolean) {
+ card.isDeleting = deleting
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.kt b/ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.kt
new file mode 100644
index 0000000..84ec3ed
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.kt
@@ -0,0 +1,122 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.databinding
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.databinding.DataBindingUtil
+import androidx.databinding.ObservableList
+import androidx.databinding.ViewDataBinding
+import androidx.fragment.app.Fragment
+import com.wireguard.android.BR
+import java.lang.ref.WeakReference
+
+/**
+ * Helper class for binding an ObservableList to the children of a ViewGroup.
+ */
+internal class ItemChangeListener(private val container: ViewGroup, private val layoutId: Int, private val fragment: Fragment?) {
+ private val callback = OnListChangedCallback(this)
+ private val layoutInflater: LayoutInflater = LayoutInflater.from(container.context)
+ private var list: ObservableList? = null
+
+ private fun getView(position: Int, convertView: View?): View {
+ var binding = if (convertView != null) DataBindingUtil.getBinding(convertView) else null
+ if (binding == null) {
+ binding = DataBindingUtil.inflate(layoutInflater, layoutId, container, false)
+ }
+ require(list != null) { "Trying to get a view while list is still null" }
+ binding!!.setVariable(BR.collection, list)
+ binding.setVariable(BR.item, list!![position])
+ binding.setVariable(BR.fragment, fragment)
+ binding.executePendingBindings()
+ return binding.root
+ }
+
+ fun setList(newList: ObservableList?) {
+ list?.removeOnListChangedCallback(callback)
+ list = newList
+ if (list != null) {
+ list!!.addOnListChangedCallback(callback)
+ callback.onChanged(list!!)
+ } else {
+ container.removeAllViews()
+ }
+ }
+
+ private class OnListChangedCallback constructor(listener: ItemChangeListener) : ObservableList.OnListChangedCallback>() {
+ private val weakListener: WeakReference> = WeakReference(listener)
+
+ override fun onChanged(sender: ObservableList) {
+ val listener = weakListener.get()
+ if (listener != null) {
+ // TODO: recycle views
+ listener.container.removeAllViews()
+ for (i in sender.indices)
+ listener.container.addView(listener.getView(i, null))
+ } else {
+ sender.removeOnListChangedCallback(this)
+ }
+ }
+
+ override fun onItemRangeChanged(
+ sender: ObservableList, positionStart: Int,
+ itemCount: Int
+ ) {
+ val listener = weakListener.get()
+ if (listener != null) {
+ for (i in positionStart until positionStart + itemCount) {
+ val child = listener.container.getChildAt(i)
+ listener.container.removeViewAt(i)
+ listener.container.addView(listener.getView(i, child))
+ }
+ } else {
+ sender.removeOnListChangedCallback(this)
+ }
+ }
+
+ override fun onItemRangeInserted(
+ sender: ObservableList, positionStart: Int,
+ itemCount: Int
+ ) {
+ val listener = weakListener.get()
+ if (listener != null) {
+ for (i in positionStart until positionStart + itemCount)
+ listener.container.addView(listener.getView(i, null))
+ } else {
+ sender.removeOnListChangedCallback(this)
+ }
+ }
+
+ override fun onItemRangeMoved(
+ sender: ObservableList, fromPosition: Int,
+ toPosition: Int, itemCount: Int
+ ) {
+ val listener = weakListener.get()
+ if (listener != null) {
+ val views = arrayOfNulls(itemCount)
+ for (i in 0 until itemCount) views[i] = listener.container.getChildAt(fromPosition + i)
+ listener.container.removeViews(fromPosition, itemCount)
+ for (i in 0 until itemCount) listener.container.addView(views[i], toPosition + i)
+ } else {
+ sender.removeOnListChangedCallback(this)
+ }
+ }
+
+ override fun onItemRangeRemoved(
+ sender: ObservableList, positionStart: Int,
+ itemCount: Int
+ ) {
+ val listener = weakListener.get()
+ if (listener != null) {
+ listener.container.removeViews(positionStart, itemCount)
+ } else {
+ sender.removeOnListChangedCallback(this)
+ }
+ }
+
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/databinding/Keyed.kt b/ui/src/main/java/com/wireguard/android/databinding/Keyed.kt
new file mode 100644
index 0000000..fc4ee35
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/databinding/Keyed.kt
@@ -0,0 +1,12 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.databinding
+
+/**
+ * Interface for objects that have a identifying key of the given type.
+ */
+interface Keyed {
+ val key: K
+}
diff --git a/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedArrayList.kt b/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedArrayList.kt
new file mode 100644
index 0000000..4d6c3a2
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedArrayList.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.databinding
+
+import androidx.databinding.ObservableArrayList
+
+/**
+ * ArrayList that allows looking up elements by some key property. As the key property must always
+ * be retrievable, this list cannot hold `null` elements. Because this class places no
+ * restrictions on the order or duplication of keys, lookup by key, as well as all list modification
+ * operations, require O(n) time.
+ */
+open class ObservableKeyedArrayList> : ObservableArrayList() {
+ fun containsKey(key: K) = indexOfKey(key) >= 0
+
+ operator fun get(key: K): E? {
+ val index = indexOfKey(key)
+ return if (index >= 0) get(index) else null
+ }
+
+ open fun indexOfKey(key: K): Int {
+ val iterator = listIterator()
+ while (iterator.hasNext()) {
+ val index = iterator.nextIndex()
+ if (iterator.next()!!.key == key)
+ return index
+ }
+ return -1
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.kt b/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.kt
new file mode 100644
index 0000000..a9ef491
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.databinding
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.databinding.DataBindingUtil
+import androidx.databinding.ObservableList
+import androidx.databinding.ViewDataBinding
+import androidx.recyclerview.widget.RecyclerView
+import com.wireguard.android.BR
+import java.lang.ref.WeakReference
+
+/**
+ * A generic `RecyclerView.Adapter` backed by a `ObservableKeyedArrayList`.
+ */
+class ObservableKeyedRecyclerViewAdapter> internal constructor(
+ context: Context, private val layoutId: Int,
+ list: ObservableKeyedArrayList?
+) : RecyclerView.Adapter() {
+ private val callback = OnListChangedCallback(this)
+ private val layoutInflater: LayoutInflater = LayoutInflater.from(context)
+ private var list: ObservableKeyedArrayList? = null
+ private var rowConfigurationHandler: RowConfigurationHandler? = null
+
+ private fun getItem(position: Int): E? = if (list == null || position < 0 || position >= list!!.size) null else list?.get(position)
+
+ override fun getItemCount() = list?.size ?: 0
+
+ override fun getItemId(position: Int) = (getKey(position)?.hashCode() ?: -1).toLong()
+
+ private fun getKey(position: Int): K? = getItem(position)?.key
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ holder.binding.setVariable(BR.collection, list)
+ holder.binding.setVariable(BR.key, getKey(position))
+ holder.binding.setVariable(BR.item, getItem(position))
+ holder.binding.executePendingBindings()
+ if (rowConfigurationHandler != null) {
+ val item = getItem(position)
+ if (item != null) {
+ rowConfigurationHandler?.onConfigureRow(holder.binding, item, position)
+ }
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(DataBindingUtil.inflate(layoutInflater, layoutId, parent, false))
+
+ fun setList(newList: ObservableKeyedArrayList?) {
+ list?.removeOnListChangedCallback(callback)
+ list = newList
+ list?.addOnListChangedCallback(callback)
+ notifyDataSetChanged()
+ }
+
+ fun setRowConfigurationHandler(rowConfigurationHandler: RowConfigurationHandler<*, *>?) {
+ @Suppress("UNCHECKED_CAST")
+ this.rowConfigurationHandler = rowConfigurationHandler as? RowConfigurationHandler
+ }
+
+ interface RowConfigurationHandler {
+ fun onConfigureRow(binding: B, item: T, position: Int)
+ }
+
+ private class OnListChangedCallback> constructor(adapter: ObservableKeyedRecyclerViewAdapter<*, E>) : ObservableList.OnListChangedCallback>() {
+ private val weakAdapter: WeakReference> = WeakReference(adapter)
+
+ override fun onChanged(sender: ObservableList) {
+ val adapter = weakAdapter.get()
+ if (adapter != null)
+ adapter.notifyDataSetChanged()
+ else
+ sender.removeOnListChangedCallback(this)
+ }
+
+ override fun onItemRangeChanged(sender: ObservableList, positionStart: Int,
+ itemCount: Int) {
+ onChanged(sender)
+ }
+
+ override fun onItemRangeInserted(sender: ObservableList, positionStart: Int,
+ itemCount: Int) {
+ onChanged(sender)
+ }
+
+ override fun onItemRangeMoved(sender: ObservableList, fromPosition: Int,
+ toPosition: Int, itemCount: Int) {
+ onChanged(sender)
+ }
+
+ override fun onItemRangeRemoved(sender: ObservableList, positionStart: Int,
+ itemCount: Int) {
+ onChanged(sender)
+ }
+
+ }
+
+ class ViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root)
+
+ init {
+ setList(list)
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/databinding/ObservableSortedKeyedArrayList.kt b/ui/src/main/java/com/wireguard/android/databinding/ObservableSortedKeyedArrayList.kt
new file mode 100644
index 0000000..d6c039f
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/databinding/ObservableSortedKeyedArrayList.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.databinding
+
+import java.util.AbstractList
+import java.util.Collections
+import java.util.Comparator
+import java.util.Spliterator
+
+/**
+ * KeyedArrayList that enforces uniqueness and sorted order across the set of keys. This class uses
+ * binary search to improve lookup and replacement times to O(log(n)). However, due to the
+ * array-based nature of this class, insertion and removal of elements with anything but the largest
+ * key still require O(n) time.
+ */
+class ObservableSortedKeyedArrayList>(private val comparator: Comparator) : ObservableKeyedArrayList() {
+ @Transient
+ private val keyList = KeyList(this)
+
+ override fun add(element: E): Boolean {
+ val insertionPoint = getInsertionPoint(element)
+ if (insertionPoint < 0) {
+ // Skipping insertion is non-destructive if the new and existing objects are the same.
+ if (element === get(-insertionPoint - 1)) return false
+ throw IllegalArgumentException("Element with same key already exists in list")
+ }
+ super.add(insertionPoint, element)
+ return true
+ }
+
+ override fun add(index: Int, element: E) {
+ val insertionPoint = getInsertionPoint(element)
+ require(insertionPoint >= 0) { "Element with same key already exists in list" }
+ if (insertionPoint != index) throw IndexOutOfBoundsException("Wrong index given for element")
+ super.add(index, element)
+ }
+
+ override fun addAll(elements: Collection): Boolean {
+ var didChange = false
+ for (e in elements) {
+ if (add(e))
+ didChange = true
+ }
+ return didChange
+ }
+
+ override fun addAll(index: Int, elements: Collection): Boolean {
+ var i = index
+ for (e in elements)
+ add(i++, e)
+ return true
+ }
+
+ private fun getInsertionPoint(e: E) = -Collections.binarySearch(keyList, e.key, comparator) - 1
+
+ override fun indexOfKey(key: K): Int {
+ val index = Collections.binarySearch(keyList, key, comparator)
+ return if (index >= 0) index else -1
+ }
+
+ override fun set(index: Int, element: E): E {
+ val order = comparator.compare(element.key, get(index).key)
+ if (order != 0) {
+ // Allow replacement if the new key would be inserted adjacent to the replaced element.
+ val insertionPoint = getInsertionPoint(element)
+ if (insertionPoint < index || insertionPoint > index + 1)
+ throw IndexOutOfBoundsException("Wrong index given for element")
+ }
+ return super.set(index, element)
+ }
+
+ private class KeyList>(private val list: ObservableSortedKeyedArrayList) : AbstractList(), Set {
+ override fun get(index: Int): K = list[index].key
+
+ override val size
+ get() = list.size
+
+ override fun spliterator(): Spliterator = super.spliterator()
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt b/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt
new file mode 100644
index 0000000..f077cba
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.fragment
+
+import android.content.pm.PackageManager
+import android.graphics.drawable.GradientDrawable
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewTreeObserver
+import android.widget.FrameLayout
+import androidx.core.os.bundleOf
+import androidx.fragment.app.setFragmentResult
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.wireguard.android.R
+import com.wireguard.android.util.resolveAttribute
+
+class AddTunnelsSheet : BottomSheetDialogFragment() {
+
+ private var behavior: BottomSheetBehavior? = null
+ private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
+ override fun onSlide(bottomSheet: View, slideOffset: Float) {
+ }
+
+ override fun onStateChanged(bottomSheet: View, newState: Int) {
+ if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
+ dismiss()
+ }
+ }
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ if (savedInstanceState != null) dismiss()
+ val view = inflater.inflate(R.layout.add_tunnels_bottom_sheet, container, false)
+ if (activity?.packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) != true) {
+ val qrcode = view.findViewById(R.id.create_from_qrcode)
+ qrcode.isEnabled = false
+ qrcode.visibility = View.GONE
+ }
+ return view
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
+ override fun onGlobalLayout() {
+ view.viewTreeObserver.removeOnGlobalLayoutListener(this)
+ val dialog = dialog as BottomSheetDialog? ?: return
+ behavior = dialog.behavior
+ behavior?.apply {
+ state = BottomSheetBehavior.STATE_EXPANDED
+ peekHeight = 0
+ addBottomSheetCallback(bottomSheetCallback)
+ }
+ dialog.findViewById(R.id.create_empty)?.setOnClickListener {
+ dismiss()
+ onRequestCreateConfig()
+ }
+ dialog.findViewById(R.id.create_from_file)?.setOnClickListener {
+ dismiss()
+ onRequestImportConfig()
+ }
+ dialog.findViewById(R.id.create_from_qrcode)?.setOnClickListener {
+ dismiss()
+ onRequestScanQRCode()
+ }
+ }
+ })
+ val gradientDrawable = GradientDrawable().apply {
+ setColor(requireContext().resolveAttribute(com.google.android.material.R.attr.colorSurface))
+ }
+ view.background = gradientDrawable
+ }
+
+ override fun dismiss() {
+ super.dismiss()
+ behavior?.removeBottomSheetCallback(bottomSheetCallback)
+ }
+
+ private fun onRequestCreateConfig() {
+ setFragmentResult(REQUEST_KEY_NEW_TUNNEL, bundleOf(REQUEST_METHOD to REQUEST_CREATE))
+ }
+
+ private fun onRequestImportConfig() {
+ setFragmentResult(REQUEST_KEY_NEW_TUNNEL, bundleOf(REQUEST_METHOD to REQUEST_IMPORT))
+ }
+
+ private fun onRequestScanQRCode() {
+ setFragmentResult(REQUEST_KEY_NEW_TUNNEL, bundleOf(REQUEST_METHOD to REQUEST_SCAN))
+ }
+
+ companion object {
+ const val REQUEST_KEY_NEW_TUNNEL = "request_new_tunnel"
+ const val REQUEST_METHOD = "request_method"
+ const val REQUEST_CREATE = "request_create"
+ const val REQUEST_IMPORT = "request_import"
+ const val REQUEST_SCAN = "request_scan"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.kt
new file mode 100644
index 0000000..692dd80
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.kt
@@ -0,0 +1,170 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.fragment
+
+import android.Manifest
+import android.app.Dialog
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.content.pm.PackageManager.PackageInfoFlags
+import android.os.Build
+import android.os.Bundle
+import android.widget.Button
+import android.widget.Toast
+import androidx.appcompat.app.AlertDialog
+import androidx.core.os.bundleOf
+import androidx.databinding.Observable
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.setFragmentResult
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.tabs.TabLayout
+import com.wireguard.android.BR
+import com.wireguard.android.R
+import com.wireguard.android.databinding.AppListDialogFragmentBinding
+import com.wireguard.android.databinding.ObservableKeyedArrayList
+import com.wireguard.android.model.ApplicationData
+import com.wireguard.android.util.ErrorMessages
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class AppListDialogFragment : DialogFragment() {
+ private val appData = ObservableKeyedArrayList()
+ private var currentlySelectedApps = emptyList()
+ private var initiallyExcluded = false
+ private var button: Button? = null
+ private var tabs: TabLayout? = null
+
+ private fun loadData() {
+ val activity = activity ?: return
+ val pm = activity.packageManager
+ lifecycleScope.launch(Dispatchers.Default) {
+ try {
+ val applicationData: MutableList = ArrayList()
+ withContext(Dispatchers.IO) {
+ val packageInfos = getPackagesHoldingPermissions(pm, arrayOf(Manifest.permission.INTERNET))
+ packageInfos.forEach {
+ val packageName = it.packageName
+ val appInfo = it.applicationInfo ?: return@forEach
+ val appData =
+ ApplicationData(appInfo.loadIcon(pm), appInfo.loadLabel(pm).toString(), packageName, currentlySelectedApps.contains(packageName))
+ applicationData.add(appData)
+ appData.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
+ override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
+ if (propertyId == BR.selected)
+ setButtonText()
+ }
+ })
+ }
+ }
+ applicationData.sortWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
+ withContext(Dispatchers.Main.immediate) {
+ appData.clear()
+ appData.addAll(applicationData)
+ setButtonText()
+ }
+ } catch (e: Throwable) {
+ withContext(Dispatchers.Main.immediate) {
+ val error = ErrorMessages[e]
+ val message = activity.getString(R.string.error_fetching_apps, error)
+ Toast.makeText(activity, message, Toast.LENGTH_LONG).show()
+ dismissAllowingStateLoss()
+ }
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ currentlySelectedApps = (arguments?.getStringArrayList(KEY_SELECTED_APPS) ?: emptyList())
+ initiallyExcluded = arguments?.getBoolean(KEY_IS_EXCLUDED) ?: true
+ }
+
+ private fun getPackagesHoldingPermissions(pm: PackageManager, permissions: Array): List {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ pm.getPackagesHoldingPermissions(permissions, PackageInfoFlags.of(0L))
+ } else {
+ @Suppress("DEPRECATION")
+ pm.getPackagesHoldingPermissions(permissions, 0)
+ }
+ }
+
+ private fun setButtonText() {
+ val numSelected = appData.count { it.isSelected }
+ button?.text = if (numSelected == 0)
+ getString(R.string.use_all_applications)
+ else when (tabs?.selectedTabPosition) {
+ 0 -> resources.getQuantityString(R.plurals.exclude_n_applications, numSelected, numSelected)
+ 1 -> resources.getQuantityString(R.plurals.include_n_applications, numSelected, numSelected)
+ else -> null
+ }
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val alertDialogBuilder = MaterialAlertDialogBuilder(requireActivity())
+ val binding = AppListDialogFragmentBinding.inflate(requireActivity().layoutInflater, null, false)
+ binding.executePendingBindings()
+ alertDialogBuilder.setView(binding.root)
+ tabs = binding.tabs
+ tabs?.apply {
+ selectTab(binding.tabs.getTabAt(if (initiallyExcluded) 0 else 1))
+ addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
+ override fun onTabReselected(tab: TabLayout.Tab?) = Unit
+ override fun onTabUnselected(tab: TabLayout.Tab?) = Unit
+ override fun onTabSelected(tab: TabLayout.Tab?) = setButtonText()
+ })
+ }
+ alertDialogBuilder.setPositiveButton(" ") { _, _ -> setSelectionAndDismiss() }
+ alertDialogBuilder.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() }
+ alertDialogBuilder.setNeutralButton(R.string.toggle_all) { _, _ -> }
+ binding.fragment = this
+ binding.appData = appData
+ loadData()
+ val dialog = alertDialogBuilder.create()
+ dialog.setOnShowListener {
+ button = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
+ setButtonText()
+ dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { _ ->
+ val selectAll = appData.none { it.isSelected }
+ appData.forEach {
+ it.isSelected = selectAll
+ }
+ }
+ }
+ return dialog
+ }
+
+ private fun setSelectionAndDismiss() {
+ val selectedApps: MutableList = ArrayList()
+ for (data in appData) {
+ if (data.isSelected) {
+ selectedApps.add(data.packageName)
+ }
+ }
+ setFragmentResult(
+ REQUEST_SELECTION, bundleOf(
+ KEY_SELECTED_APPS to selectedApps.toTypedArray(),
+ KEY_IS_EXCLUDED to (tabs?.selectedTabPosition == 0)
+ )
+ )
+ dismiss()
+ }
+
+ companion object {
+ const val KEY_SELECTED_APPS = "selected_apps"
+ const val KEY_IS_EXCLUDED = "is_excluded"
+ const val REQUEST_SELECTION = "request_selection"
+
+ fun newInstance(selectedApps: ArrayList?, isExcluded: Boolean): AppListDialogFragment {
+ val extras = Bundle()
+ extras.putStringArrayList(KEY_SELECTED_APPS, selectedApps)
+ extras.putBoolean(KEY_IS_EXCLUDED, isExcluded)
+ val fragment = AppListDialogFragment()
+ fragment.arguments = extras
+ return fragment
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt
new file mode 100644
index 0000000..2e551f8
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt
@@ -0,0 +1,114 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.fragment
+
+import android.content.Context
+import android.util.Log
+import android.view.View
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.databinding.DataBindingUtil
+import androidx.databinding.ViewDataBinding
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.snackbar.Snackbar
+import com.wireguard.android.Application
+import com.wireguard.android.R
+import com.wireguard.android.activity.BaseActivity
+import com.wireguard.android.activity.BaseActivity.OnSelectedTunnelChangedListener
+import com.wireguard.android.backend.GoBackend
+import com.wireguard.android.backend.Tunnel
+import com.wireguard.android.databinding.TunnelDetailFragmentBinding
+import com.wireguard.android.databinding.TunnelListItemBinding
+import com.wireguard.android.model.ObservableTunnel
+import com.wireguard.android.util.ErrorMessages
+import kotlinx.coroutines.launch
+
+/**
+ * Base class for fragments that need to know the currently-selected tunnel. Only does anything when
+ * attached to a `BaseActivity`.
+ */
+abstract class BaseFragment : Fragment(), OnSelectedTunnelChangedListener {
+ private var pendingTunnel: ObservableTunnel? = null
+ private var pendingTunnelUp: Boolean? = null
+ private val permissionActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ val tunnel = pendingTunnel
+ val checked = pendingTunnelUp
+ if (tunnel != null && checked != null)
+ setTunnelStateWithPermissionsResult(tunnel, checked)
+ pendingTunnel = null
+ pendingTunnelUp = null
+ }
+
+ protected var selectedTunnel: ObservableTunnel?
+ get() = (activity as? BaseActivity)?.selectedTunnel
+ protected set(tunnel) {
+ (activity as? BaseActivity)?.selectedTunnel = tunnel
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ (activity as? BaseActivity)?.addOnSelectedTunnelChangedListener(this)
+ }
+
+ override fun onDetach() {
+ (activity as? BaseActivity)?.removeOnSelectedTunnelChangedListener(this)
+ super.onDetach()
+ }
+
+ fun setTunnelState(view: View, checked: Boolean) {
+ val tunnel = when (val binding = DataBindingUtil.findBinding(view)) {
+ is TunnelDetailFragmentBinding -> binding.tunnel
+ is TunnelListItemBinding -> binding.item
+ else -> return
+ } ?: return
+ val activity = activity ?: return
+ activity.lifecycleScope.launch {
+ if (Application.getBackend() is GoBackend) {
+ try {
+ val intent = GoBackend.VpnService.prepare(activity)
+ if (intent != null) {
+ pendingTunnel = tunnel
+ pendingTunnelUp = checked
+ permissionActivityResultLauncher.launch(intent)
+ return@launch
+ }
+ } catch (e: Throwable) {
+ val message = activity.getString(R.string.error_prepare, ErrorMessages[e])
+ Snackbar.make(view, message, Snackbar.LENGTH_LONG)
+ .setAnchorView(view.findViewById(R.id.create_fab))
+ .show()
+ Log.e(TAG, message, e)
+ }
+ }
+ setTunnelStateWithPermissionsResult(tunnel, checked)
+ }
+ }
+
+ private fun setTunnelStateWithPermissionsResult(tunnel: ObservableTunnel, checked: Boolean) {
+ val activity = activity ?: return
+ activity.lifecycleScope.launch {
+ try {
+ tunnel.setStateAsync(Tunnel.State.of(checked))
+ } catch (e: Throwable) {
+ val error = ErrorMessages[e]
+ val messageResId = if (checked) R.string.error_up else R.string.error_down
+ val message = activity.getString(messageResId, error)
+ val view = view
+ if (view != null)
+ Snackbar.make(view, message, Snackbar.LENGTH_LONG)
+ .setAnchorView(view.findViewById(R.id.create_fab))
+ .show()
+ else
+ Toast.makeText(activity, message, Toast.LENGTH_LONG).show()
+ Log.e(TAG, message, e)
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "WireGuard/BaseFragment"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.kt
new file mode 100644
index 0000000..23da3fc
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.fragment
+
+import android.app.Dialog
+import android.os.Bundle
+import android.view.WindowManager
+import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.wireguard.android.Application
+import com.wireguard.android.R
+import com.wireguard.android.databinding.ConfigNamingDialogFragmentBinding
+import com.wireguard.config.BadConfigException
+import com.wireguard.config.Config
+import kotlinx.coroutines.launch
+import java.io.ByteArrayInputStream
+import java.io.IOException
+import java.nio.charset.StandardCharsets
+
+class ConfigNamingDialogFragment : DialogFragment() {
+ private var binding: ConfigNamingDialogFragmentBinding? = null
+ private var config: Config? = null
+
+ private fun createTunnelAndDismiss() {
+ val binding = binding ?: return
+ val activity = activity ?: return
+ val name = binding.tunnelNameText.text.toString()
+ activity.lifecycleScope.launch {
+ try {
+ Application.getTunnelManager().create(name, config)
+ dismiss()
+ } catch (e: Throwable) {
+ binding.tunnelNameTextLayout.error = e.message
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val configText = requireArguments().getString(KEY_CONFIG_TEXT)
+ val configBytes = configText!!.toByteArray(StandardCharsets.UTF_8)
+ config = try {
+ Config.parse(ByteArrayInputStream(configBytes))
+ } catch (e: Throwable) {
+ when (e) {
+ is BadConfigException, is IOException -> throw IllegalArgumentException("Invalid config passed to ${javaClass.simpleName}", e)
+ else -> throw e
+ }
+ }
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val activity = requireActivity()
+ val alertDialogBuilder = MaterialAlertDialogBuilder(activity)
+ alertDialogBuilder.setTitle(R.string.import_from_qr_code)
+ binding = ConfigNamingDialogFragmentBinding.inflate(activity.layoutInflater, null, false)
+ binding?.apply {
+ executePendingBindings()
+ alertDialogBuilder.setView(root)
+ }
+ alertDialogBuilder.setPositiveButton(R.string.create_tunnel) { _, _ -> createTunnelAndDismiss() }
+ alertDialogBuilder.setNegativeButton(R.string.cancel) { _, _ -> dismiss() }
+ val dialog = alertDialogBuilder.create()
+ dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
+ return dialog
+ }
+
+ companion object {
+ private const val KEY_CONFIG_TEXT = "config_text"
+
+ fun newInstance(configText: String?): ConfigNamingDialogFragment {
+ val extras = Bundle()
+ extras.putString(KEY_CONFIG_TEXT, configText)
+ val fragment = ConfigNamingDialogFragment()
+ fragment.arguments = extras
+ return fragment
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt
new file mode 100644
index 0000000..7731391
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.fragment
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.MenuProvider
+import androidx.databinding.DataBindingUtil
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import com.wireguard.android.R
+import com.wireguard.android.backend.Tunnel
+import com.wireguard.android.databinding.TunnelDetailFragmentBinding
+import com.wireguard.android.databinding.TunnelDetailPeerBinding
+import com.wireguard.android.model.ObservableTunnel
+import com.wireguard.android.util.QuantityFormatter
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+/**
+ * Fragment that shows details about a specific tunnel.
+ */
+class TunnelDetailFragment : BaseFragment(), MenuProvider {
+ private var binding: TunnelDetailFragmentBinding? = null
+ private var lastState = Tunnel.State.TOGGLE
+ private var timerActive = true
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return false
+ }
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.tunnel_detail, menu)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ super.onCreateView(inflater, container, savedInstanceState)
+ binding = TunnelDetailFragmentBinding.inflate(inflater, container, false)
+ binding?.executePendingBindings()
+ return binding?.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
+ }
+
+ override fun onDestroyView() {
+ binding = null
+ super.onDestroyView()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ timerActive = true
+ lifecycleScope.launch {
+ while (timerActive) {
+ updateStats()
+ delay(1000)
+ }
+ }
+ }
+
+ override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?) {
+ val binding = binding ?: return
+ binding.tunnel = newTunnel
+ if (newTunnel == null) {
+ binding.config = null
+ } else {
+ lifecycleScope.launch {
+ try {
+ binding.config = newTunnel.getConfigAsync()
+ } catch (_: Throwable) {
+ binding.config = null
+ }
+ }
+ }
+ lastState = Tunnel.State.TOGGLE
+ lifecycleScope.launch { updateStats() }
+ }
+
+ override fun onStop() {
+ timerActive = false
+ super.onStop()
+ }
+
+ override fun onViewStateRestored(savedInstanceState: Bundle?) {
+ binding ?: return
+ binding!!.fragment = this
+ onSelectedTunnelChanged(null, selectedTunnel)
+ super.onViewStateRestored(savedInstanceState)
+ }
+
+ private suspend fun updateStats() {
+ val binding = binding ?: return
+ val tunnel = binding.tunnel ?: return
+ if (!isResumed) return
+ val state = tunnel.state
+ if (state != Tunnel.State.UP && lastState == state) return
+ lastState = state
+ try {
+ val statistics = tunnel.getStatisticsAsync()
+ for (i in 0 until binding.peersLayout.childCount) {
+ val peer: TunnelDetailPeerBinding = DataBindingUtil.getBinding(binding.peersLayout.getChildAt(i))
+ ?: continue
+ val publicKey = peer.item!!.publicKey
+ val peerStats = statistics.peer(publicKey)
+ if (peerStats == null || (peerStats.rxBytes == 0L && peerStats.txBytes == 0L)) {
+ peer.transferLabel.visibility = View.GONE
+ peer.transferText.visibility = View.GONE
+ } else {
+ peer.transferText.text = getString(
+ R.string.transfer_rx_tx,
+ QuantityFormatter.formatBytes(peerStats.rxBytes),
+ QuantityFormatter.formatBytes(peerStats.txBytes)
+ )
+ peer.transferLabel.visibility = View.VISIBLE
+ peer.transferText.visibility = View.VISIBLE
+ }
+ if (peerStats == null || peerStats.latestHandshakeEpochMillis == 0L) {
+ peer.latestHandshakeLabel.visibility = View.GONE
+ peer.latestHandshakeText.visibility = View.GONE
+ } else {
+ peer.latestHandshakeText.text = QuantityFormatter.formatEpochAgo(peerStats.latestHandshakeEpochMillis)
+ peer.latestHandshakeLabel.visibility = View.VISIBLE
+ peer.latestHandshakeText.visibility = View.VISIBLE
+ }
+ }
+ } catch (e: Throwable) {
+ for (i in 0 until binding.peersLayout.childCount) {
+ val peer: TunnelDetailPeerBinding = DataBindingUtil.getBinding(binding.peersLayout.getChildAt(i))
+ ?: continue
+ peer.transferLabel.visibility = View.GONE
+ peer.transferText.visibility = View.GONE
+ peer.latestHandshakeLabel.visibility = View.GONE
+ peer.latestHandshakeText.visibility = View.GONE
+ }
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt
new file mode 100644
index 0000000..f5d28ad
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt
@@ -0,0 +1,333 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.fragment
+
+import android.content.Context
+import android.os.Bundle
+import android.text.InputType
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager
+import android.view.inputmethod.InputMethodManager
+import android.widget.EditText
+import android.widget.Toast
+import androidx.core.os.BundleCompat
+import androidx.core.view.MenuProvider
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.snackbar.Snackbar
+import com.wireguard.android.Application
+import com.wireguard.android.R
+import com.wireguard.android.backend.Tunnel
+import com.wireguard.android.databinding.TunnelEditorFragmentBinding
+import com.wireguard.android.model.ObservableTunnel
+import com.wireguard.android.util.AdminKnobs
+import com.wireguard.android.util.BiometricAuthenticator
+import com.wireguard.android.util.ErrorMessages
+import com.wireguard.android.viewmodel.ConfigProxy
+import com.wireguard.config.Config
+import kotlinx.coroutines.launch
+
+/**
+ * Fragment for editing a WireGuard configuration.
+ */
+class TunnelEditorFragment : BaseFragment(), MenuProvider {
+ private var haveShownKeys = false
+ private var binding: TunnelEditorFragmentBinding? = null
+ private var tunnel: ObservableTunnel? = null
+
+ private fun onConfigLoaded(config: Config) {
+ binding?.config = ConfigProxy(config)
+ }
+
+ private fun onConfigSaved(savedTunnel: Tunnel, throwable: Throwable?) {
+ val ctx = activity ?: Application.get()
+ if (throwable == null) {
+ val message = ctx.getString(R.string.config_save_success, savedTunnel.name)
+ Log.d(TAG, message)
+ Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show()
+ onFinished()
+ } else {
+ val error = ErrorMessages[throwable]
+ val message = ctx.getString(R.string.config_save_error, savedTunnel.name, error)
+ Log.e(TAG, message, throwable)
+ val binding = binding
+ if (binding != null)
+ Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show()
+ else
+ Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.config_editor, menu)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ super.onCreateView(inflater, container, savedInstanceState)
+ binding = TunnelEditorFragmentBinding.inflate(inflater, container, false)
+ binding?.apply {
+ executePendingBindings()
+ privateKeyTextLayout.setEndIconOnClickListener { config?.`interface`?.generateKeyPair() }
+ }
+ return binding?.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
+ }
+
+ override fun onDestroyView() {
+ activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ binding = null
+ super.onDestroyView()
+ }
+
+ private fun onFinished() {
+ // Hide the keyboard; it rarely goes away on its own.
+ val activity = activity ?: return
+ val focusedView = activity.currentFocus
+ if (focusedView != null) {
+ val inputManager = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
+ inputManager?.hideSoftInputFromWindow(
+ focusedView.windowToken,
+ InputMethodManager.HIDE_NOT_ALWAYS
+ )
+ }
+ parentFragmentManager.popBackStackImmediate()
+
+ // If we just made a new one, save it to select the details page.
+ if (selectedTunnel != tunnel)
+ selectedTunnel = tunnel
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ if (menuItem.itemId == R.id.menu_action_save) {
+ binding ?: return false
+ val newConfig = try {
+ binding!!.config!!.resolve()
+ } catch (e: Throwable) {
+ val error = ErrorMessages[e]
+ val tunnelName = if (tunnel == null) binding!!.name else tunnel!!.name
+ val message = getString(R.string.config_save_error, tunnelName, error)
+ Log.e(TAG, message, e)
+ Snackbar.make(binding!!.mainContainer, error, Snackbar.LENGTH_LONG).show()
+ return false
+ }
+ val activity = requireActivity()
+ activity.lifecycleScope.launch {
+ when {
+ tunnel == null -> {
+ Log.d(TAG, "Attempting to create new tunnel " + binding!!.name)
+ val manager = Application.getTunnelManager()
+ try {
+ onTunnelCreated(manager.create(binding!!.name!!, newConfig), null)
+ } catch (e: Throwable) {
+ onTunnelCreated(null, e)
+ }
+ }
+
+ tunnel!!.name != binding!!.name -> {
+ Log.d(TAG, "Attempting to rename tunnel to " + binding!!.name)
+ try {
+ tunnel!!.setNameAsync(binding!!.name!!)
+ onTunnelRenamed(tunnel!!, newConfig, null)
+ } catch (e: Throwable) {
+ onTunnelRenamed(tunnel!!, newConfig, e)
+ }
+ }
+
+ else -> {
+ Log.d(TAG, "Attempting to save config of " + tunnel!!.name)
+ try {
+ tunnel!!.setConfigAsync(newConfig)
+ onConfigSaved(tunnel!!, null)
+ } catch (e: Throwable) {
+ onConfigSaved(tunnel!!, e)
+ }
+ }
+ }
+ }
+ return true
+ }
+ return false
+ }
+
+ @Suppress("UNUSED_PARAMETER")
+ fun onRequestSetExcludedIncludedApplications(view: View?) {
+ if (binding != null) {
+ var isExcluded = true
+ var selectedApps = ArrayList(binding!!.config!!.`interface`.excludedApplications)
+ if (selectedApps.isEmpty()) {
+ selectedApps = ArrayList(binding!!.config!!.`interface`.includedApplications)
+ if (selectedApps.isNotEmpty())
+ isExcluded = false
+ }
+ val fragment = AppListDialogFragment.newInstance(selectedApps, isExcluded)
+ childFragmentManager.setFragmentResultListener(AppListDialogFragment.REQUEST_SELECTION, viewLifecycleOwner) { _, bundle ->
+ requireNotNull(binding) { "Tried to set excluded/included apps while no view was loaded" }
+ val newSelections = requireNotNull(bundle.getStringArray(AppListDialogFragment.KEY_SELECTED_APPS))
+ val excluded = requireNotNull(bundle.getBoolean(AppListDialogFragment.KEY_IS_EXCLUDED))
+ if (excluded) {
+ binding!!.config!!.`interface`.includedApplications.clear()
+ binding!!.config!!.`interface`.excludedApplications.apply {
+ clear()
+ addAll(newSelections)
+ }
+ } else {
+ binding!!.config!!.`interface`.excludedApplications.clear()
+ binding!!.config!!.`interface`.includedApplications.apply {
+ clear()
+ addAll(newSelections)
+ }
+ }
+ }
+ fragment.show(childFragmentManager, null)
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ if (binding != null) outState.putParcelable(KEY_LOCAL_CONFIG, binding!!.config)
+ outState.putString(KEY_ORIGINAL_NAME, if (tunnel == null) null else tunnel!!.name)
+ super.onSaveInstanceState(outState)
+ }
+
+ override fun onSelectedTunnelChanged(
+ oldTunnel: ObservableTunnel?,
+ newTunnel: ObservableTunnel?
+ ) {
+ tunnel = newTunnel
+ if (binding == null) return
+ binding!!.config = ConfigProxy()
+ if (tunnel != null) {
+ binding!!.name = tunnel!!.name
+ lifecycleScope.launch {
+ try {
+ onConfigLoaded(tunnel!!.getConfigAsync())
+ } catch (_: Throwable) {
+ }
+ }
+ } else {
+ binding!!.name = ""
+ }
+ }
+
+ private fun onTunnelCreated(newTunnel: ObservableTunnel?, throwable: Throwable?) {
+ val ctx = activity ?: Application.get()
+ if (throwable == null) {
+ tunnel = newTunnel
+ val message = ctx.getString(R.string.tunnel_create_success, tunnel!!.name)
+ Log.d(TAG, message)
+ Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show()
+ onFinished()
+ } else {
+ val error = ErrorMessages[throwable]
+ val message = ctx.getString(R.string.tunnel_create_error, error)
+ Log.e(TAG, message, throwable)
+ val binding = binding
+ if (binding != null)
+ Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show()
+ else
+ Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private suspend fun onTunnelRenamed(
+ renamedTunnel: ObservableTunnel, newConfig: Config,
+ throwable: Throwable?
+ ) {
+ val ctx = activity ?: Application.get()
+ if (throwable == null) {
+ val message = ctx.getString(R.string.tunnel_rename_success, renamedTunnel.name)
+ Log.d(TAG, message)
+ // Now save the rest of configuration changes.
+ Log.d(TAG, "Attempting to save config of renamed tunnel " + tunnel!!.name)
+ try {
+ renamedTunnel.setConfigAsync(newConfig)
+ onConfigSaved(renamedTunnel, null)
+ } catch (e: Throwable) {
+ onConfigSaved(renamedTunnel, e)
+ }
+ } else {
+ val error = ErrorMessages[throwable]
+ val message = ctx.getString(R.string.tunnel_rename_error, error)
+ Log.e(TAG, message, throwable)
+ val binding = binding
+ if (binding != null)
+ Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show()
+ else
+ Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ override fun onViewStateRestored(savedInstanceState: Bundle?) {
+ binding ?: return
+ binding!!.fragment = this
+ if (savedInstanceState == null) {
+ onSelectedTunnelChanged(null, selectedTunnel)
+ } else {
+ tunnel = selectedTunnel
+ val config = BundleCompat.getParcelable(savedInstanceState, KEY_LOCAL_CONFIG, ConfigProxy::class.java)!!
+ val originalName = savedInstanceState.getString(KEY_ORIGINAL_NAME)
+ if (tunnel != null && tunnel!!.name != originalName) onSelectedTunnelChanged(null, tunnel) else binding!!.config = config
+ }
+ super.onViewStateRestored(savedInstanceState)
+ }
+
+ private var showingAuthenticator = false
+
+ fun onKeyClick(view: View) = onKeyFocusChange(view, true)
+
+ fun onKeyFocusChange(view: View, isFocused: Boolean) {
+ if (!isFocused || showingAuthenticator) return
+ val edit = view as? EditText ?: return
+ if (edit.inputType == InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD) return
+ if (!haveShownKeys && edit.text.isNotEmpty()) {
+ if (AdminKnobs.disableConfigExport) return
+ showingAuthenticator = true
+ BiometricAuthenticator.authenticate(R.string.biometric_prompt_private_key_title, this) {
+ showingAuthenticator = false
+ when (it) {
+ is BiometricAuthenticator.Result.Success, is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> {
+ haveShownKeys = true
+ showPrivateKey(edit)
+ }
+
+ is BiometricAuthenticator.Result.Failure -> {
+ Snackbar.make(
+ binding!!.mainContainer,
+ it.message,
+ Snackbar.LENGTH_SHORT
+ ).show()
+ }
+
+ is BiometricAuthenticator.Result.Cancelled -> {}
+ }
+ }
+ } else {
+ showPrivateKey(edit)
+ }
+ }
+
+ private fun showPrivateKey(edit: EditText) {
+ activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ edit.inputType = InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
+ }
+
+ companion object {
+ private const val KEY_LOCAL_CONFIG = "local_config"
+ private const val KEY_ORIGINAL_NAME = "original_name"
+ private const val TAG = "WireGuard/TunnelEditorFragment"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt
new file mode 100644
index 0000000..119b6af
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt
@@ -0,0 +1,342 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.fragment
+
+import android.content.Intent
+import android.content.res.Resources
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.view.animation.Animation
+import android.view.animation.AnimationUtils
+import android.widget.Toast
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.addCallback
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.view.ActionMode
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.snackbar.Snackbar
+import com.google.zxing.qrcode.QRCodeReader
+import com.journeyapps.barcodescanner.ScanContract
+import com.journeyapps.barcodescanner.ScanOptions
+import com.wireguard.android.Application
+import com.wireguard.android.R
+import com.wireguard.android.activity.TunnelCreatorActivity
+import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler
+import com.wireguard.android.databinding.TunnelListFragmentBinding
+import com.wireguard.android.databinding.TunnelListItemBinding
+import com.wireguard.android.model.ObservableTunnel
+import com.wireguard.android.updater.SnackbarUpdateShower
+import com.wireguard.android.util.ErrorMessages
+import com.wireguard.android.util.QrCodeFromFileScanner
+import com.wireguard.android.util.TunnelImporter
+import com.wireguard.android.widget.MultiselectableRelativeLayout
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.launch
+
+/**
+ * Fragment containing a list of known WireGuard tunnels. It allows creating and deleting tunnels.
+ */
+class TunnelListFragment : BaseFragment() {
+ private val actionModeListener = ActionModeListener()
+ private var actionMode: ActionMode? = null
+ private var backPressedCallback: OnBackPressedCallback? = null
+ private var binding: TunnelListFragmentBinding? = null
+ private val tunnelFileImportResultLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { data ->
+ if (data == null) return@registerForActivityResult
+ val activity = activity ?: return@registerForActivityResult
+ val contentResolver = activity.contentResolver ?: return@registerForActivityResult
+ activity.lifecycleScope.launch {
+ if (QrCodeFromFileScanner.validContentType(contentResolver, data)) {
+ try {
+ val qrCodeFromFileScanner = QrCodeFromFileScanner(contentResolver, QRCodeReader())
+ val result = qrCodeFromFileScanner.scan(data)
+ TunnelImporter.importTunnel(parentFragmentManager, result.text) { showSnackbar(it) }
+ } catch (e: Exception) {
+ val error = ErrorMessages[e]
+ val message = Application.get().resources.getString(R.string.import_error, error)
+ Log.e(TAG, message, e)
+ showSnackbar(message)
+ }
+ } else {
+ TunnelImporter.importTunnel(contentResolver, data) { showSnackbar(it) }
+ }
+ }
+ }
+
+ private val qrImportResultLauncher = registerForActivityResult(ScanContract()) { result ->
+ val qrCode = result.contents
+ val activity = activity
+ if (qrCode != null && activity != null) {
+ activity.lifecycleScope.launch { TunnelImporter.importTunnel(parentFragmentManager, qrCode) { showSnackbar(it) } }
+ }
+ }
+
+ private val snackbarUpdateShower = SnackbarUpdateShower(this)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ if (savedInstanceState != null) {
+ val checkedItems = savedInstanceState.getIntegerArrayList(CHECKED_ITEMS)
+ if (checkedItems != null) {
+ for (i in checkedItems) actionModeListener.setItemChecked(i, true)
+ }
+ }
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ super.onCreateView(inflater, container, savedInstanceState)
+ binding = TunnelListFragmentBinding.inflate(inflater, container, false)
+ val bottomSheet = AddTunnelsSheet()
+ binding?.apply {
+ createFab.setOnClickListener {
+ if (childFragmentManager.findFragmentByTag("BOTTOM_SHEET") != null)
+ return@setOnClickListener
+ childFragmentManager.setFragmentResultListener(AddTunnelsSheet.REQUEST_KEY_NEW_TUNNEL, viewLifecycleOwner) { _, bundle ->
+ when (bundle.getString(AddTunnelsSheet.REQUEST_METHOD)) {
+ AddTunnelsSheet.REQUEST_CREATE -> {
+ startActivity(Intent(requireActivity(), TunnelCreatorActivity::class.java))
+ }
+
+ AddTunnelsSheet.REQUEST_IMPORT -> {
+ tunnelFileImportResultLauncher.launch("*/*")
+ }
+
+ AddTunnelsSheet.REQUEST_SCAN -> {
+ qrImportResultLauncher.launch(
+ ScanOptions()
+ .setOrientationLocked(false)
+ .setBeepEnabled(false)
+ .setPrompt(getString(R.string.qr_code_hint))
+ )
+ }
+ }
+ }
+ bottomSheet.showNow(childFragmentManager, "BOTTOM_SHEET")
+ }
+ executePendingBindings()
+ snackbarUpdateShower.attach(mainContainer, createFab)
+ }
+ backPressedCallback = requireActivity().onBackPressedDispatcher.addCallback(this) { actionMode?.finish() }
+ backPressedCallback?.isEnabled = false
+
+ return binding?.root
+ }
+
+ override fun onDestroyView() {
+ binding = null
+ super.onDestroyView()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putIntegerArrayList(CHECKED_ITEMS, actionModeListener.getCheckedItems())
+ }
+
+ override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?) {
+ binding ?: return
+ lifecycleScope.launch {
+ val tunnels = Application.getTunnelManager().getTunnels()
+ if (newTunnel != null) viewForTunnel(newTunnel, tunnels)?.setSingleSelected(true)
+ if (oldTunnel != null) viewForTunnel(oldTunnel, tunnels)?.setSingleSelected(false)
+ }
+ }
+
+ private fun onTunnelDeletionFinished(count: Int, throwable: Throwable?) {
+ val message: String
+ val ctx = activity ?: Application.get()
+ if (throwable == null) {
+ message = ctx.resources.getQuantityString(R.plurals.delete_success, count, count)
+ } else {
+ val error = ErrorMessages[throwable]
+ message = ctx.resources.getQuantityString(R.plurals.delete_error, count, count, error)
+ Log.e(TAG, message, throwable)
+ }
+ showSnackbar(message)
+ }
+
+ override fun onViewStateRestored(savedInstanceState: Bundle?) {
+ super.onViewStateRestored(savedInstanceState)
+ binding ?: return
+ binding!!.fragment = this
+ lifecycleScope.launch { binding!!.tunnels = Application.getTunnelManager().getTunnels() }
+ binding!!.rowConfigurationHandler = object : RowConfigurationHandler {
+ override fun onConfigureRow(binding: TunnelListItemBinding, item: ObservableTunnel, position: Int) {
+ binding.fragment = this@TunnelListFragment
+ binding.root.setOnClickListener {
+ if (actionMode == null) {
+ selectedTunnel = item
+ } else {
+ actionModeListener.toggleItemChecked(position)
+ }
+ }
+ binding.root.setOnLongClickListener {
+ actionModeListener.toggleItemChecked(position)
+ true
+ }
+ if (actionMode != null)
+ (binding.root as MultiselectableRelativeLayout).setMultiSelected(actionModeListener.checkedItems.contains(position))
+ else
+ (binding.root as MultiselectableRelativeLayout).setSingleSelected(selectedTunnel == item)
+ }
+ }
+ }
+
+ private fun showSnackbar(message: CharSequence) {
+ val binding = binding
+ if (binding != null)
+ Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG)
+ .setAnchorView(binding.createFab)
+ .show()
+ else
+ Toast.makeText(activity ?: Application.get(), message, Toast.LENGTH_SHORT).show()
+ }
+
+ private fun viewForTunnel(tunnel: ObservableTunnel, tunnels: List<*>): MultiselectableRelativeLayout? {
+ return binding?.tunnelList?.findViewHolderForAdapterPosition(tunnels.indexOf(tunnel))?.itemView as? MultiselectableRelativeLayout
+ }
+
+ private inner class ActionModeListener : ActionMode.Callback {
+ val checkedItems: MutableCollection = HashSet()
+ private var resources: Resources? = null
+
+ fun getCheckedItems(): ArrayList {
+ return ArrayList(checkedItems)
+ }
+
+ override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
+ return when (item.itemId) {
+ R.id.menu_action_delete -> {
+ val activity = activity ?: return true
+ val copyCheckedItems = HashSet(checkedItems)
+ binding?.createFab?.apply {
+ visibility = View.VISIBLE
+ scaleX = 1f
+ scaleY = 1f
+ }
+ activity.lifecycleScope.launch {
+ try {
+ val tunnels = Application.getTunnelManager().getTunnels()
+ val tunnelsToDelete = ArrayList()
+ for (position in copyCheckedItems) tunnelsToDelete.add(tunnels[position])
+ val futures = tunnelsToDelete.map { async(SupervisorJob()) { it.deleteAsync() } }
+ onTunnelDeletionFinished(futures.awaitAll().size, null)
+ } catch (e: Throwable) {
+ onTunnelDeletionFinished(0, e)
+ }
+ }
+ checkedItems.clear()
+ mode.finish()
+ true
+ }
+
+ R.id.menu_action_select_all -> {
+ lifecycleScope.launch {
+ val tunnels = Application.getTunnelManager().getTunnels()
+ for (i in 0 until tunnels.size) {
+ setItemChecked(i, true)
+ }
+ }
+ true
+ }
+
+ else -> false
+ }
+ }
+
+ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+ actionMode = mode
+ backPressedCallback?.isEnabled = true
+ if (activity != null) {
+ resources = activity!!.resources
+ }
+ animateFab(binding?.createFab, false)
+ mode.menuInflater.inflate(R.menu.tunnel_list_action_mode, menu)
+ binding?.tunnelList?.adapter?.notifyDataSetChanged()
+ return true
+ }
+
+ override fun onDestroyActionMode(mode: ActionMode) {
+ actionMode = null
+ backPressedCallback?.isEnabled = false
+ resources = null
+ animateFab(binding?.createFab, true)
+ checkedItems.clear()
+ binding?.tunnelList?.adapter?.notifyDataSetChanged()
+ }
+
+ override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
+ updateTitle(mode)
+ return false
+ }
+
+ fun setItemChecked(position: Int, checked: Boolean) {
+ if (checked) {
+ checkedItems.add(position)
+ } else {
+ checkedItems.remove(position)
+ }
+ val adapter = if (binding == null) null else binding!!.tunnelList.adapter
+ if (actionMode == null && !checkedItems.isEmpty() && activity != null) {
+ (activity as AppCompatActivity).startSupportActionMode(this)
+ } else if (actionMode != null && checkedItems.isEmpty()) {
+ actionMode!!.finish()
+ }
+ adapter?.notifyItemChanged(position)
+ updateTitle(actionMode)
+ }
+
+ fun toggleItemChecked(position: Int) {
+ setItemChecked(position, !checkedItems.contains(position))
+ }
+
+ private fun updateTitle(mode: ActionMode?) {
+ if (mode == null) {
+ return
+ }
+ val count = checkedItems.size
+ if (count == 0) {
+ mode.title = ""
+ } else {
+ mode.title = resources!!.getQuantityString(R.plurals.delete_title, count, count)
+ }
+ }
+
+ private fun animateFab(view: View?, show: Boolean) {
+ view ?: return
+ val animation = AnimationUtils.loadAnimation(
+ context, if (show) R.anim.scale_up else R.anim.scale_down
+ )
+ animation.setAnimationListener(object : Animation.AnimationListener {
+ override fun onAnimationRepeat(animation: Animation?) {
+ }
+
+ override fun onAnimationEnd(animation: Animation?) {
+ if (!show) view.visibility = View.GONE
+ }
+
+ override fun onAnimationStart(animation: Animation?) {
+ if (show) view.visibility = View.VISIBLE
+ }
+ })
+ view.startAnimation(animation)
+ }
+ }
+
+ companion object {
+ private const val CHECKED_ITEMS = "CHECKED_ITEMS"
+ private const val TAG = "WireGuard/TunnelListFragment"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/model/ApplicationData.kt b/ui/src/main/java/com/wireguard/android/model/ApplicationData.kt
new file mode 100644
index 0000000..e6b5705
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/model/ApplicationData.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.model
+
+import android.graphics.drawable.Drawable
+import androidx.databinding.BaseObservable
+import androidx.databinding.Bindable
+import com.wireguard.android.BR
+import com.wireguard.android.databinding.Keyed
+
+class ApplicationData(val icon: Drawable, val name: String, val packageName: String, isSelected: Boolean) : BaseObservable(), Keyed {
+ override val key = name
+
+ @get:Bindable
+ var isSelected = isSelected
+ set(value) {
+ field = value
+ notifyPropertyChanged(BR.selected)
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt b/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt
new file mode 100644
index 0000000..227c129
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.kt
@@ -0,0 +1,146 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.model
+
+import android.util.Log
+import androidx.databinding.BaseObservable
+import androidx.databinding.Bindable
+import com.wireguard.android.BR
+import com.wireguard.android.backend.Statistics
+import com.wireguard.android.backend.Tunnel
+import com.wireguard.android.databinding.Keyed
+import com.wireguard.android.util.applicationScope
+import com.wireguard.config.Config
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * Encapsulates the volatile and nonvolatile state of a WireGuard tunnel.
+ */
+class ObservableTunnel internal constructor(
+ private val manager: TunnelManager,
+ private var name: String,
+ config: Config?,
+ state: Tunnel.State
+) : BaseObservable(), Keyed, Tunnel {
+ override val key
+ get() = name
+
+ @Bindable
+ override fun getName() = name
+
+ suspend fun setNameAsync(name: String): String = withContext(Dispatchers.Main.immediate) {
+ if (name != this@ObservableTunnel.name)
+ manager.setTunnelName(this@ObservableTunnel, name)
+ else
+ this@ObservableTunnel.name
+ }
+
+ fun onNameChanged(name: String): String {
+ this.name = name
+ notifyPropertyChanged(BR.name)
+ return name
+ }
+
+
+ @get:Bindable
+ var state = state
+ private set
+
+ override fun onStateChange(newState: Tunnel.State) {
+ onStateChanged(newState)
+ }
+
+ fun onStateChanged(state: Tunnel.State): Tunnel.State {
+ if (state != Tunnel.State.UP) onStatisticsChanged(null)
+ this.state = state
+ notifyPropertyChanged(BR.state)
+ return state
+ }
+
+ suspend fun setStateAsync(state: Tunnel.State): Tunnel.State = withContext(Dispatchers.Main.immediate) {
+ if (state != this@ObservableTunnel.state)
+ manager.setTunnelState(this@ObservableTunnel, state)
+ else
+ this@ObservableTunnel.state
+ }
+
+
+ @get:Bindable
+ var config = config
+ get() {
+ if (field == null)
+ // Opportunistically fetch this if we don't have a cached one, and rely on data bindings to update it eventually
+ applicationScope.launch {
+ try {
+ manager.getTunnelConfig(this@ObservableTunnel)
+ } catch (e: Throwable) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ }
+ }
+ return field
+ }
+ private set
+
+ suspend fun getConfigAsync(): Config = withContext(Dispatchers.Main.immediate) {
+ config ?: manager.getTunnelConfig(this@ObservableTunnel)
+ }
+
+ suspend fun setConfigAsync(config: Config): Config = withContext(Dispatchers.Main.immediate) {
+ this@ObservableTunnel.config.let {
+ if (config != it)
+ manager.setTunnelConfig(this@ObservableTunnel, config)
+ else
+ it
+ }
+ }
+
+ fun onConfigChanged(config: Config?): Config? {
+ this.config = config
+ notifyPropertyChanged(BR.config)
+ return config
+ }
+
+
+ @get:Bindable
+ var statistics: Statistics? = null
+ get() {
+ if (field == null || field?.isStale != false)
+ // Opportunistically fetch this if we don't have a cached one, and rely on data bindings to update it eventually
+ applicationScope.launch {
+ try {
+ manager.getTunnelStatistics(this@ObservableTunnel)
+ } catch (e: Throwable) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ }
+ }
+ return field
+ }
+ private set
+
+ suspend fun getStatisticsAsync(): Statistics = withContext(Dispatchers.Main.immediate) {
+ statistics.let {
+ if (it == null || it.isStale)
+ manager.getTunnelStatistics(this@ObservableTunnel)
+ else
+ it
+ }
+ }
+
+ fun onStatisticsChanged(statistics: Statistics?): Statistics? {
+ this.statistics = statistics
+ notifyPropertyChanged(BR.statistics)
+ return statistics
+ }
+
+
+ suspend fun deleteAsync() = manager.delete(this)
+
+
+ companion object {
+ private const val TAG = "WireGuard/ObservableTunnel"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/model/TunnelComparator.kt b/ui/src/main/java/com/wireguard/android/model/TunnelComparator.kt
new file mode 100644
index 0000000..3be1019
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/model/TunnelComparator.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.model
+
+object TunnelComparator : Comparator {
+ private class NaturalSortString(originalString: String) {
+ class NaturalSortToken(val maybeString: String?, val maybeNumber: Int?) : Comparable {
+ override fun compareTo(other: NaturalSortToken): Int {
+ if (maybeString == null) {
+ if (other.maybeString != null || maybeNumber!! < other.maybeNumber!!) {
+ return -1
+ } else if (maybeNumber > other.maybeNumber) {
+ return 1
+ }
+ } else if (other.maybeString == null || maybeString > other.maybeString) {
+ return 1
+ } else if (maybeString < other.maybeString) {
+ return -1
+ }
+ return 0
+ }
+ }
+
+ val tokens: MutableList = ArrayList()
+
+ init {
+ for (s in NATURAL_SORT_DIGIT_FINDER.findAll(originalString.split(WHITESPACE_FINDER).joinToString(" ").lowercase())) {
+ try {
+ val n = s.value.toInt()
+ tokens.add(NaturalSortToken(null, n))
+ } catch (_: NumberFormatException) {
+ tokens.add(NaturalSortToken(s.value, null))
+ }
+ }
+ }
+
+ private companion object {
+ private val NATURAL_SORT_DIGIT_FINDER = Regex("""\d+|\D+""")
+ private val WHITESPACE_FINDER = Regex("""\s""")
+ }
+ }
+
+ override fun compare(a: String, b: String): Int {
+ if (a == b)
+ return 0
+ val na = NaturalSortString(a)
+ val nb = NaturalSortString(b)
+ for (i in 0 until nb.tokens.size) {
+ if (i == na.tokens.size) {
+ return -1
+ }
+ val c = na.tokens[i].compareTo(nb.tokens[i])
+ if (c != 0)
+ return c
+ }
+ return 1
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/model/TunnelManager.kt b/ui/src/main/java/com/wireguard/android/model/TunnelManager.kt
new file mode 100644
index 0000000..e08623d
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/model/TunnelManager.kt
@@ -0,0 +1,254 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.model
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.util.Log
+import android.widget.Toast
+import androidx.databinding.BaseObservable
+import androidx.databinding.Bindable
+import com.wireguard.android.Application.Companion.get
+import com.wireguard.android.Application.Companion.getBackend
+import com.wireguard.android.Application.Companion.getTunnelManager
+import com.wireguard.android.BR
+import com.wireguard.android.R
+import com.wireguard.android.backend.Statistics
+import com.wireguard.android.backend.Tunnel
+import com.wireguard.android.configStore.ConfigStore
+import com.wireguard.android.databinding.ObservableSortedKeyedArrayList
+import com.wireguard.android.util.ErrorMessages
+import com.wireguard.android.util.UserKnobs
+import com.wireguard.android.util.applicationScope
+import com.wireguard.config.Config
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * Maintains and mediates changes to the set of available WireGuard tunnels,
+ */
+class TunnelManager(private val configStore: ConfigStore) : BaseObservable() {
+ private val tunnels = CompletableDeferred>()
+ private val context: Context = get()
+ private val tunnelMap: ObservableSortedKeyedArrayList = ObservableSortedKeyedArrayList(TunnelComparator)
+ private var haveLoaded = false
+
+ private fun addToList(name: String, config: Config?, state: Tunnel.State): ObservableTunnel {
+ val tunnel = ObservableTunnel(this, name, config, state)
+ tunnelMap.add(tunnel)
+ return tunnel
+ }
+
+ suspend fun getTunnels(): ObservableSortedKeyedArrayList = tunnels.await()
+
+ suspend fun create(name: String, config: Config?): ObservableTunnel = withContext(Dispatchers.Main.immediate) {
+ if (Tunnel.isNameInvalid(name))
+ throw IllegalArgumentException(context.getString(R.string.tunnel_error_invalid_name))
+ if (tunnelMap.containsKey(name))
+ throw IllegalArgumentException(context.getString(R.string.tunnel_error_already_exists, name))
+ addToList(name, withContext(Dispatchers.IO) { configStore.create(name, config!!) }, Tunnel.State.DOWN)
+ }
+
+ suspend fun delete(tunnel: ObservableTunnel) = withContext(Dispatchers.Main.immediate) {
+ val originalState = tunnel.state
+ val wasLastUsed = tunnel == lastUsedTunnel
+ // Make sure nothing touches the tunnel.
+ if (wasLastUsed)
+ lastUsedTunnel = null
+ tunnelMap.remove(tunnel)
+ try {
+ if (originalState == Tunnel.State.UP)
+ withContext(Dispatchers.IO) { getBackend().setState(tunnel, Tunnel.State.DOWN, null) }
+ try {
+ withContext(Dispatchers.IO) { configStore.delete(tunnel.name) }
+ } catch (e: Throwable) {
+ if (originalState == Tunnel.State.UP)
+ withContext(Dispatchers.IO) { getBackend().setState(tunnel, Tunnel.State.UP, tunnel.config) }
+ throw e
+ }
+ } catch (e: Throwable) {
+ // Failure, put the tunnel back.
+ tunnelMap.add(tunnel)
+ if (wasLastUsed)
+ lastUsedTunnel = tunnel
+ throw e
+ }
+ }
+
+ @get:Bindable
+ var lastUsedTunnel: ObservableTunnel? = null
+ private set(value) {
+ if (value == field) return
+ field = value
+ notifyPropertyChanged(BR.lastUsedTunnel)
+ applicationScope.launch { UserKnobs.setLastUsedTunnel(value?.name) }
+ }
+
+ suspend fun getTunnelConfig(tunnel: ObservableTunnel): Config = withContext(Dispatchers.Main.immediate) {
+ tunnel.onConfigChanged(withContext(Dispatchers.IO) { configStore.load(tunnel.name) })!!
+ }
+
+ fun onCreate() {
+ applicationScope.launch {
+ try {
+ onTunnelsLoaded(withContext(Dispatchers.IO) { configStore.enumerate() }, withContext(Dispatchers.IO) { getBackend().runningTunnelNames })
+ } catch (e: Throwable) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ }
+ }
+ }
+
+ private fun onTunnelsLoaded(present: Iterable, running: Collection) {
+ for (name in present)
+ addToList(name, null, if (running.contains(name)) Tunnel.State.UP else Tunnel.State.DOWN)
+ applicationScope.launch {
+ val lastUsedName = UserKnobs.lastUsedTunnel.first()
+ if (lastUsedName != null)
+ lastUsedTunnel = tunnelMap[lastUsedName]
+ haveLoaded = true
+ restoreState(true)
+ tunnels.complete(tunnelMap)
+ }
+ }
+
+ private fun refreshTunnelStates() {
+ applicationScope.launch {
+ try {
+ val running = withContext(Dispatchers.IO) { getBackend().runningTunnelNames }
+ for (tunnel in tunnelMap)
+ tunnel.onStateChanged(if (running.contains(tunnel.name)) Tunnel.State.UP else Tunnel.State.DOWN)
+ } catch (e: Throwable) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ }
+ }
+ }
+
+ suspend fun restoreState(force: Boolean) {
+ if (!haveLoaded || (!force && !UserKnobs.restoreOnBoot.first()))
+ return
+ val previouslyRunning = UserKnobs.runningTunnels.first()
+ if (previouslyRunning.isEmpty()) return
+ withContext(Dispatchers.IO) {
+ try {
+ tunnelMap.filter { previouslyRunning.contains(it.name) }.map { async(Dispatchers.IO + SupervisorJob()) { setTunnelState(it, Tunnel.State.UP) } }
+ .awaitAll()
+ } catch (e: Throwable) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ }
+ }
+ }
+
+ suspend fun saveState() {
+ UserKnobs.setRunningTunnels(tunnelMap.filter { it.state == Tunnel.State.UP }.map { it.name }.toSet())
+ }
+
+ suspend fun setTunnelConfig(tunnel: ObservableTunnel, config: Config): Config = withContext(Dispatchers.Main.immediate) {
+ tunnel.onConfigChanged(withContext(Dispatchers.IO) {
+ getBackend().setState(tunnel, tunnel.state, config)
+ configStore.save(tunnel.name, config)
+ })!!
+ }
+
+ suspend fun setTunnelName(tunnel: ObservableTunnel, name: String): String = withContext(Dispatchers.Main.immediate) {
+ if (Tunnel.isNameInvalid(name))
+ throw IllegalArgumentException(context.getString(R.string.tunnel_error_invalid_name))
+ if (tunnelMap.containsKey(name)) {
+ throw IllegalArgumentException(context.getString(R.string.tunnel_error_already_exists, name))
+ }
+ val originalState = tunnel.state
+ val wasLastUsed = tunnel == lastUsedTunnel
+ // Make sure nothing touches the tunnel.
+ if (wasLastUsed)
+ lastUsedTunnel = null
+ tunnelMap.remove(tunnel)
+ var throwable: Throwable? = null
+ var newName: String? = null
+ try {
+ if (originalState == Tunnel.State.UP)
+ withContext(Dispatchers.IO) { getBackend().setState(tunnel, Tunnel.State.DOWN, null) }
+ withContext(Dispatchers.IO) { configStore.rename(tunnel.name, name) }
+ newName = tunnel.onNameChanged(name)
+ if (originalState == Tunnel.State.UP)
+ withContext(Dispatchers.IO) { getBackend().setState(tunnel, Tunnel.State.UP, tunnel.config) }
+ } catch (e: Throwable) {
+ throwable = e
+ // On failure, we don't know what state the tunnel might be in. Fix that.
+ getTunnelState(tunnel)
+ }
+ // Add the tunnel back to the manager, under whatever name it thinks it has.
+ tunnelMap.add(tunnel)
+ if (wasLastUsed)
+ lastUsedTunnel = tunnel
+ if (throwable != null)
+ throw throwable
+ newName!!
+ }
+
+ suspend fun setTunnelState(tunnel: ObservableTunnel, state: Tunnel.State): Tunnel.State = withContext(Dispatchers.Main.immediate) {
+ var newState = tunnel.state
+ var throwable: Throwable? = null
+ try {
+ newState = withContext(Dispatchers.IO) { getBackend().setState(tunnel, state, tunnel.getConfigAsync()) }
+ if (newState == Tunnel.State.UP)
+ lastUsedTunnel = tunnel
+ } catch (e: Throwable) {
+ throwable = e
+ }
+ tunnel.onStateChanged(newState)
+ saveState()
+ if (throwable != null)
+ throw throwable
+ newState
+ }
+
+ class IntentReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent?) {
+ applicationScope.launch {
+ val manager = getTunnelManager()
+ if (intent == null) return@launch
+ val action = intent.action ?: return@launch
+ if ("com.wireguard.android.action.REFRESH_TUNNEL_STATES" == action) {
+ manager.refreshTunnelStates()
+ return@launch
+ }
+ if (!UserKnobs.allowRemoteControlIntents.first())
+ return@launch
+ val state = when (action) {
+ "com.wireguard.android.action.SET_TUNNEL_UP" -> Tunnel.State.UP
+ "com.wireguard.android.action.SET_TUNNEL_DOWN" -> Tunnel.State.DOWN
+ else -> return@launch
+ }
+ val tunnelName = intent.getStringExtra("tunnel") ?: return@launch
+ val tunnels = manager.getTunnels()
+ val tunnel = tunnels[tunnelName] ?: return@launch
+ try {
+ manager.setTunnelState(tunnel, state)
+ } catch (e: Throwable) {
+ Toast.makeText(context, ErrorMessages[e], Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+ }
+
+ suspend fun getTunnelState(tunnel: ObservableTunnel): Tunnel.State = withContext(Dispatchers.Main.immediate) {
+ tunnel.onStateChanged(withContext(Dispatchers.IO) { getBackend().getState(tunnel) })
+ }
+
+ suspend fun getTunnelStatistics(tunnel: ObservableTunnel): Statistics = withContext(Dispatchers.Main.immediate) {
+ tunnel.onStatisticsChanged(withContext(Dispatchers.IO) { getBackend().getStatistics(tunnel) })!!
+ }
+
+ companion object {
+ private const val TAG = "WireGuard/TunnelManager"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/DonatePreference.kt b/ui/src/main/java/com/wireguard/android/preference/DonatePreference.kt
new file mode 100644
index 0000000..2f66a2c
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/DonatePreference.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.preference
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.util.AttributeSet
+import android.widget.Toast
+import androidx.preference.Preference
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.wireguard.android.R
+import com.wireguard.android.updater.Updater
+import com.wireguard.android.util.ErrorMessages
+import androidx.core.net.toUri
+
+class DonatePreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) {
+ override fun getSummary() = context.getString(R.string.donate_summary)
+
+ override fun getTitle() = context.getString(R.string.donate_title)
+
+ override fun onClick() {
+ /* Google Play Store forbids links to our donation page. */
+ if (Updater.installerIsGooglePlay(context)) {
+ MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.donate_title)
+ .setMessage(R.string.donate_google_play_disappointment)
+ .show()
+ return
+ }
+
+ val intent = Intent(Intent.ACTION_VIEW)
+ intent.data = "https://www.wireguard.com/donations/".toUri()
+ try {
+ context.startActivity(intent)
+ } catch (e: Throwable) {
+ Toast.makeText(context, ErrorMessages[e], Toast.LENGTH_SHORT).show()
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/KernelModuleEnablerPreference.kt b/ui/src/main/java/com/wireguard/android/preference/KernelModuleEnablerPreference.kt
new file mode 100644
index 0000000..3d1c27f
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/KernelModuleEnablerPreference.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.preference
+
+import android.content.Context
+import android.content.Intent
+import android.util.AttributeSet
+import android.util.Log
+import androidx.lifecycle.lifecycleScope
+import androidx.preference.Preference
+import com.wireguard.android.Application
+import com.wireguard.android.R
+import com.wireguard.android.activity.SettingsActivity
+import com.wireguard.android.backend.Tunnel
+import com.wireguard.android.backend.WgQuickBackend
+import com.wireguard.android.util.UserKnobs
+import com.wireguard.android.util.activity
+import com.wireguard.android.util.lifecycleScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlin.system.exitProcess
+
+class KernelModuleEnablerPreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) {
+ private var state = State.UNKNOWN
+
+ init {
+ isVisible = false
+ lifecycleScope.launch {
+ setState(if (Application.getBackend() is WgQuickBackend) State.ENABLED else State.DISABLED)
+ }
+ }
+
+ override fun getSummary() = if (state == State.UNKNOWN) "" else context.getString(state.summaryResourceId)
+
+ override fun getTitle() = if (state == State.UNKNOWN) "" else context.getString(state.titleResourceId)
+
+ override fun onClick() {
+ activity.lifecycleScope.launch {
+ if (state == State.DISABLED) {
+ setState(State.ENABLING)
+ UserKnobs.setEnableKernelModule(true)
+ } else if (state == State.ENABLED) {
+ setState(State.DISABLING)
+ UserKnobs.setEnableKernelModule(false)
+ }
+ val observableTunnels = Application.getTunnelManager().getTunnels()
+ val downings = observableTunnels.map { async(SupervisorJob()) { it.setStateAsync(Tunnel.State.DOWN) } }
+ try {
+ downings.awaitAll()
+ withContext(Dispatchers.IO) {
+ val restartIntent = Intent(context, SettingsActivity::class.java)
+ restartIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ restartIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ Application.get().startActivity(restartIntent)
+ exitProcess(0)
+ }
+ } catch (e: Throwable) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ }
+ }
+ }
+
+ private fun setState(state: State) {
+ if (this.state == state) return
+ this.state = state
+ if (isEnabled != state.shouldEnableView) isEnabled = state.shouldEnableView
+ if (isVisible != state.visible) isVisible = state.visible
+ notifyChanged()
+ }
+
+ private enum class State(val titleResourceId: Int, val summaryResourceId: Int, val shouldEnableView: Boolean, val visible: Boolean) {
+ UNKNOWN(0, 0, false, false),
+ ENABLED(R.string.module_enabler_enabled_title, R.string.module_enabler_enabled_summary, true, true),
+ DISABLED(R.string.module_enabler_disabled_title, R.string.module_enabler_disabled_summary, true, true),
+ ENABLING(R.string.module_enabler_disabled_title, R.string.success_application_will_restart, false, true),
+ DISABLING(R.string.module_enabler_enabled_title, R.string.success_application_will_restart, false, true);
+ }
+
+ companion object {
+ private const val TAG = "WireGuard/KernelModuleEnablerPreference"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/PreferencesPreferenceDataStore.kt b/ui/src/main/java/com/wireguard/android/preference/PreferencesPreferenceDataStore.kt
new file mode 100644
index 0000000..e2fc51e
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/PreferencesPreferenceDataStore.kt
@@ -0,0 +1,135 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.preference
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.floatPreferencesKey
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import androidx.preference.PreferenceDataStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+
+class PreferencesPreferenceDataStore(private val coroutineScope: CoroutineScope, private val dataStore: DataStore) : PreferenceDataStore() {
+ override fun putString(key: String?, value: String?) {
+ if (key == null) return
+ val pk = stringPreferencesKey(key)
+ coroutineScope.launch {
+ dataStore.edit {
+ if (value == null) it.remove(pk)
+ else it[pk] = value
+ }
+ }
+ }
+
+ override fun putStringSet(key: String?, values: Set?) {
+ if (key == null) return
+ val pk = stringSetPreferencesKey(key)
+ val filteredValues = values?.filterNotNull()?.toSet()
+ coroutineScope.launch {
+ dataStore.edit {
+ if (filteredValues == null || filteredValues.isEmpty()) it.remove(pk)
+ else it[pk] = filteredValues
+ }
+ }
+ }
+
+ override fun putInt(key: String?, value: Int) {
+ if (key == null) return
+ val pk = intPreferencesKey(key)
+ coroutineScope.launch {
+ dataStore.edit {
+ it[pk] = value
+ }
+ }
+ }
+
+ override fun putLong(key: String?, value: Long) {
+ if (key == null) return
+ val pk = longPreferencesKey(key)
+ coroutineScope.launch {
+ dataStore.edit {
+ it[pk] = value
+ }
+ }
+ }
+
+ override fun putFloat(key: String?, value: Float) {
+ if (key == null) return
+ val pk = floatPreferencesKey(key)
+ coroutineScope.launch {
+ dataStore.edit {
+ it[pk] = value
+ }
+ }
+ }
+
+ override fun putBoolean(key: String?, value: Boolean) {
+ if (key == null) return
+ val pk = booleanPreferencesKey(key)
+ coroutineScope.launch {
+ dataStore.edit {
+ it[pk] = value
+ }
+ }
+ }
+
+ override fun getString(key: String?, defValue: String?): String? {
+ if (key == null) return defValue
+ val pk = stringPreferencesKey(key)
+ return runBlocking {
+ dataStore.data.map { it[pk] ?: defValue }.first()
+ }
+ }
+
+ override fun getStringSet(key: String?, defValues: Set?): Set? {
+ if (key == null) return defValues
+ val pk = stringSetPreferencesKey(key)
+ return runBlocking {
+ dataStore.data.map { it[pk] ?: defValues }.first()
+ }
+ }
+
+ override fun getInt(key: String?, defValue: Int): Int {
+ if (key == null) return defValue
+ val pk = intPreferencesKey(key)
+ return runBlocking {
+ dataStore.data.map { it[pk] ?: defValue }.first()
+ }
+ }
+
+ override fun getLong(key: String?, defValue: Long): Long {
+ if (key == null) return defValue
+ val pk = longPreferencesKey(key)
+ return runBlocking {
+ dataStore.data.map { it[pk] ?: defValue }.first()
+ }
+ }
+
+ override fun getFloat(key: String?, defValue: Float): Float {
+ if (key == null) return defValue
+ val pk = floatPreferencesKey(key)
+ return runBlocking {
+ dataStore.data.map { it[pk] ?: defValue }.first()
+ }
+ }
+
+ override fun getBoolean(key: String?, defValue: Boolean): Boolean {
+ if (key == null) return defValue
+ val pk = booleanPreferencesKey(key)
+ return runBlocking {
+ dataStore.data.map { it[pk] ?: defValue }.first()
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/QuickTilePreference.kt b/ui/src/main/java/com/wireguard/android/preference/QuickTilePreference.kt
new file mode 100644
index 0000000..458b9f9
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/QuickTilePreference.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.preference
+
+import android.app.StatusBarManager
+import android.content.ComponentName
+import android.content.Context
+import android.graphics.drawable.Icon
+import android.os.Build
+import android.util.AttributeSet
+import android.widget.Toast
+import androidx.annotation.RequiresApi
+import androidx.preference.Preference
+import com.wireguard.android.QuickTileService
+import com.wireguard.android.R
+
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+class QuickTilePreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) {
+ override fun getSummary() = context.getString(R.string.quick_settings_tile_add_summary)
+
+ override fun getTitle() = context.getString(R.string.quick_settings_tile_add_title)
+
+ override fun onClick() {
+ val statusBarManager = context.getSystemService(StatusBarManager::class.java)
+ statusBarManager.requestAddTileService(
+ ComponentName(context, QuickTileService::class.java),
+ context.getString(R.string.quick_settings_tile_action),
+ Icon.createWithResource(context, R.drawable.ic_tile),
+ context.mainExecutor
+ ) {
+ when (it) {
+ StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ALREADY_ADDED,
+ StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ADDED -> {
+ parent?.removePreference(this)
+ --preferenceManager.preferenceScreen.initialExpandedChildrenCount
+ }
+ StatusBarManager.TILE_ADD_REQUEST_ERROR_MISMATCHED_PACKAGE,
+ StatusBarManager.TILE_ADD_REQUEST_ERROR_REQUEST_IN_PROGRESS,
+ StatusBarManager.TILE_ADD_REQUEST_ERROR_BAD_COMPONENT,
+ StatusBarManager.TILE_ADD_REQUEST_ERROR_NOT_CURRENT_USER,
+ StatusBarManager.TILE_ADD_REQUEST_ERROR_APP_NOT_IN_FOREGROUND,
+ StatusBarManager.TILE_ADD_REQUEST_ERROR_NO_STATUS_BAR_SERVICE ->
+ Toast.makeText(context, context.getString(R.string.quick_settings_tile_add_failure, it), Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.kt b/ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.kt
new file mode 100644
index 0000000..b22048b
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.preference
+
+import android.content.Context
+import android.util.AttributeSet
+import androidx.preference.Preference
+import com.wireguard.android.Application
+import com.wireguard.android.R
+import com.wireguard.android.util.ToolsInstaller
+import com.wireguard.android.util.lifecycleScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * Preference implementing a button that asynchronously runs `ToolsInstaller` and displays the
+ * result as the preference summary.
+ */
+class ToolsInstallerPreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) {
+ private var state = State.INITIAL
+ override fun getSummary() = context.getString(state.messageResourceId)
+
+ override fun getTitle() = context.getString(R.string.tools_installer_title)
+
+ override fun onAttached() {
+ super.onAttached()
+ lifecycleScope.launch {
+ try {
+ val state = withContext(Dispatchers.IO) { Application.getToolsInstaller().areInstalled() }
+ when {
+ state == ToolsInstaller.ERROR -> setState(State.INITIAL)
+ state and ToolsInstaller.YES == ToolsInstaller.YES -> setState(State.ALREADY)
+ state and (ToolsInstaller.MAGISK or ToolsInstaller.NO) == ToolsInstaller.MAGISK or ToolsInstaller.NO -> setState(State.INITIAL_MAGISK)
+ state and (ToolsInstaller.SYSTEM or ToolsInstaller.NO) == ToolsInstaller.SYSTEM or ToolsInstaller.NO -> setState(State.INITIAL_SYSTEM)
+ else -> setState(State.INITIAL)
+ }
+ } catch (_: Throwable) {
+ setState(State.INITIAL)
+ }
+ }
+ }
+
+ override fun onClick() {
+ setState(State.WORKING)
+ lifecycleScope.launch {
+ try {
+ val result = withContext(Dispatchers.IO) { Application.getToolsInstaller().install() }
+ when {
+ result and (ToolsInstaller.YES or ToolsInstaller.MAGISK) == ToolsInstaller.YES or ToolsInstaller.MAGISK -> setState(State.SUCCESS_MAGISK)
+ result and (ToolsInstaller.YES or ToolsInstaller.SYSTEM) == ToolsInstaller.YES or ToolsInstaller.SYSTEM -> setState(State.SUCCESS_SYSTEM)
+ else -> setState(State.FAILURE)
+ }
+ } catch (_: Throwable) {
+ setState(State.FAILURE)
+ }
+ }
+ }
+
+ private fun setState(state: State) {
+ if (this.state == state) return
+ this.state = state
+ if (isEnabled != state.shouldEnableView) isEnabled = state.shouldEnableView
+ notifyChanged()
+ }
+
+ private enum class State(val messageResourceId: Int, val shouldEnableView: Boolean) {
+ INITIAL(R.string.tools_installer_initial, true),
+ ALREADY(R.string.tools_installer_already, false),
+ FAILURE(R.string.tools_installer_failure, true),
+ WORKING(R.string.tools_installer_working, false),
+ INITIAL_SYSTEM(R.string.tools_installer_initial_system, true),
+ SUCCESS_SYSTEM(R.string.tools_installer_success_system, false),
+ INITIAL_MAGISK(R.string.tools_installer_initial_magisk, true),
+ SUCCESS_MAGISK(R.string.tools_installer_success_magisk, false);
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/VersionPreference.kt b/ui/src/main/java/com/wireguard/android/preference/VersionPreference.kt
new file mode 100644
index 0000000..3850482
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/VersionPreference.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.preference
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.util.AttributeSet
+import android.widget.Toast
+import androidx.preference.Preference
+import com.wireguard.android.Application
+import com.wireguard.android.BuildConfig
+import com.wireguard.android.R
+import com.wireguard.android.backend.Backend
+import com.wireguard.android.backend.GoBackend
+import com.wireguard.android.backend.WgQuickBackend
+import com.wireguard.android.util.ErrorMessages
+import com.wireguard.android.util.lifecycleScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class VersionPreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) {
+ private var versionSummary: String? = null
+
+ override fun getSummary() = versionSummary
+
+ override fun getTitle() = context.getString(R.string.version_title, BuildConfig.VERSION_NAME)
+
+ override fun onClick() {
+ val intent = Intent(Intent.ACTION_VIEW)
+ intent.data = Uri.parse("https://www.wireguard.com/")
+ try {
+ context.startActivity(intent)
+ } catch (e: Throwable) {
+ Toast.makeText(context, ErrorMessages[e], Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ companion object {
+ private fun getBackendPrettyName(context: Context, backend: Backend) = when (backend) {
+ is WgQuickBackend -> context.getString(R.string.type_name_kernel_module)
+ is GoBackend -> context.getString(R.string.type_name_go_userspace)
+ else -> ""
+ }
+ }
+
+ init {
+ lifecycleScope.launch {
+ val backend = Application.getBackend()
+ versionSummary = getContext().getString(R.string.version_summary_checking, getBackendPrettyName(context, backend).lowercase())
+ notifyChanged()
+ versionSummary = try {
+ getContext().getString(R.string.version_summary, getBackendPrettyName(context, backend), withContext(Dispatchers.IO) { backend.version })
+ } catch (_: Throwable) {
+ getContext().getString(R.string.version_summary_unknown, getBackendPrettyName(context, backend).lowercase())
+ }
+ notifyChanged()
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.kt b/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.kt
new file mode 100644
index 0000000..5270115
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.kt
@@ -0,0 +1,113 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.preference
+
+import android.content.Context
+import android.util.AttributeSet
+import android.util.Log
+import androidx.preference.Preference
+import com.google.android.material.snackbar.Snackbar
+import com.wireguard.android.Application
+import com.wireguard.android.R
+import com.wireguard.android.util.AdminKnobs
+import com.wireguard.android.util.BiometricAuthenticator
+import com.wireguard.android.util.DownloadsFileSaver
+import com.wireguard.android.util.ErrorMessages
+import com.wireguard.android.util.activity
+import com.wireguard.android.util.lifecycleScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.nio.charset.StandardCharsets
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+
+/**
+ * Preference implementing a button that asynchronously exports config zips.
+ */
+class ZipExporterPreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) {
+ private var exportedFilePath: String? = null
+ private val downloadsFileSaver = DownloadsFileSaver(activity)
+
+ private fun exportZip() {
+ lifecycleScope.launch {
+ val tunnels = Application.getTunnelManager().getTunnels()
+ try {
+ exportedFilePath = withContext(Dispatchers.IO) {
+ val configs = tunnels.map { async(SupervisorJob()) { it.getConfigAsync() } }.awaitAll()
+ if (configs.isEmpty()) {
+ throw IllegalArgumentException(context.getString(R.string.no_tunnels_error))
+ }
+ val outputFile = downloadsFileSaver.save("wireguard-export.zip", "application/zip", true)
+ if (outputFile == null) {
+ withContext(Dispatchers.Main.immediate) {
+ isEnabled = true
+ }
+ return@withContext null
+ }
+ try {
+ ZipOutputStream(outputFile.outputStream).use { zip ->
+ for (i in configs.indices) {
+ zip.putNextEntry(ZipEntry(tunnels[i].name + ".conf"))
+ zip.write(configs[i].toWgQuickString().toByteArray(StandardCharsets.UTF_8))
+ }
+ zip.closeEntry()
+ }
+ } catch (e: Throwable) {
+ outputFile.delete()
+ throw e
+ }
+ outputFile.fileName
+ }
+ notifyChanged()
+ } catch (e: Throwable) {
+ val error = ErrorMessages[e]
+ val message = context.getString(R.string.zip_export_error, error)
+ Log.e(TAG, message, e)
+ Snackbar.make(
+ activity.findViewById(android.R.id.content),
+ message, Snackbar.LENGTH_LONG
+ ).show()
+ isEnabled = true
+ }
+ }
+ }
+
+ override fun getSummary() =
+ if (exportedFilePath == null) context.getString(R.string.zip_export_summary) else context.getString(R.string.zip_export_success, exportedFilePath)
+
+ override fun getTitle() = context.getString(R.string.zip_export_title)
+
+ override fun onClick() {
+ if (AdminKnobs.disableConfigExport) return
+ val fragment = activity.supportFragmentManager.fragments.first()
+ BiometricAuthenticator.authenticate(R.string.biometric_prompt_zip_exporter_title, fragment) {
+ when (it) {
+ // When we have successful authentication, or when there is no biometric hardware available.
+ is BiometricAuthenticator.Result.Success, is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> {
+ isEnabled = false
+ exportZip()
+ }
+
+ is BiometricAuthenticator.Result.Failure -> {
+ Snackbar.make(
+ activity.findViewById(android.R.id.content),
+ it.message,
+ Snackbar.LENGTH_SHORT
+ ).show()
+ }
+
+ is BiometricAuthenticator.Result.Cancelled -> {}
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "WireGuard/ZipExporterPreference"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/updater/Ed25519.java b/ui/src/main/java/com/wireguard/android/updater/Ed25519.java
new file mode 100644
index 0000000..44e99b8
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/updater/Ed25519.java
@@ -0,0 +1,2507 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.updater;
+
+import java.math.BigInteger;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
+import java.util.Arrays;
+
+/**
+ * Implementation of Ed25519 signature verification.
+ *
+ * This implementation is based on the ed25519/ref10 implementation in NaCl.
+ *
+ * It implements this twisted Edwards curve:
+ *
+ *
+ * -x^2 + y^2 = 1 + (-121665 / 121666 mod 2^255-19)*x^2*y^2
+ *
+ *
+ * @see Bernstein D.J., Birkner P., Joye M., Lange
+ * T., Peters C. (2008) Twisted Edwards Curves
+ * @see Hisil H., Wong K.KH., Carter G., Dawson E.
+ * (2008) Twisted Edwards Curves Revisited
+ */
+final class Ed25519 {
+
+ // d = -121665 / 121666 mod 2^255-19
+ private static final long[] D;
+ // 2d
+ private static final long[] D2;
+ // 2^((p-1)/4) mod p where p = 2^255-19
+ private static final long[] SQRTM1;
+
+ /**
+ * Base point for the Edwards twisted curve = (x, 4/5) and its exponentiations. B_TABLE[i][j] =
+ * (j+1)*256^i*B for i in [0, 32) and j in [0, 8). Base point B = B_TABLE[0][0]
+ */
+ private static final CachedXYT[][] B_TABLE;
+ private static final CachedXYT[] B2;
+
+ private static final BigInteger P_BI =
+ BigInteger.valueOf(2).pow(255).subtract(BigInteger.valueOf(19));
+ private static final BigInteger D_BI =
+ BigInteger.valueOf(-121665).multiply(BigInteger.valueOf(121666).modInverse(P_BI)).mod(P_BI);
+ private static final BigInteger D2_BI = BigInteger.valueOf(2).multiply(D_BI).mod(P_BI);
+ private static final BigInteger SQRTM1_BI =
+ BigInteger.valueOf(2).modPow(P_BI.subtract(BigInteger.ONE).divide(BigInteger.valueOf(4)), P_BI);
+
+ private Ed25519() {
+ }
+
+ private static class Point {
+ private BigInteger x;
+ private BigInteger y;
+ }
+
+ private static BigInteger recoverX(BigInteger y) {
+ // x^2 = (y^2 - 1) / (d * y^2 + 1) mod 2^255-19
+ BigInteger xx =
+ y.pow(2)
+ .subtract(BigInteger.ONE)
+ .multiply(D_BI.multiply(y.pow(2)).add(BigInteger.ONE).modInverse(P_BI));
+ BigInteger x = xx.modPow(P_BI.add(BigInteger.valueOf(3)).divide(BigInteger.valueOf(8)), P_BI);
+ if (!x.pow(2).subtract(xx).mod(P_BI).equals(BigInteger.ZERO)) {
+ x = x.multiply(SQRTM1_BI).mod(P_BI);
+ }
+ if (x.testBit(0)) {
+ x = P_BI.subtract(x);
+ }
+ return x;
+ }
+
+ private static Point edwards(Point a, Point b) {
+ Point o = new Point();
+ BigInteger xxyy = D_BI.multiply(a.x.multiply(b.x).multiply(a.y).multiply(b.y)).mod(P_BI);
+ o.x =
+ (a.x.multiply(b.y).add(b.x.multiply(a.y)))
+ .multiply(BigInteger.ONE.add(xxyy).modInverse(P_BI))
+ .mod(P_BI);
+ o.y =
+ (a.y.multiply(b.y).add(a.x.multiply(b.x)))
+ .multiply(BigInteger.ONE.subtract(xxyy).modInverse(P_BI))
+ .mod(P_BI);
+ return o;
+ }
+
+ private static byte[] toLittleEndian(BigInteger n) {
+ byte[] b = new byte[32];
+ byte[] nBytes = n.toByteArray();
+ System.arraycopy(nBytes, 0, b, 32 - nBytes.length, nBytes.length);
+ for (int i = 0; i < b.length / 2; i++) {
+ byte t = b[i];
+ b[i] = b[b.length - i - 1];
+ b[b.length - i - 1] = t;
+ }
+ return b;
+ }
+
+ private static CachedXYT getCachedXYT(Point p) {
+ return new CachedXYT(
+ Field25519.expand(toLittleEndian(p.y.add(p.x).mod(P_BI))),
+ Field25519.expand(toLittleEndian(p.y.subtract(p.x).mod(P_BI))),
+ Field25519.expand(toLittleEndian(D2_BI.multiply(p.x).multiply(p.y).mod(P_BI))));
+ }
+
+ static {
+ Point b = new Point();
+ b.y = BigInteger.valueOf(4).multiply(BigInteger.valueOf(5).modInverse(P_BI)).mod(P_BI);
+ b.x = recoverX(b.y);
+
+ D = Field25519.expand(toLittleEndian(D_BI));
+ D2 = Field25519.expand(toLittleEndian(D2_BI));
+ SQRTM1 = Field25519.expand(toLittleEndian(SQRTM1_BI));
+
+ Point bi = b;
+ B_TABLE = new CachedXYT[32][8];
+ for (int i = 0; i < 32; i++) {
+ Point bij = bi;
+ for (int j = 0; j < 8; j++) {
+ B_TABLE[i][j] = getCachedXYT(bij);
+ bij = edwards(bij, bi);
+ }
+ for (int j = 0; j < 8; j++) {
+ bi = edwards(bi, bi);
+ }
+ }
+ bi = b;
+ Point b2 = edwards(b, b);
+ B2 = new CachedXYT[8];
+ for (int i = 0; i < 8; i++) {
+ B2[i] = getCachedXYT(bi);
+ bi = edwards(bi, b2);
+ }
+ }
+
+ private static final int PUBLIC_KEY_LEN = Field25519.FIELD_LEN;
+ private static final int SIGNATURE_LEN = Field25519.FIELD_LEN * 2;
+
+ /**
+ * Defines field 25519 function based on curve25519-donna C
+ * implementation (mostly identical).
+ *
+ * Field elements are written as an array of signed, 64-bit limbs (an array of longs), least
+ * significant first. The value of the field element is:
+ *
+ *
+ * x[0] + 2^26·x[1] + 2^51·x[2] + 2^77·x[3] + 2^102·x[4] + 2^128·x[5] + 2^153·x[6] + 2^179·x[7] +
+ * 2^204·x[8] + 2^230·x[9],
+ *
+ *
+ * i.e. the limbs are 26, 25, 26, 25, ... bits wide.
+ */
+ private static final class Field25519 {
+ /**
+ * During Field25519 computation, the mixed radix representation may be in different forms:
+ *
+ * - Reduced-size form: the array has size at most 10.
+ *
- Non-reduced-size form: the array is not reduced modulo 2^255 - 19 and has size at most
+ * 19.
+ *
+ *
+ * TODO(quannguyen):
+ *
+ * - Clarify ill-defined terminologies.
+ *
- The reduction procedure is different from DJB's paper
+ * (http://cr.yp.to/ecdh/curve25519-20060209.pdf). The coefficients after reducing degree and
+ * reducing coefficients aren't guaranteed to be in range {-2^25, ..., 2^25}. We should check to
+ * see what's going on.
+ *
- Consider using method mult() everywhere and making product() private.
+ *
+ */
+
+ static final int FIELD_LEN = 32;
+ static final int LIMB_CNT = 10;
+ private static final long TWO_TO_25 = 1 << 25;
+ private static final long TWO_TO_26 = TWO_TO_25 << 1;
+
+ private static final int[] EXPAND_START = {0, 3, 6, 9, 12, 16, 19, 22, 25, 28};
+ private static final int[] EXPAND_SHIFT = {0, 2, 3, 5, 6, 0, 1, 3, 4, 6};
+ private static final int[] MASK = {0x3ffffff, 0x1ffffff};
+ private static final int[] SHIFT = {26, 25};
+
+ /**
+ * Sums two numbers: output = in1 + in2
+ *
+ * On entry: in1, in2 are in reduced-size form.
+ */
+ static void sum(long[] output, long[] in1, long[] in2) {
+ for (int i = 0; i < LIMB_CNT; i++) {
+ output[i] = in1[i] + in2[i];
+ }
+ }
+
+ /**
+ * Sums two numbers: output += in
+ *
+ * On entry: in is in reduced-size form.
+ */
+ static void sum(long[] output, long[] in) {
+ sum(output, output, in);
+ }
+
+ /**
+ * Find the difference of two numbers: output = in1 - in2
+ * (note the order of the arguments!).
+ *
+ * On entry: in1, in2 are in reduced-size form.
+ */
+ static void sub(long[] output, long[] in1, long[] in2) {
+ for (int i = 0; i < LIMB_CNT; i++) {
+ output[i] = in1[i] - in2[i];
+ }
+ }
+
+ /**
+ * Find the difference of two numbers: output = in - output
+ * (note the order of the arguments!).
+ *
+ * On entry: in, output are in reduced-size form.
+ */
+ static void sub(long[] output, long[] in) {
+ sub(output, in, output);
+ }
+
+ /**
+ * Multiply a number by a scalar: output = in * scalar
+ */
+ static void scalarProduct(long[] output, long[] in, long scalar) {
+ for (int i = 0; i < LIMB_CNT; i++) {
+ output[i] = in[i] * scalar;
+ }
+ }
+
+ /**
+ * Multiply two numbers: out = in2 * in
+ *
+ * output must be distinct to both inputs. The inputs are reduced coefficient form,
+ * the output is not.
+ *
+ * out[x] <= 14 * the largest product of the input limbs.
+ */
+ static void product(long[] out, long[] in2, long[] in) {
+ out[0] = in2[0] * in[0];
+ out[1] = in2[0] * in[1]
+ + in2[1] * in[0];
+ out[2] = 2 * in2[1] * in[1]
+ + in2[0] * in[2]
+ + in2[2] * in[0];
+ out[3] = in2[1] * in[2]
+ + in2[2] * in[1]
+ + in2[0] * in[3]
+ + in2[3] * in[0];
+ out[4] = in2[2] * in[2]
+ + 2 * (in2[1] * in[3] + in2[3] * in[1])
+ + in2[0] * in[4]
+ + in2[4] * in[0];
+ out[5] = in2[2] * in[3]
+ + in2[3] * in[2]
+ + in2[1] * in[4]
+ + in2[4] * in[1]
+ + in2[0] * in[5]
+ + in2[5] * in[0];
+ out[6] = 2 * (in2[3] * in[3] + in2[1] * in[5] + in2[5] * in[1])
+ + in2[2] * in[4]
+ + in2[4] * in[2]
+ + in2[0] * in[6]
+ + in2[6] * in[0];
+ out[7] = in2[3] * in[4]
+ + in2[4] * in[3]
+ + in2[2] * in[5]
+ + in2[5] * in[2]
+ + in2[1] * in[6]
+ + in2[6] * in[1]
+ + in2[0] * in[7]
+ + in2[7] * in[0];
+ out[8] = in2[4] * in[4]
+ + 2 * (in2[3] * in[5] + in2[5] * in[3] + in2[1] * in[7] + in2[7] * in[1])
+ + in2[2] * in[6]
+ + in2[6] * in[2]
+ + in2[0] * in[8]
+ + in2[8] * in[0];
+ out[9] = in2[4] * in[5]
+ + in2[5] * in[4]
+ + in2[3] * in[6]
+ + in2[6] * in[3]
+ + in2[2] * in[7]
+ + in2[7] * in[2]
+ + in2[1] * in[8]
+ + in2[8] * in[1]
+ + in2[0] * in[9]
+ + in2[9] * in[0];
+ out[10] =
+ 2 * (in2[5] * in[5] + in2[3] * in[7] + in2[7] * in[3] + in2[1] * in[9] + in2[9] * in[1])
+ + in2[4] * in[6]
+ + in2[6] * in[4]
+ + in2[2] * in[8]
+ + in2[8] * in[2];
+ out[11] = in2[5] * in[6]
+ + in2[6] * in[5]
+ + in2[4] * in[7]
+ + in2[7] * in[4]
+ + in2[3] * in[8]
+ + in2[8] * in[3]
+ + in2[2] * in[9]
+ + in2[9] * in[2];
+ out[12] = in2[6] * in[6]
+ + 2 * (in2[5] * in[7] + in2[7] * in[5] + in2[3] * in[9] + in2[9] * in[3])
+ + in2[4] * in[8]
+ + in2[8] * in[4];
+ out[13] = in2[6] * in[7]
+ + in2[7] * in[6]
+ + in2[5] * in[8]
+ + in2[8] * in[5]
+ + in2[4] * in[9]
+ + in2[9] * in[4];
+ out[14] = 2 * (in2[7] * in[7] + in2[5] * in[9] + in2[9] * in[5])
+ + in2[6] * in[8]
+ + in2[8] * in[6];
+ out[15] = in2[7] * in[8]
+ + in2[8] * in[7]
+ + in2[6] * in[9]
+ + in2[9] * in[6];
+ out[16] = in2[8] * in[8]
+ + 2 * (in2[7] * in[9] + in2[9] * in[7]);
+ out[17] = in2[8] * in[9]
+ + in2[9] * in[8];
+ out[18] = 2 * in2[9] * in[9];
+ }
+
+ /**
+ * Reduce a field element by calling reduceSizeByModularReduction and reduceCoefficients.
+ *
+ * @param input An input array of any length. If the array has 19 elements, it will be used as
+ * temporary buffer and its contents changed.
+ * @param output An output array of size LIMB_CNT. After the call |output[i]| < 2^26 will hold.
+ */
+ static void reduce(long[] input, long[] output) {
+ long[] tmp;
+ if (input.length == 19) {
+ tmp = input;
+ } else {
+ tmp = new long[19];
+ System.arraycopy(input, 0, tmp, 0, input.length);
+ }
+ reduceSizeByModularReduction(tmp);
+ reduceCoefficients(tmp);
+ System.arraycopy(tmp, 0, output, 0, LIMB_CNT);
+ }
+
+ /**
+ * Reduce a long form to a reduced-size form by taking the input mod 2^255 - 19.
+ *
+ * On entry: |output[i]| < 14*2^54
+ * On exit: |output[0..8]| < 280*2^54
+ */
+ static void reduceSizeByModularReduction(long[] output) {
+ // The coefficients x[10], x[11],..., x[18] are eliminated by reduction modulo 2^255 - 19.
+ // For example, the coefficient x[18] is multiplied by 19 and added to the coefficient x[8].
+ //
+ // Each of these shifts and adds ends up multiplying the value by 19.
+ //
+ // For output[0..8], the absolute entry value is < 14*2^54 and we add, at most, 19*14*2^54 thus,
+ // on exit, |output[0..8]| < 280*2^54.
+ output[8] += output[18] << 4;
+ output[8] += output[18] << 1;
+ output[8] += output[18];
+ output[7] += output[17] << 4;
+ output[7] += output[17] << 1;
+ output[7] += output[17];
+ output[6] += output[16] << 4;
+ output[6] += output[16] << 1;
+ output[6] += output[16];
+ output[5] += output[15] << 4;
+ output[5] += output[15] << 1;
+ output[5] += output[15];
+ output[4] += output[14] << 4;
+ output[4] += output[14] << 1;
+ output[4] += output[14];
+ output[3] += output[13] << 4;
+ output[3] += output[13] << 1;
+ output[3] += output[13];
+ output[2] += output[12] << 4;
+ output[2] += output[12] << 1;
+ output[2] += output[12];
+ output[1] += output[11] << 4;
+ output[1] += output[11] << 1;
+ output[1] += output[11];
+ output[0] += output[10] << 4;
+ output[0] += output[10] << 1;
+ output[0] += output[10];
+ }
+
+ /**
+ * Reduce all coefficients of the short form input so that |x| < 2^26.
+ *
+ * On entry: |output[i]| < 280*2^54
+ */
+ static void reduceCoefficients(long[] output) {
+ output[10] = 0;
+
+ for (int i = 0; i < LIMB_CNT; i += 2) {
+ long over = output[i] / TWO_TO_26;
+ // The entry condition (that |output[i]| < 280*2^54) means that over is, at most, 280*2^28 in
+ // the first iteration of this loop. This is added to the next limb and we can approximate the
+ // resulting bound of that limb by 281*2^54.
+ output[i] -= over << 26;
+ output[i + 1] += over;
+
+ // For the first iteration, |output[i+1]| < 281*2^54, thus |over| < 281*2^29. When this is
+ // added to the next limb, the resulting bound can be approximated as 281*2^54.
+ //
+ // For subsequent iterations of the loop, 281*2^54 remains a conservative bound and no
+ // overflow occurs.
+ over = output[i + 1] / TWO_TO_25;
+ output[i + 1] -= over << 25;
+ output[i + 2] += over;
+ }
+ // Now |output[10]| < 281*2^29 and all other coefficients are reduced.
+ output[0] += output[10] << 4;
+ output[0] += output[10] << 1;
+ output[0] += output[10];
+
+ output[10] = 0;
+ // Now output[1..9] are reduced, and |output[0]| < 2^26 + 19*281*2^29 so |over| will be no more
+ // than 2^16.
+ long over = output[0] / TWO_TO_26;
+ output[0] -= over << 26;
+ output[1] += over;
+ // Now output[0,2..9] are reduced, and |output[1]| < 2^25 + 2^16 < 2^26. The bound on
+ // |output[1]| is sufficient to meet our needs.
+ }
+
+ /**
+ * A helpful wrapper around {@ref Field25519#product}: output = in * in2.
+ *
+ * On entry: |in[i]| < 2^27 and |in2[i]| < 2^27.
+ *
+ * The output is reduced degree (indeed, one need only provide storage for 10 limbs) and
+ * |output[i]| < 2^26.
+ */
+ static void mult(long[] output, long[] in, long[] in2) {
+ long[] t = new long[19];
+ product(t, in, in2);
+ // |t[i]| < 2^26
+ reduce(t, output);
+ }
+
+ /**
+ * Square a number: out = in**2
+ *
+ * output must be distinct from the input. The inputs are reduced coefficient form, the output is
+ * not.
+ *
+ * out[x] <= 14 * the largest product of the input limbs.
+ */
+ private static void squareInner(long[] out, long[] in) {
+ out[0] = in[0] * in[0];
+ out[1] = 2 * in[0] * in[1];
+ out[2] = 2 * (in[1] * in[1] + in[0] * in[2]);
+ out[3] = 2 * (in[1] * in[2] + in[0] * in[3]);
+ out[4] = in[2] * in[2]
+ + 4 * in[1] * in[3]
+ + 2 * in[0] * in[4];
+ out[5] = 2 * (in[2] * in[3] + in[1] * in[4] + in[0] * in[5]);
+ out[6] = 2 * (in[3] * in[3] + in[2] * in[4] + in[0] * in[6] + 2 * in[1] * in[5]);
+ out[7] = 2 * (in[3] * in[4] + in[2] * in[5] + in[1] * in[6] + in[0] * in[7]);
+ out[8] = in[4] * in[4]
+ + 2 * (in[2] * in[6] + in[0] * in[8] + 2 * (in[1] * in[7] + in[3] * in[5]));
+ out[9] = 2 * (in[4] * in[5] + in[3] * in[6] + in[2] * in[7] + in[1] * in[8] + in[0] * in[9]);
+ out[10] = 2 * (in[5] * in[5]
+ + in[4] * in[6]
+ + in[2] * in[8]
+ + 2 * (in[3] * in[7] + in[1] * in[9]));
+ out[11] = 2 * (in[5] * in[6] + in[4] * in[7] + in[3] * in[8] + in[2] * in[9]);
+ out[12] = in[6] * in[6]
+ + 2 * (in[4] * in[8] + 2 * (in[5] * in[7] + in[3] * in[9]));
+ out[13] = 2 * (in[6] * in[7] + in[5] * in[8] + in[4] * in[9]);
+ out[14] = 2 * (in[7] * in[7] + in[6] * in[8] + 2 * in[5] * in[9]);
+ out[15] = 2 * (in[7] * in[8] + in[6] * in[9]);
+ out[16] = in[8] * in[8] + 4 * in[7] * in[9];
+ out[17] = 2 * in[8] * in[9];
+ out[18] = 2 * in[9] * in[9];
+ }
+
+ /**
+ * Returns in^2.
+ *
+ * On entry: The |in| argument is in reduced coefficients form and |in[i]| < 2^27.
+ *
+ * On exit: The |output| argument is in reduced coefficients form (indeed, one need only provide
+ * storage for 10 limbs) and |out[i]| < 2^26.
+ */
+ static void square(long[] output, long[] in) {
+ long[] t = new long[19];
+ squareInner(t, in);
+ // |t[i]| < 14*2^54 because the largest product of two limbs will be < 2^(27+27) and SquareInner
+ // adds together, at most, 14 of those products.
+ reduce(t, output);
+ }
+
+ /**
+ * Takes a little-endian, 32-byte number and expands it into mixed radix form.
+ */
+ static long[] expand(byte[] input) {
+ long[] output = new long[LIMB_CNT];
+ for (int i = 0; i < LIMB_CNT; i++) {
+ output[i] = ((((long) (input[EXPAND_START[i]] & 0xff))
+ | ((long) (input[EXPAND_START[i] + 1] & 0xff)) << 8
+ | ((long) (input[EXPAND_START[i] + 2] & 0xff)) << 16
+ | ((long) (input[EXPAND_START[i] + 3] & 0xff)) << 24) >> EXPAND_SHIFT[i]) & MASK[i & 1];
+ }
+ return output;
+ }
+
+ /**
+ * Takes a fully reduced mixed radix form number and contract it into a little-endian, 32-byte
+ * array.
+ *
+ * On entry: |input_limbs[i]| < 2^26
+ */
+ @SuppressWarnings("NarrowingCompoundAssignment")
+ static byte[] contract(long[] inputLimbs) {
+ long[] input = Arrays.copyOf(inputLimbs, LIMB_CNT);
+ for (int j = 0; j < 2; j++) {
+ for (int i = 0; i < 9; i++) {
+ // This calculation is a time-invariant way to make input[i] non-negative by borrowing
+ // from the next-larger limb.
+ int carry = -(int) ((input[i] & (input[i] >> 31)) >> SHIFT[i & 1]);
+ input[i] = input[i] + (carry << SHIFT[i & 1]);
+ input[i + 1] -= carry;
+ }
+
+ // There's no greater limb for input[9] to borrow from, but we can multiply by 19 and borrow
+ // from input[0], which is valid mod 2^255-19.
+ {
+ int carry = -(int) ((input[9] & (input[9] >> 31)) >> 25);
+ input[9] += (carry << 25);
+ input[0] -= (carry * 19);
+ }
+
+ // After the first iteration, input[1..9] are non-negative and fit within 25 or 26 bits,
+ // depending on position. However, input[0] may be negative.
+ }
+
+ // The first borrow-propagation pass above ended with every limb except (possibly) input[0]
+ // non-negative.
+ //
+ // If input[0] was negative after the first pass, then it was because of a carry from input[9].
+ // On entry, input[9] < 2^26 so the carry was, at most, one, since (2**26-1) >> 25 = 1. Thus
+ // input[0] >= -19.
+ //
+ // In the second pass, each limb is decreased by at most one. Thus the second borrow-propagation
+ // pass could only have wrapped around to decrease input[0] again if the first pass left
+ // input[0] negative *and* input[1] through input[9] were all zero. In that case, input[1] is
+ // now 2^25 - 1, and this last borrow-propagation step will leave input[1] non-negative.
+ {
+ int carry = -(int) ((input[0] & (input[0] >> 31)) >> 26);
+ input[0] += (carry << 26);
+ input[1] -= carry;
+ }
+
+ // All input[i] are now non-negative. However, there might be values between 2^25 and 2^26 in a
+ // limb which is, nominally, 25 bits wide.
+ for (int j = 0; j < 2; j++) {
+ for (int i = 0; i < 9; i++) {
+ int carry = (int) (input[i] >> SHIFT[i & 1]);
+ input[i] &= MASK[i & 1];
+ input[i + 1] += carry;
+ }
+ }
+
+ {
+ int carry = (int) (input[9] >> 25);
+ input[9] &= 0x1ffffff;
+ input[0] += 19 * carry;
+ }
+
+ // If the first carry-chain pass, just above, ended up with a carry from input[9], and that
+ // caused input[0] to be out-of-bounds, then input[0] was < 2^26 + 2*19, because the carry was,
+ // at most, two.
+ //
+ // If the second pass carried from input[9] again then input[0] is < 2*19 and the input[9] ->
+ // input[0] carry didn't push input[0] out of bounds.
+
+ // It still remains the case that input might be between 2^255-19 and 2^255. In this case,
+ // input[1..9] must take their maximum value and input[0] must be >= (2^255-19) & 0x3ffffff,
+ // which is 0x3ffffed.
+ int mask = gte((int) input[0], 0x3ffffed);
+ for (int i = 1; i < LIMB_CNT; i++) {
+ mask &= eq((int) input[i], MASK[i & 1]);
+ }
+
+ // mask is either 0xffffffff (if input >= 2^255-19) and zero otherwise. Thus this conditionally
+ // subtracts 2^255-19.
+ input[0] -= mask & 0x3ffffed;
+ input[1] -= mask & 0x1ffffff;
+ for (int i = 2; i < LIMB_CNT; i += 2) {
+ input[i] -= mask & 0x3ffffff;
+ input[i + 1] -= mask & 0x1ffffff;
+ }
+
+ for (int i = 0; i < LIMB_CNT; i++) {
+ input[i] <<= EXPAND_SHIFT[i];
+ }
+ byte[] output = new byte[FIELD_LEN];
+ for (int i = 0; i < LIMB_CNT; i++) {
+ output[EXPAND_START[i]] |= input[i] & 0xff;
+ output[EXPAND_START[i] + 1] |= (input[i] >> 8) & 0xff;
+ output[EXPAND_START[i] + 2] |= (input[i] >> 16) & 0xff;
+ output[EXPAND_START[i] + 3] |= (input[i] >> 24) & 0xff;
+ }
+ return output;
+ }
+
+ /**
+ * Computes inverse of z = z(2^255 - 21)
+ *
+ * Shamelessly copied from agl's code which was shamelessly copied from djb's code. Only the
+ * comment format and the variable namings are different from those.
+ */
+ static void inverse(long[] out, long[] z) {
+ long[] z2 = new long[Field25519.LIMB_CNT];
+ long[] z9 = new long[Field25519.LIMB_CNT];
+ long[] z11 = new long[Field25519.LIMB_CNT];
+ long[] z2To5Minus1 = new long[Field25519.LIMB_CNT];
+ long[] z2To10Minus1 = new long[Field25519.LIMB_CNT];
+ long[] z2To20Minus1 = new long[Field25519.LIMB_CNT];
+ long[] z2To50Minus1 = new long[Field25519.LIMB_CNT];
+ long[] z2To100Minus1 = new long[Field25519.LIMB_CNT];
+ long[] t0 = new long[Field25519.LIMB_CNT];
+ long[] t1 = new long[Field25519.LIMB_CNT];
+
+ square(z2, z); // 2
+ square(t1, z2); // 4
+ square(t0, t1); // 8
+ mult(z9, t0, z); // 9
+ mult(z11, z9, z2); // 11
+ square(t0, z11); // 22
+ mult(z2To5Minus1, t0, z9); // 2^5 - 2^0 = 31
+
+ square(t0, z2To5Minus1); // 2^6 - 2^1
+ square(t1, t0); // 2^7 - 2^2
+ square(t0, t1); // 2^8 - 2^3
+ square(t1, t0); // 2^9 - 2^4
+ square(t0, t1); // 2^10 - 2^5
+ mult(z2To10Minus1, t0, z2To5Minus1); // 2^10 - 2^0
+
+ square(t0, z2To10Minus1); // 2^11 - 2^1
+ square(t1, t0); // 2^12 - 2^2
+ for (int i = 2; i < 10; i += 2) { // 2^20 - 2^10
+ square(t0, t1);
+ square(t1, t0);
+ }
+ mult(z2To20Minus1, t1, z2To10Minus1); // 2^20 - 2^0
+
+ square(t0, z2To20Minus1); // 2^21 - 2^1
+ square(t1, t0); // 2^22 - 2^2
+ for (int i = 2; i < 20; i += 2) { // 2^40 - 2^20
+ square(t0, t1);
+ square(t1, t0);
+ }
+ mult(t0, t1, z2To20Minus1); // 2^40 - 2^0
+
+ square(t1, t0); // 2^41 - 2^1
+ square(t0, t1); // 2^42 - 2^2
+ for (int i = 2; i < 10; i += 2) { // 2^50 - 2^10
+ square(t1, t0);
+ square(t0, t1);
+ }
+ mult(z2To50Minus1, t0, z2To10Minus1); // 2^50 - 2^0
+
+ square(t0, z2To50Minus1); // 2^51 - 2^1
+ square(t1, t0); // 2^52 - 2^2
+ for (int i = 2; i < 50; i += 2) { // 2^100 - 2^50
+ square(t0, t1);
+ square(t1, t0);
+ }
+ mult(z2To100Minus1, t1, z2To50Minus1); // 2^100 - 2^0
+
+ square(t1, z2To100Minus1); // 2^101 - 2^1
+ square(t0, t1); // 2^102 - 2^2
+ for (int i = 2; i < 100; i += 2) { // 2^200 - 2^100
+ square(t1, t0);
+ square(t0, t1);
+ }
+ mult(t1, t0, z2To100Minus1); // 2^200 - 2^0
+
+ square(t0, t1); // 2^201 - 2^1
+ square(t1, t0); // 2^202 - 2^2
+ for (int i = 2; i < 50; i += 2) { // 2^250 - 2^50
+ square(t0, t1);
+ square(t1, t0);
+ }
+ mult(t0, t1, z2To50Minus1); // 2^250 - 2^0
+
+ square(t1, t0); // 2^251 - 2^1
+ square(t0, t1); // 2^252 - 2^2
+ square(t1, t0); // 2^253 - 2^3
+ square(t0, t1); // 2^254 - 2^4
+ square(t1, t0); // 2^255 - 2^5
+ mult(out, t1, z11); // 2^255 - 21
+ }
+
+
+ /**
+ * Returns 0xffffffff iff a == b and zero otherwise.
+ */
+ private static int eq(int a, int b) {
+ a = ~(a ^ b);
+ a &= a << 16;
+ a &= a << 8;
+ a &= a << 4;
+ a &= a << 2;
+ a &= a << 1;
+ return a >> 31;
+ }
+
+ /**
+ * returns 0xffffffff if a >= b and zero otherwise, where a and b are both non-negative.
+ */
+ private static int gte(int a, int b) {
+ a -= b;
+ // a >= 0 iff a >= b.
+ return ~(a >> 31);
+ }
+ }
+
+ // (x = 0, y = 1) point
+ private static final CachedXYT CACHED_NEUTRAL = new CachedXYT(
+ new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+ new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+ new long[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0});
+ private static final PartialXYZT NEUTRAL = new PartialXYZT(
+ new XYZ(new long[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+ new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+ new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0}),
+ new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0});
+
+ /**
+ * Projective point representation (X:Y:Z) satisfying x = X/Z, y = Y/Z
+ *
+ * Note that this is referred as ge_p2 in ref10 impl.
+ * Also note that x = X, y = Y and z = Z below following Java coding style.
+ *
+ * See
+ * Koyama K., Tsuruoka Y. (1993) Speeding up Elliptic Cryptosystems by Using a Signed Binary
+ * Window Method.
+ *
+ * https://hyperelliptic.org/EFD/g1p/auto-twisted-projective.html
+ */
+ private static class XYZ {
+
+ final long[] x;
+ final long[] y;
+ final long[] z;
+
+ XYZ() {
+ this(new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT]);
+ }
+
+ XYZ(long[] x, long[] y, long[] z) {
+ this.x = x;
+ this.y = y;
+ this.z = z;
+ }
+
+ XYZ(XYZ xyz) {
+ x = Arrays.copyOf(xyz.x, Field25519.LIMB_CNT);
+ y = Arrays.copyOf(xyz.y, Field25519.LIMB_CNT);
+ z = Arrays.copyOf(xyz.z, Field25519.LIMB_CNT);
+ }
+
+ XYZ(PartialXYZT partialXYZT) {
+ this();
+ fromPartialXYZT(this, partialXYZT);
+ }
+
+ /**
+ * ge_p1p1_to_p2.c
+ */
+ static XYZ fromPartialXYZT(XYZ out, PartialXYZT in) {
+ Field25519.mult(out.x, in.xyz.x, in.t);
+ Field25519.mult(out.y, in.xyz.y, in.xyz.z);
+ Field25519.mult(out.z, in.xyz.z, in.t);
+ return out;
+ }
+
+ /**
+ * Encodes this point to bytes.
+ */
+ byte[] toBytes() {
+ long[] recip = new long[Field25519.LIMB_CNT];
+ long[] x = new long[Field25519.LIMB_CNT];
+ long[] y = new long[Field25519.LIMB_CNT];
+ Field25519.inverse(recip, z);
+ Field25519.mult(x, this.x, recip);
+ Field25519.mult(y, this.y, recip);
+ byte[] s = Field25519.contract(y);
+ s[31] = (byte) (s[31] ^ (getLsb(x) << 7));
+ return s;
+ }
+
+
+ /**
+ * Best effort fix-timing array comparison.
+ *
+ * @return true if two arrays are equal.
+ */
+ private static boolean bytesEqual(final byte[] x, final byte[] y) {
+ if (x == null || y == null) {
+ return false;
+ }
+ if (x.length != y.length) {
+ return false;
+ }
+ int res = 0;
+ for (int i = 0; i < x.length; i++) {
+ res |= x[i] ^ y[i];
+ }
+ return res == 0;
+ }
+
+ /**
+ * Checks that the point is on curve
+ */
+ boolean isOnCurve() {
+ long[] x2 = new long[Field25519.LIMB_CNT];
+ Field25519.square(x2, x);
+ long[] y2 = new long[Field25519.LIMB_CNT];
+ Field25519.square(y2, y);
+ long[] z2 = new long[Field25519.LIMB_CNT];
+ Field25519.square(z2, z);
+ long[] z4 = new long[Field25519.LIMB_CNT];
+ Field25519.square(z4, z2);
+ long[] lhs = new long[Field25519.LIMB_CNT];
+ // lhs = y^2 - x^2
+ Field25519.sub(lhs, y2, x2);
+ // lhs = z^2 * (y2 - x2)
+ Field25519.mult(lhs, lhs, z2);
+ long[] rhs = new long[Field25519.LIMB_CNT];
+ // rhs = x^2 * y^2
+ Field25519.mult(rhs, x2, y2);
+ // rhs = D * x^2 * y^2
+ Field25519.mult(rhs, rhs, D);
+ // rhs = z^4 + D * x^2 * y^2
+ Field25519.sum(rhs, z4);
+ // Field25519.mult reduces its output, but Field25519.sum does not, so we have to manually
+ // reduce it here.
+ Field25519.reduce(rhs, rhs);
+ // z^2 (y^2 - x^2) == z^4 + D * x^2 * y^2
+ return bytesEqual(Field25519.contract(lhs), Field25519.contract(rhs));
+ }
+ }
+
+ /**
+ * Represents extended projective point representation (X:Y:Z:T) satisfying x = X/Z, y = Y/Z,
+ * XY = ZT
+ *
+ * Note that this is referred as ge_p3 in ref10 impl.
+ * Also note that t = T below following Java coding style.
+ *
+ * See
+ * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited.
+ *
+ * https://hyperelliptic.org/EFD/g1p/auto-twisted-extended.html
+ */
+ private static class XYZT {
+
+ final XYZ xyz;
+ final long[] t;
+
+ XYZT() {
+ this(new XYZ(), new long[Field25519.LIMB_CNT]);
+ }
+
+ XYZT(XYZ xyz, long[] t) {
+ this.xyz = xyz;
+ this.t = t;
+ }
+
+ XYZT(PartialXYZT partialXYZT) {
+ this();
+ fromPartialXYZT(this, partialXYZT);
+ }
+
+ /**
+ * ge_p1p1_to_p2.c
+ */
+ private static XYZT fromPartialXYZT(XYZT out, PartialXYZT in) {
+ Field25519.mult(out.xyz.x, in.xyz.x, in.t);
+ Field25519.mult(out.xyz.y, in.xyz.y, in.xyz.z);
+ Field25519.mult(out.xyz.z, in.xyz.z, in.t);
+ Field25519.mult(out.t, in.xyz.x, in.xyz.y);
+ return out;
+ }
+
+ /**
+ * Decodes {@code s} into an extented projective point.
+ * See Section 5.1.3 Decoding in https://tools.ietf.org/html/rfc8032#section-5.1.3
+ */
+ private static XYZT fromBytesNegateVarTime(byte[] s) throws GeneralSecurityException {
+ long[] x = new long[Field25519.LIMB_CNT];
+ long[] y = Field25519.expand(s);
+ long[] z = new long[Field25519.LIMB_CNT];
+ z[0] = 1;
+ long[] t = new long[Field25519.LIMB_CNT];
+ long[] u = new long[Field25519.LIMB_CNT];
+ long[] v = new long[Field25519.LIMB_CNT];
+ long[] vxx = new long[Field25519.LIMB_CNT];
+ long[] check = new long[Field25519.LIMB_CNT];
+ Field25519.square(u, y);
+ Field25519.mult(v, u, D);
+ Field25519.sub(u, u, z); // u = y^2 - 1
+ Field25519.sum(v, v, z); // v = dy^2 + 1
+
+ long[] v3 = new long[Field25519.LIMB_CNT];
+ Field25519.square(v3, v);
+ Field25519.mult(v3, v3, v); // v3 = v^3
+ Field25519.square(x, v3);
+ Field25519.mult(x, x, v);
+ Field25519.mult(x, x, u); // x = uv^7
+
+ pow2252m3(x, x); // x = (uv^7)^((q-5)/8)
+ Field25519.mult(x, x, v3);
+ Field25519.mult(x, x, u); // x = uv^3(uv^7)^((q-5)/8)
+
+ Field25519.square(vxx, x);
+ Field25519.mult(vxx, vxx, v);
+ Field25519.sub(check, vxx, u); // vx^2-u
+ if (isNonZeroVarTime(check)) {
+ Field25519.sum(check, vxx, u); // vx^2+u
+ if (isNonZeroVarTime(check)) {
+ throw new GeneralSecurityException("Cannot convert given bytes to extended projective "
+ + "coordinates. No square root exists for modulo 2^255-19");
+ }
+ Field25519.mult(x, x, SQRTM1);
+ }
+
+ if (!isNonZeroVarTime(x) && (s[31] & 0xff) >> 7 != 0) {
+ throw new GeneralSecurityException("Cannot convert given bytes to extended projective "
+ + "coordinates. Computed x is zero and encoded x's least significant bit is not zero");
+ }
+ if (getLsb(x) == ((s[31] & 0xff) >> 7)) {
+ neg(x, x);
+ }
+
+ Field25519.mult(t, x, y);
+ return new XYZT(new XYZ(x, y, z), t);
+ }
+ }
+
+ /**
+ * Partial projective point representation ((X:Z),(Y:T)) satisfying x=X/Z, y=Y/T
+ *
+ * Note that this is referred as complete form in the original ref10 impl (ge_p1p1).
+ * Also note that t = T below following Java coding style.
+ *
+ * Although this has the same types as XYZT, it is redefined to have its own type so that it is
+ * readable and 1:1 corresponds to ref10 impl.
+ *
+ * Can be converted to XYZT as follows:
+ * X1 = X * T = x * Z * T = x * Z1
+ * Y1 = Y * Z = y * T * Z = y * Z1
+ * Z1 = Z * T = Z * T
+ * T1 = X * Y = x * Z * y * T = x * y * Z1 = X1Y1 / Z1
+ */
+ private static class PartialXYZT {
+
+ final XYZ xyz;
+ final long[] t;
+
+ PartialXYZT() {
+ this(new XYZ(), new long[Field25519.LIMB_CNT]);
+ }
+
+ PartialXYZT(XYZ xyz, long[] t) {
+ this.xyz = xyz;
+ this.t = t;
+ }
+
+ PartialXYZT(PartialXYZT other) {
+ xyz = new XYZ(other.xyz);
+ t = Arrays.copyOf(other.t, Field25519.LIMB_CNT);
+ }
+ }
+
+ /**
+ * Corresponds to the caching mentioned in the last paragraph of Section 3.1 of
+ * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited.
+ * with Z = 1.
+ */
+ private static class CachedXYT {
+
+ final long[] yPlusX;
+ final long[] yMinusX;
+ final long[] t2d;
+
+ /**
+ * Creates a cached XYZT with Z = 1
+ *
+ * @param yPlusX y + x
+ * @param yMinusX y - x
+ * @param t2d 2d * xy
+ */
+ CachedXYT(long[] yPlusX, long[] yMinusX, long[] t2d) {
+ this.yPlusX = yPlusX;
+ this.yMinusX = yMinusX;
+ this.t2d = t2d;
+ }
+
+ CachedXYT(CachedXYT other) {
+ yPlusX = Arrays.copyOf(other.yPlusX, Field25519.LIMB_CNT);
+ yMinusX = Arrays.copyOf(other.yMinusX, Field25519.LIMB_CNT);
+ t2d = Arrays.copyOf(other.t2d, Field25519.LIMB_CNT);
+ }
+
+ // z is one implicitly, so this just copies {@code in} to {@code output}.
+ void multByZ(long[] output, long[] in) {
+ System.arraycopy(in, 0, output, 0, Field25519.LIMB_CNT);
+ }
+
+ /**
+ * If icopy is 1, copies {@code other} into this point. Time invariant wrt to icopy value.
+ */
+ void copyConditional(CachedXYT other, int icopy) {
+ copyConditional(yPlusX, other.yPlusX, icopy);
+ copyConditional(yMinusX, other.yMinusX, icopy);
+ copyConditional(t2d, other.t2d, icopy);
+ }
+
+ /**
+ * Conditionally copies a reduced-form limb arrays {@code b} into {@code a} if {@code icopy} is 1,
+ * but leave {@code a} unchanged if 'iswap' is 0. Runs in data-invariant time to avoid
+ * side-channel attacks.
+ *
+ *
NOTE that this function requires that {@code icopy} be 1 or 0; other values give wrong
+ * results. Also, the two limb arrays must be in reduced-coefficient, reduced-degree form: the
+ * values in a[10..19] or b[10..19] aren't swapped, and all all values in a[0..9],b[0..9] must
+ * have magnitude less than Integer.MAX_VALUE.
+ */
+ static void copyConditional(long[] a, long[] b, int icopy) {
+ int copy = -icopy;
+ for (int i = 0; i < Field25519.LIMB_CNT; i++) {
+ int x = copy & (((int) a[i]) ^ ((int) b[i]));
+ a[i] = ((int) a[i]) ^ x;
+ }
+ }
+ }
+
+ private static class CachedXYZT extends CachedXYT {
+
+ private final long[] z;
+
+ CachedXYZT() {
+ this(new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT]);
+ }
+
+ /**
+ * ge_p3_to_cached.c
+ */
+ CachedXYZT(XYZT xyzt) {
+ this();
+ Field25519.sum(yPlusX, xyzt.xyz.y, xyzt.xyz.x);
+ Field25519.sub(yMinusX, xyzt.xyz.y, xyzt.xyz.x);
+ System.arraycopy(xyzt.xyz.z, 0, z, 0, Field25519.LIMB_CNT);
+ Field25519.mult(t2d, xyzt.t, D2);
+ }
+
+ /**
+ * Creates a cached XYZT
+ *
+ * @param yPlusX Y + X
+ * @param yMinusX Y - X
+ * @param z Z
+ * @param t2d 2d * (XY/Z)
+ */
+ CachedXYZT(long[] yPlusX, long[] yMinusX, long[] z, long[] t2d) {
+ super(yPlusX, yMinusX, t2d);
+ this.z = z;
+ }
+
+ @Override
+ public void multByZ(long[] output, long[] in) {
+ Field25519.mult(output, in, z);
+ }
+ }
+
+ /**
+ * Addition defined in Section 3.1 of
+ * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited.
+ *
+ * Please note that this is a partial of the operation listed there leaving out the final
+ * conversion from PartialXYZT to XYZT.
+ *
+ * @param extended extended projective point input
+ * @param cached cached projective point input
+ */
+ private static void add(PartialXYZT partialXYZT, XYZT extended, CachedXYT cached) {
+ long[] t = new long[Field25519.LIMB_CNT];
+
+ // Y1 + X1
+ Field25519.sum(partialXYZT.xyz.x, extended.xyz.y, extended.xyz.x);
+
+ // Y1 - X1
+ Field25519.sub(partialXYZT.xyz.y, extended.xyz.y, extended.xyz.x);
+
+ // A = (Y1 - X1) * (Y2 - X2)
+ Field25519.mult(partialXYZT.xyz.y, partialXYZT.xyz.y, cached.yMinusX);
+
+ // B = (Y1 + X1) * (Y2 + X2)
+ Field25519.mult(partialXYZT.xyz.z, partialXYZT.xyz.x, cached.yPlusX);
+
+ // C = T1 * 2d * T2 = 2d * T1 * T2 (2d is written as k in the paper)
+ Field25519.mult(partialXYZT.t, extended.t, cached.t2d);
+
+ // Z1 * Z2
+ cached.multByZ(partialXYZT.xyz.x, extended.xyz.z);
+
+ // D = 2 * Z1 * Z2
+ Field25519.sum(t, partialXYZT.xyz.x, partialXYZT.xyz.x);
+
+ // X3 = B - A
+ Field25519.sub(partialXYZT.xyz.x, partialXYZT.xyz.z, partialXYZT.xyz.y);
+
+ // Y3 = B + A
+ Field25519.sum(partialXYZT.xyz.y, partialXYZT.xyz.z, partialXYZT.xyz.y);
+
+ // Z3 = D + C
+ Field25519.sum(partialXYZT.xyz.z, t, partialXYZT.t);
+
+ // T3 = D - C
+ Field25519.sub(partialXYZT.t, t, partialXYZT.t);
+ }
+
+ /**
+ * Based on the addition defined in Section 3.1 of
+ * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited.
+ *
+ * Please note that this is a partial of the operation listed there leaving out the final
+ * conversion from PartialXYZT to XYZT.
+ *
+ * @param extended extended projective point input
+ * @param cached cached projective point input
+ */
+ private static void sub(PartialXYZT partialXYZT, XYZT extended, CachedXYT cached) {
+ long[] t = new long[Field25519.LIMB_CNT];
+
+ // Y1 + X1
+ Field25519.sum(partialXYZT.xyz.x, extended.xyz.y, extended.xyz.x);
+
+ // Y1 - X1
+ Field25519.sub(partialXYZT.xyz.y, extended.xyz.y, extended.xyz.x);
+
+ // A = (Y1 - X1) * (Y2 + X2)
+ Field25519.mult(partialXYZT.xyz.y, partialXYZT.xyz.y, cached.yPlusX);
+
+ // B = (Y1 + X1) * (Y2 - X2)
+ Field25519.mult(partialXYZT.xyz.z, partialXYZT.xyz.x, cached.yMinusX);
+
+ // C = T1 * 2d * T2 = 2d * T1 * T2 (2d is written as k in the paper)
+ Field25519.mult(partialXYZT.t, extended.t, cached.t2d);
+
+ // Z1 * Z2
+ cached.multByZ(partialXYZT.xyz.x, extended.xyz.z);
+
+ // D = 2 * Z1 * Z2
+ Field25519.sum(t, partialXYZT.xyz.x, partialXYZT.xyz.x);
+
+ // X3 = B - A
+ Field25519.sub(partialXYZT.xyz.x, partialXYZT.xyz.z, partialXYZT.xyz.y);
+
+ // Y3 = B + A
+ Field25519.sum(partialXYZT.xyz.y, partialXYZT.xyz.z, partialXYZT.xyz.y);
+
+ // Z3 = D - C
+ Field25519.sub(partialXYZT.xyz.z, t, partialXYZT.t);
+
+ // T3 = D + C
+ Field25519.sum(partialXYZT.t, t, partialXYZT.t);
+ }
+
+ /**
+ * Doubles {@code p} and puts the result into this PartialXYZT.
+ *
+ * This is based on the addition defined in formula 7 in Section 3.3 of
+ * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited.
+ *
+ * Please note that this is a partial of the operation listed there leaving out the final
+ * conversion from PartialXYZT to XYZT and also this fixes a typo in calculation of Y3 and T3 in
+ * the paper, H should be replaced with A+B.
+ */
+ private static void doubleXYZ(PartialXYZT partialXYZT, XYZ p) {
+ long[] t0 = new long[Field25519.LIMB_CNT];
+
+ // XX = X1^2
+ Field25519.square(partialXYZT.xyz.x, p.x);
+
+ // YY = Y1^2
+ Field25519.square(partialXYZT.xyz.z, p.y);
+
+ // B' = Z1^2
+ Field25519.square(partialXYZT.t, p.z);
+
+ // B = 2 * B'
+ Field25519.sum(partialXYZT.t, partialXYZT.t, partialXYZT.t);
+
+ // A = X1 + Y1
+ Field25519.sum(partialXYZT.xyz.y, p.x, p.y);
+
+ // AA = A^2
+ Field25519.square(t0, partialXYZT.xyz.y);
+
+ // Y3 = YY + XX
+ Field25519.sum(partialXYZT.xyz.y, partialXYZT.xyz.z, partialXYZT.xyz.x);
+
+ // Z3 = YY - XX
+ Field25519.sub(partialXYZT.xyz.z, partialXYZT.xyz.z, partialXYZT.xyz.x);
+
+ // X3 = AA - Y3
+ Field25519.sub(partialXYZT.xyz.x, t0, partialXYZT.xyz.y);
+
+ // T3 = B - Z3
+ Field25519.sub(partialXYZT.t, partialXYZT.t, partialXYZT.xyz.z);
+ }
+
+ /**
+ * Doubles {@code p} and puts the result into this PartialXYZT.
+ */
+ private static void doubleXYZT(PartialXYZT partialXYZT, XYZT p) {
+ doubleXYZ(partialXYZT, p.xyz);
+ }
+
+ /**
+ * Compares two byte values in constant time.
+ */
+ private static int eq(int a, int b) {
+ int r = ~(a ^ b) & 0xff;
+ r &= r << 4;
+ r &= r << 2;
+ r &= r << 1;
+ return (r >> 7) & 1;
+ }
+
+ /**
+ * This is a constant time operation where point b*B*256^pos is stored in {@code t}.
+ * When b is 0, t remains the same (i.e., neutral point).
+ *
+ * Although B_TABLE[32][8] (B_TABLE[i][j] = (j+1)*B*256^i) has j values in [0, 7], the select
+ * method negates the corresponding point if b is negative (which is straight forward in elliptic
+ * curves by just negating y coordinate). Therefore we can get multiples of B with the half of
+ * memory requirements.
+ *
+ * @param t neutral element (i.e., point 0), also serves as output.
+ * @param pos in B[pos][j] = (j+1)*B*256^pos
+ * @param b value in [-8, 8] range.
+ */
+ private static void select(CachedXYT t, int pos, byte b) {
+ int bnegative = (b & 0xff) >> 7;
+ int babs = b - (((-bnegative) & b) << 1);
+
+ t.copyConditional(B_TABLE[pos][0], eq(babs, 1));
+ t.copyConditional(B_TABLE[pos][1], eq(babs, 2));
+ t.copyConditional(B_TABLE[pos][2], eq(babs, 3));
+ t.copyConditional(B_TABLE[pos][3], eq(babs, 4));
+ t.copyConditional(B_TABLE[pos][4], eq(babs, 5));
+ t.copyConditional(B_TABLE[pos][5], eq(babs, 6));
+ t.copyConditional(B_TABLE[pos][6], eq(babs, 7));
+ t.copyConditional(B_TABLE[pos][7], eq(babs, 8));
+
+ long[] yPlusX = Arrays.copyOf(t.yMinusX, Field25519.LIMB_CNT);
+ long[] yMinusX = Arrays.copyOf(t.yPlusX, Field25519.LIMB_CNT);
+ long[] t2d = Arrays.copyOf(t.t2d, Field25519.LIMB_CNT);
+ neg(t2d, t2d);
+ CachedXYT minust = new CachedXYT(yPlusX, yMinusX, t2d);
+ t.copyConditional(minust, bnegative);
+ }
+
+ /**
+ * Computes {@code a}*B
+ * where a = a[0]+256*a[1]+...+256^31 a[31] and
+ * B is the Ed25519 base point (x,4/5) with x positive.
+ *
+ * Preconditions:
+ * a[31] <= 127
+ *
+ * @throws IllegalStateException iff there is arithmetic error.
+ */
+ @SuppressWarnings("NarrowingCompoundAssignment")
+ private static XYZ scalarMultWithBase(byte[] a) {
+ byte[] e = new byte[2 * Field25519.FIELD_LEN];
+ for (int i = 0; i < Field25519.FIELD_LEN; i++) {
+ e[2 * i + 0] = (byte) (((a[i] & 0xff) >> 0) & 0xf);
+ e[2 * i + 1] = (byte) (((a[i] & 0xff) >> 4) & 0xf);
+ }
+ // each e[i] is between 0 and 15
+ // e[63] is between 0 and 7
+
+ // Rewrite e in a way that each e[i] is in [-8, 8].
+ // This can be done since a[63] is in [0, 7], the carry-over onto the most significant byte
+ // a[63] can be at most 1.
+ int carry = 0;
+ for (int i = 0; i < e.length - 1; i++) {
+ e[i] += carry;
+ carry = e[i] + 8;
+ carry >>= 4;
+ e[i] -= carry << 4;
+ }
+ e[e.length - 1] += carry;
+
+ PartialXYZT ret = new PartialXYZT(NEUTRAL);
+ XYZT xyzt = new XYZT();
+ // Although B_TABLE's i can be at most 31 (stores only 32 4bit multiples of B) and we have 64
+ // 4bit values in e array, the below for loop adds cached values by iterating e by two in odd
+ // indices. After the result, we can double the result point 4 times to shift the multiplication
+ // scalar by 4 bits.
+ for (int i = 1; i < e.length; i += 2) {
+ CachedXYT t = new CachedXYT(CACHED_NEUTRAL);
+ select(t, i / 2, e[i]);
+ add(ret, XYZT.fromPartialXYZT(xyzt, ret), t);
+ }
+
+ // Doubles the result 4 times to shift the multiplication scalar 4 bits to get the actual result
+ // for the odd indices in e.
+ XYZ xyz = new XYZ();
+ doubleXYZ(ret, XYZ.fromPartialXYZT(xyz, ret));
+ doubleXYZ(ret, XYZ.fromPartialXYZT(xyz, ret));
+ doubleXYZ(ret, XYZ.fromPartialXYZT(xyz, ret));
+ doubleXYZ(ret, XYZ.fromPartialXYZT(xyz, ret));
+
+ // Add multiples of B for even indices of e.
+ for (int i = 0; i < e.length; i += 2) {
+ CachedXYT t = new CachedXYT(CACHED_NEUTRAL);
+ select(t, i / 2, e[i]);
+ add(ret, XYZT.fromPartialXYZT(xyzt, ret), t);
+ }
+
+ // This check is to protect against flaws, i.e. if there is a computation error through a
+ // faulty CPU or if the implementation contains a bug.
+ XYZ result = new XYZ(ret);
+ if (!result.isOnCurve()) {
+ throw new IllegalStateException("arithmetic error in scalar multiplication");
+ }
+ return result;
+ }
+
+ @SuppressWarnings("NarrowingCompoundAssignment")
+ private static byte[] slide(byte[] a) {
+ byte[] r = new byte[256];
+ // Writes each bit in a[0..31] into r[0..255]:
+ // a = a[0]+256*a[1]+...+256^31*a[31] is equal to
+ // r = r[0]+2*r[1]+...+2^255*r[255]
+ for (int i = 0; i < 256; i++) {
+ r[i] = (byte) (1 & ((a[i >> 3] & 0xff) >> (i & 7)));
+ }
+
+ // Transforms r[i] as odd values in [-15, 15]
+ for (int i = 0; i < 256; i++) {
+ if (r[i] != 0) {
+ for (int b = 1; b <= 6 && i + b < 256; b++) {
+ if (r[i + b] != 0) {
+ if (r[i] + (r[i + b] << b) <= 15) {
+ r[i] += r[i + b] << b;
+ r[i + b] = 0;
+ } else if (r[i] - (r[i + b] << b) >= -15) {
+ r[i] -= r[i + b] << b;
+ for (int k = i + b; k < 256; k++) {
+ if (r[k] == 0) {
+ r[k] = 1;
+ break;
+ }
+ r[k] = 0;
+ }
+ } else {
+ break;
+ }
+ }
+ }
+ }
+ }
+ return r;
+ }
+
+ /**
+ * Computes {@code a}*{@code pointA}+{@code b}*B
+ * where a = a[0]+256*a[1]+...+256^31*a[31].
+ * and b = b[0]+256*b[1]+...+256^31*b[31].
+ * B is the Ed25519 base point (x,4/5) with x positive.
+ *
+ * Note that execution time varies based on the input since this will only be used in verification
+ * of signatures.
+ */
+ private static XYZ doubleScalarMultVarTime(byte[] a, XYZT pointA, byte[] b) {
+ // pointA, 3*pointA, 5*pointA, 7*pointA, 9*pointA, 11*pointA, 13*pointA, 15*pointA
+ CachedXYZT[] pointAArray = new CachedXYZT[8];
+ pointAArray[0] = new CachedXYZT(pointA);
+ PartialXYZT t = new PartialXYZT();
+ doubleXYZT(t, pointA);
+ XYZT doubleA = new XYZT(t);
+ for (int i = 1; i < pointAArray.length; i++) {
+ add(t, doubleA, pointAArray[i - 1]);
+ pointAArray[i] = new CachedXYZT(new XYZT(t));
+ }
+
+ byte[] aSlide = slide(a);
+ byte[] bSlide = slide(b);
+ t = new PartialXYZT(NEUTRAL);
+ XYZT u = new XYZT();
+ int i = 255;
+ for (; i >= 0; i--) {
+ if (aSlide[i] != 0 || bSlide[i] != 0) {
+ break;
+ }
+ }
+ for (; i >= 0; i--) {
+ doubleXYZ(t, new XYZ(t));
+ if (aSlide[i] > 0) {
+ add(t, XYZT.fromPartialXYZT(u, t), pointAArray[aSlide[i] / 2]);
+ } else if (aSlide[i] < 0) {
+ sub(t, XYZT.fromPartialXYZT(u, t), pointAArray[-aSlide[i] / 2]);
+ }
+ if (bSlide[i] > 0) {
+ add(t, XYZT.fromPartialXYZT(u, t), B2[bSlide[i] / 2]);
+ } else if (bSlide[i] < 0) {
+ sub(t, XYZT.fromPartialXYZT(u, t), B2[-bSlide[i] / 2]);
+ }
+ }
+
+ return new XYZ(t);
+ }
+
+ /**
+ * Returns true if {@code in} is nonzero.
+ *
+ * Note that execution time might depend on the input {@code in}.
+ */
+ private static boolean isNonZeroVarTime(long[] in) {
+ long[] inCopy = new long[in.length + 1];
+ System.arraycopy(in, 0, inCopy, 0, in.length);
+ Field25519.reduceCoefficients(inCopy);
+ byte[] bytes = Field25519.contract(inCopy);
+ for (byte b : bytes) {
+ if (b != 0) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns the least significant bit of {@code in}.
+ */
+ private static int getLsb(long[] in) {
+ return Field25519.contract(in)[0] & 1;
+ }
+
+ /**
+ * Negates all values in {@code in} and store it in {@code out}.
+ */
+ private static void neg(long[] out, long[] in) {
+ for (int i = 0; i < in.length; i++) {
+ out[i] = -in[i];
+ }
+ }
+
+ /**
+ * Computes {@code in}^(2^252-3) mod 2^255-19 and puts the result in {@code out}.
+ */
+ private static void pow2252m3(long[] out, long[] in) {
+ long[] t0 = new long[Field25519.LIMB_CNT];
+ long[] t1 = new long[Field25519.LIMB_CNT];
+ long[] t2 = new long[Field25519.LIMB_CNT];
+
+ // z2 = z1^2^1
+ Field25519.square(t0, in);
+
+ // z8 = z2^2^2
+ Field25519.square(t1, t0);
+ for (int i = 1; i < 2; i++) {
+ Field25519.square(t1, t1);
+ }
+
+ // z9 = z1*z8
+ Field25519.mult(t1, in, t1);
+
+ // z11 = z2*z9
+ Field25519.mult(t0, t0, t1);
+
+ // z22 = z11^2^1
+ Field25519.square(t0, t0);
+
+ // z_5_0 = z9*z22
+ Field25519.mult(t0, t1, t0);
+
+ // z_10_5 = z_5_0^2^5
+ Field25519.square(t1, t0);
+ for (int i = 1; i < 5; i++) {
+ Field25519.square(t1, t1);
+ }
+
+ // z_10_0 = z_10_5*z_5_0
+ Field25519.mult(t0, t1, t0);
+
+ // z_20_10 = z_10_0^2^10
+ Field25519.square(t1, t0);
+ for (int i = 1; i < 10; i++) {
+ Field25519.square(t1, t1);
+ }
+
+ // z_20_0 = z_20_10*z_10_0
+ Field25519.mult(t1, t1, t0);
+
+ // z_40_20 = z_20_0^2^20
+ Field25519.square(t2, t1);
+ for (int i = 1; i < 20; i++) {
+ Field25519.square(t2, t2);
+ }
+
+ // z_40_0 = z_40_20*z_20_0
+ Field25519.mult(t1, t2, t1);
+
+ // z_50_10 = z_40_0^2^10
+ Field25519.square(t1, t1);
+ for (int i = 1; i < 10; i++) {
+ Field25519.square(t1, t1);
+ }
+
+ // z_50_0 = z_50_10*z_10_0
+ Field25519.mult(t0, t1, t0);
+
+ // z_100_50 = z_50_0^2^50
+ Field25519.square(t1, t0);
+ for (int i = 1; i < 50; i++) {
+ Field25519.square(t1, t1);
+ }
+
+ // z_100_0 = z_100_50*z_50_0
+ Field25519.mult(t1, t1, t0);
+
+ // z_200_100 = z_100_0^2^100
+ Field25519.square(t2, t1);
+ for (int i = 1; i < 100; i++) {
+ Field25519.square(t2, t2);
+ }
+
+ // z_200_0 = z_200_100*z_100_0
+ Field25519.mult(t1, t2, t1);
+
+ // z_250_50 = z_200_0^2^50
+ Field25519.square(t1, t1);
+ for (int i = 1; i < 50; i++) {
+ Field25519.square(t1, t1);
+ }
+
+ // z_250_0 = z_250_50*z_50_0
+ Field25519.mult(t0, t1, t0);
+
+ // z_252_2 = z_250_0^2^2
+ Field25519.square(t0, t0);
+ for (int i = 1; i < 2; i++) {
+ Field25519.square(t0, t0);
+ }
+
+ // z_252_3 = z_252_2*z1
+ Field25519.mult(out, t0, in);
+ }
+
+ /**
+ * Returns 3 bytes of {@code in} starting from {@code idx} in Little-Endian format.
+ */
+ private static long load3(byte[] in, int idx) {
+ long result;
+ result = (long) in[idx] & 0xff;
+ result |= (long) (in[idx + 1] & 0xff) << 8;
+ result |= (long) (in[idx + 2] & 0xff) << 16;
+ return result;
+ }
+
+ /**
+ * Returns 4 bytes of {@code in} starting from {@code idx} in Little-Endian format.
+ */
+ private static long load4(byte[] in, int idx) {
+ long result = load3(in, idx);
+ result |= (long) (in[idx + 3] & 0xff) << 24;
+ return result;
+ }
+
+ /**
+ * Input:
+ * s[0]+256*s[1]+...+256^63*s[63] = s
+ *
+ * Output:
+ * s[0]+256*s[1]+...+256^31*s[31] = s mod l
+ * where l = 2^252 + 27742317777372353535851937790883648493.
+ * Overwrites s in place.
+ */
+ private static void reduce(byte[] s) {
+ // Observation:
+ // 2^252 mod l is equivalent to -27742317777372353535851937790883648493 mod l
+ // Let m = -27742317777372353535851937790883648493
+ // Thus a*2^252+b mod l is equivalent to a*m+b mod l
+ //
+ // First s is divided into chunks of 21 bits as follows:
+ // s0+2^21*s1+2^42*s3+...+2^462*s23 = s[0]+256*s[1]+...+256^63*s[63]
+ long s0 = 2097151 & load3(s, 0);
+ long s1 = 2097151 & (load4(s, 2) >> 5);
+ long s2 = 2097151 & (load3(s, 5) >> 2);
+ long s3 = 2097151 & (load4(s, 7) >> 7);
+ long s4 = 2097151 & (load4(s, 10) >> 4);
+ long s5 = 2097151 & (load3(s, 13) >> 1);
+ long s6 = 2097151 & (load4(s, 15) >> 6);
+ long s7 = 2097151 & (load3(s, 18) >> 3);
+ long s8 = 2097151 & load3(s, 21);
+ long s9 = 2097151 & (load4(s, 23) >> 5);
+ long s10 = 2097151 & (load3(s, 26) >> 2);
+ long s11 = 2097151 & (load4(s, 28) >> 7);
+ long s12 = 2097151 & (load4(s, 31) >> 4);
+ long s13 = 2097151 & (load3(s, 34) >> 1);
+ long s14 = 2097151 & (load4(s, 36) >> 6);
+ long s15 = 2097151 & (load3(s, 39) >> 3);
+ long s16 = 2097151 & load3(s, 42);
+ long s17 = 2097151 & (load4(s, 44) >> 5);
+ long s18 = 2097151 & (load3(s, 47) >> 2);
+ long s19 = 2097151 & (load4(s, 49) >> 7);
+ long s20 = 2097151 & (load4(s, 52) >> 4);
+ long s21 = 2097151 & (load3(s, 55) >> 1);
+ long s22 = 2097151 & (load4(s, 57) >> 6);
+ long s23 = (load4(s, 60) >> 3);
+ long carry0;
+ long carry1;
+ long carry2;
+ long carry3;
+ long carry4;
+ long carry5;
+ long carry6;
+ long carry7;
+ long carry8;
+ long carry9;
+ long carry10;
+ long carry11;
+ long carry12;
+ long carry13;
+ long carry14;
+ long carry15;
+ long carry16;
+
+ // s23*2^462 = s23*2^210*2^252 is equivalent to s23*2^210*m in mod l
+ // As m is a 125 bit number, the result needs to scattered to 6 limbs (125/21 ceil is 6)
+ // starting from s11 (s11*2^210)
+ // m = [666643, 470296, 654183, -997805, 136657, -683901] in 21-bit limbs
+ s11 += s23 * 666643;
+ s12 += s23 * 470296;
+ s13 += s23 * 654183;
+ s14 -= s23 * 997805;
+ s15 += s23 * 136657;
+ s16 -= s23 * 683901;
+ // s23 = 0;
+
+ s10 += s22 * 666643;
+ s11 += s22 * 470296;
+ s12 += s22 * 654183;
+ s13 -= s22 * 997805;
+ s14 += s22 * 136657;
+ s15 -= s22 * 683901;
+ // s22 = 0;
+
+ s9 += s21 * 666643;
+ s10 += s21 * 470296;
+ s11 += s21 * 654183;
+ s12 -= s21 * 997805;
+ s13 += s21 * 136657;
+ s14 -= s21 * 683901;
+ // s21 = 0;
+
+ s8 += s20 * 666643;
+ s9 += s20 * 470296;
+ s10 += s20 * 654183;
+ s11 -= s20 * 997805;
+ s12 += s20 * 136657;
+ s13 -= s20 * 683901;
+ // s20 = 0;
+
+ s7 += s19 * 666643;
+ s8 += s19 * 470296;
+ s9 += s19 * 654183;
+ s10 -= s19 * 997805;
+ s11 += s19 * 136657;
+ s12 -= s19 * 683901;
+ // s19 = 0;
+
+ s6 += s18 * 666643;
+ s7 += s18 * 470296;
+ s8 += s18 * 654183;
+ s9 -= s18 * 997805;
+ s10 += s18 * 136657;
+ s11 -= s18 * 683901;
+ // s18 = 0;
+
+ // Reduce the bit length of limbs from s6 to s15 to 21-bits.
+ carry6 = (s6 + (1 << 20)) >> 21;
+ s7 += carry6;
+ s6 -= carry6 << 21;
+ carry8 = (s8 + (1 << 20)) >> 21;
+ s9 += carry8;
+ s8 -= carry8 << 21;
+ carry10 = (s10 + (1 << 20)) >> 21;
+ s11 += carry10;
+ s10 -= carry10 << 21;
+ carry12 = (s12 + (1 << 20)) >> 21;
+ s13 += carry12;
+ s12 -= carry12 << 21;
+ carry14 = (s14 + (1 << 20)) >> 21;
+ s15 += carry14;
+ s14 -= carry14 << 21;
+ carry16 = (s16 + (1 << 20)) >> 21;
+ s17 += carry16;
+ s16 -= carry16 << 21;
+
+ carry7 = (s7 + (1 << 20)) >> 21;
+ s8 += carry7;
+ s7 -= carry7 << 21;
+ carry9 = (s9 + (1 << 20)) >> 21;
+ s10 += carry9;
+ s9 -= carry9 << 21;
+ carry11 = (s11 + (1 << 20)) >> 21;
+ s12 += carry11;
+ s11 -= carry11 << 21;
+ carry13 = (s13 + (1 << 20)) >> 21;
+ s14 += carry13;
+ s13 -= carry13 << 21;
+ carry15 = (s15 + (1 << 20)) >> 21;
+ s16 += carry15;
+ s15 -= carry15 << 21;
+
+ // Resume reduction where we left off.
+ s5 += s17 * 666643;
+ s6 += s17 * 470296;
+ s7 += s17 * 654183;
+ s8 -= s17 * 997805;
+ s9 += s17 * 136657;
+ s10 -= s17 * 683901;
+ // s17 = 0;
+
+ s4 += s16 * 666643;
+ s5 += s16 * 470296;
+ s6 += s16 * 654183;
+ s7 -= s16 * 997805;
+ s8 += s16 * 136657;
+ s9 -= s16 * 683901;
+ // s16 = 0;
+
+ s3 += s15 * 666643;
+ s4 += s15 * 470296;
+ s5 += s15 * 654183;
+ s6 -= s15 * 997805;
+ s7 += s15 * 136657;
+ s8 -= s15 * 683901;
+ // s15 = 0;
+
+ s2 += s14 * 666643;
+ s3 += s14 * 470296;
+ s4 += s14 * 654183;
+ s5 -= s14 * 997805;
+ s6 += s14 * 136657;
+ s7 -= s14 * 683901;
+ // s14 = 0;
+
+ s1 += s13 * 666643;
+ s2 += s13 * 470296;
+ s3 += s13 * 654183;
+ s4 -= s13 * 997805;
+ s5 += s13 * 136657;
+ s6 -= s13 * 683901;
+ // s13 = 0;
+
+ s0 += s12 * 666643;
+ s1 += s12 * 470296;
+ s2 += s12 * 654183;
+ s3 -= s12 * 997805;
+ s4 += s12 * 136657;
+ s5 -= s12 * 683901;
+ s12 = 0;
+
+ // Reduce the range of limbs from s0 to s11 to 21-bits.
+ carry0 = (s0 + (1 << 20)) >> 21;
+ s1 += carry0;
+ s0 -= carry0 << 21;
+ carry2 = (s2 + (1 << 20)) >> 21;
+ s3 += carry2;
+ s2 -= carry2 << 21;
+ carry4 = (s4 + (1 << 20)) >> 21;
+ s5 += carry4;
+ s4 -= carry4 << 21;
+ carry6 = (s6 + (1 << 20)) >> 21;
+ s7 += carry6;
+ s6 -= carry6 << 21;
+ carry8 = (s8 + (1 << 20)) >> 21;
+ s9 += carry8;
+ s8 -= carry8 << 21;
+ carry10 = (s10 + (1 << 20)) >> 21;
+ s11 += carry10;
+ s10 -= carry10 << 21;
+
+ carry1 = (s1 + (1 << 20)) >> 21;
+ s2 += carry1;
+ s1 -= carry1 << 21;
+ carry3 = (s3 + (1 << 20)) >> 21;
+ s4 += carry3;
+ s3 -= carry3 << 21;
+ carry5 = (s5 + (1 << 20)) >> 21;
+ s6 += carry5;
+ s5 -= carry5 << 21;
+ carry7 = (s7 + (1 << 20)) >> 21;
+ s8 += carry7;
+ s7 -= carry7 << 21;
+ carry9 = (s9 + (1 << 20)) >> 21;
+ s10 += carry9;
+ s9 -= carry9 << 21;
+ carry11 = (s11 + (1 << 20)) >> 21;
+ s12 += carry11;
+ s11 -= carry11 << 21;
+
+ s0 += s12 * 666643;
+ s1 += s12 * 470296;
+ s2 += s12 * 654183;
+ s3 -= s12 * 997805;
+ s4 += s12 * 136657;
+ s5 -= s12 * 683901;
+ s12 = 0;
+
+ // Carry chain reduction to propagate excess bits from s0 to s5 to the most significant limbs.
+ carry0 = s0 >> 21;
+ s1 += carry0;
+ s0 -= carry0 << 21;
+ carry1 = s1 >> 21;
+ s2 += carry1;
+ s1 -= carry1 << 21;
+ carry2 = s2 >> 21;
+ s3 += carry2;
+ s2 -= carry2 << 21;
+ carry3 = s3 >> 21;
+ s4 += carry3;
+ s3 -= carry3 << 21;
+ carry4 = s4 >> 21;
+ s5 += carry4;
+ s4 -= carry4 << 21;
+ carry5 = s5 >> 21;
+ s6 += carry5;
+ s5 -= carry5 << 21;
+ carry6 = s6 >> 21;
+ s7 += carry6;
+ s6 -= carry6 << 21;
+ carry7 = s7 >> 21;
+ s8 += carry7;
+ s7 -= carry7 << 21;
+ carry8 = s8 >> 21;
+ s9 += carry8;
+ s8 -= carry8 << 21;
+ carry9 = s9 >> 21;
+ s10 += carry9;
+ s9 -= carry9 << 21;
+ carry10 = s10 >> 21;
+ s11 += carry10;
+ s10 -= carry10 << 21;
+ carry11 = s11 >> 21;
+ s12 += carry11;
+ s11 -= carry11 << 21;
+
+ // Do one last reduction as s12 might be 1.
+ s0 += s12 * 666643;
+ s1 += s12 * 470296;
+ s2 += s12 * 654183;
+ s3 -= s12 * 997805;
+ s4 += s12 * 136657;
+ s5 -= s12 * 683901;
+ // s12 = 0;
+
+ carry0 = s0 >> 21;
+ s1 += carry0;
+ s0 -= carry0 << 21;
+ carry1 = s1 >> 21;
+ s2 += carry1;
+ s1 -= carry1 << 21;
+ carry2 = s2 >> 21;
+ s3 += carry2;
+ s2 -= carry2 << 21;
+ carry3 = s3 >> 21;
+ s4 += carry3;
+ s3 -= carry3 << 21;
+ carry4 = s4 >> 21;
+ s5 += carry4;
+ s4 -= carry4 << 21;
+ carry5 = s5 >> 21;
+ s6 += carry5;
+ s5 -= carry5 << 21;
+ carry6 = s6 >> 21;
+ s7 += carry6;
+ s6 -= carry6 << 21;
+ carry7 = s7 >> 21;
+ s8 += carry7;
+ s7 -= carry7 << 21;
+ carry8 = s8 >> 21;
+ s9 += carry8;
+ s8 -= carry8 << 21;
+ carry9 = s9 >> 21;
+ s10 += carry9;
+ s9 -= carry9 << 21;
+ carry10 = s10 >> 21;
+ s11 += carry10;
+ s10 -= carry10 << 21;
+
+ // Serialize the result into the s.
+ s[0] = (byte) s0;
+ s[1] = (byte) (s0 >> 8);
+ s[2] = (byte) ((s0 >> 16) | (s1 << 5));
+ s[3] = (byte) (s1 >> 3);
+ s[4] = (byte) (s1 >> 11);
+ s[5] = (byte) ((s1 >> 19) | (s2 << 2));
+ s[6] = (byte) (s2 >> 6);
+ s[7] = (byte) ((s2 >> 14) | (s3 << 7));
+ s[8] = (byte) (s3 >> 1);
+ s[9] = (byte) (s3 >> 9);
+ s[10] = (byte) ((s3 >> 17) | (s4 << 4));
+ s[11] = (byte) (s4 >> 4);
+ s[12] = (byte) (s4 >> 12);
+ s[13] = (byte) ((s4 >> 20) | (s5 << 1));
+ s[14] = (byte) (s5 >> 7);
+ s[15] = (byte) ((s5 >> 15) | (s6 << 6));
+ s[16] = (byte) (s6 >> 2);
+ s[17] = (byte) (s6 >> 10);
+ s[18] = (byte) ((s6 >> 18) | (s7 << 3));
+ s[19] = (byte) (s7 >> 5);
+ s[20] = (byte) (s7 >> 13);
+ s[21] = (byte) s8;
+ s[22] = (byte) (s8 >> 8);
+ s[23] = (byte) ((s8 >> 16) | (s9 << 5));
+ s[24] = (byte) (s9 >> 3);
+ s[25] = (byte) (s9 >> 11);
+ s[26] = (byte) ((s9 >> 19) | (s10 << 2));
+ s[27] = (byte) (s10 >> 6);
+ s[28] = (byte) ((s10 >> 14) | (s11 << 7));
+ s[29] = (byte) (s11 >> 1);
+ s[30] = (byte) (s11 >> 9);
+ s[31] = (byte) (s11 >> 17);
+ }
+
+ /**
+ * Input:
+ * a[0]+256*a[1]+...+256^31*a[31] = a
+ * b[0]+256*b[1]+...+256^31*b[31] = b
+ * c[0]+256*c[1]+...+256^31*c[31] = c
+ *
+ * Output:
+ * s[0]+256*s[1]+...+256^31*s[31] = (ab+c) mod l
+ * where l = 2^252 + 27742317777372353535851937790883648493.
+ */
+ private static void mulAdd(byte[] s, byte[] a, byte[] b, byte[] c) {
+ // This is very similar to Ed25519.reduce, the difference in here is that it computes ab+c
+ // See Ed25519.reduce for related comments.
+ long a0 = 2097151 & load3(a, 0);
+ long a1 = 2097151 & (load4(a, 2) >> 5);
+ long a2 = 2097151 & (load3(a, 5) >> 2);
+ long a3 = 2097151 & (load4(a, 7) >> 7);
+ long a4 = 2097151 & (load4(a, 10) >> 4);
+ long a5 = 2097151 & (load3(a, 13) >> 1);
+ long a6 = 2097151 & (load4(a, 15) >> 6);
+ long a7 = 2097151 & (load3(a, 18) >> 3);
+ long a8 = 2097151 & load3(a, 21);
+ long a9 = 2097151 & (load4(a, 23) >> 5);
+ long a10 = 2097151 & (load3(a, 26) >> 2);
+ long a11 = (load4(a, 28) >> 7);
+ long b0 = 2097151 & load3(b, 0);
+ long b1 = 2097151 & (load4(b, 2) >> 5);
+ long b2 = 2097151 & (load3(b, 5) >> 2);
+ long b3 = 2097151 & (load4(b, 7) >> 7);
+ long b4 = 2097151 & (load4(b, 10) >> 4);
+ long b5 = 2097151 & (load3(b, 13) >> 1);
+ long b6 = 2097151 & (load4(b, 15) >> 6);
+ long b7 = 2097151 & (load3(b, 18) >> 3);
+ long b8 = 2097151 & load3(b, 21);
+ long b9 = 2097151 & (load4(b, 23) >> 5);
+ long b10 = 2097151 & (load3(b, 26) >> 2);
+ long b11 = (load4(b, 28) >> 7);
+ long c0 = 2097151 & load3(c, 0);
+ long c1 = 2097151 & (load4(c, 2) >> 5);
+ long c2 = 2097151 & (load3(c, 5) >> 2);
+ long c3 = 2097151 & (load4(c, 7) >> 7);
+ long c4 = 2097151 & (load4(c, 10) >> 4);
+ long c5 = 2097151 & (load3(c, 13) >> 1);
+ long c6 = 2097151 & (load4(c, 15) >> 6);
+ long c7 = 2097151 & (load3(c, 18) >> 3);
+ long c8 = 2097151 & load3(c, 21);
+ long c9 = 2097151 & (load4(c, 23) >> 5);
+ long c10 = 2097151 & (load3(c, 26) >> 2);
+ long c11 = (load4(c, 28) >> 7);
+ long s0;
+ long s1;
+ long s2;
+ long s3;
+ long s4;
+ long s5;
+ long s6;
+ long s7;
+ long s8;
+ long s9;
+ long s10;
+ long s11;
+ long s12;
+ long s13;
+ long s14;
+ long s15;
+ long s16;
+ long s17;
+ long s18;
+ long s19;
+ long s20;
+ long s21;
+ long s22;
+ long s23;
+ long carry0;
+ long carry1;
+ long carry2;
+ long carry3;
+ long carry4;
+ long carry5;
+ long carry6;
+ long carry7;
+ long carry8;
+ long carry9;
+ long carry10;
+ long carry11;
+ long carry12;
+ long carry13;
+ long carry14;
+ long carry15;
+ long carry16;
+ long carry17;
+ long carry18;
+ long carry19;
+ long carry20;
+ long carry21;
+ long carry22;
+
+ s0 = c0 + a0 * b0;
+ s1 = c1 + a0 * b1 + a1 * b0;
+ s2 = c2 + a0 * b2 + a1 * b1 + a2 * b0;
+ s3 = c3 + a0 * b3 + a1 * b2 + a2 * b1 + a3 * b0;
+ s4 = c4 + a0 * b4 + a1 * b3 + a2 * b2 + a3 * b1 + a4 * b0;
+ s5 = c5 + a0 * b5 + a1 * b4 + a2 * b3 + a3 * b2 + a4 * b1 + a5 * b0;
+ s6 = c6 + a0 * b6 + a1 * b5 + a2 * b4 + a3 * b3 + a4 * b2 + a5 * b1 + a6 * b0;
+ s7 = c7 + a0 * b7 + a1 * b6 + a2 * b5 + a3 * b4 + a4 * b3 + a5 * b2 + a6 * b1 + a7 * b0;
+ s8 = c8 + a0 * b8 + a1 * b7 + a2 * b6 + a3 * b5 + a4 * b4 + a5 * b3 + a6 * b2 + a7 * b1
+ + a8 * b0;
+ s9 = c9 + a0 * b9 + a1 * b8 + a2 * b7 + a3 * b6 + a4 * b5 + a5 * b4 + a6 * b3 + a7 * b2
+ + a8 * b1 + a9 * b0;
+ s10 = c10 + a0 * b10 + a1 * b9 + a2 * b8 + a3 * b7 + a4 * b6 + a5 * b5 + a6 * b4 + a7 * b3
+ + a8 * b2 + a9 * b1 + a10 * b0;
+ s11 = c11 + a0 * b11 + a1 * b10 + a2 * b9 + a3 * b8 + a4 * b7 + a5 * b6 + a6 * b5 + a7 * b4
+ + a8 * b3 + a9 * b2 + a10 * b1 + a11 * b0;
+ s12 = a1 * b11 + a2 * b10 + a3 * b9 + a4 * b8 + a5 * b7 + a6 * b6 + a7 * b5 + a8 * b4 + a9 * b3
+ + a10 * b2 + a11 * b1;
+ s13 = a2 * b11 + a3 * b10 + a4 * b9 + a5 * b8 + a6 * b7 + a7 * b6 + a8 * b5 + a9 * b4 + a10 * b3
+ + a11 * b2;
+ s14 = a3 * b11 + a4 * b10 + a5 * b9 + a6 * b8 + a7 * b7 + a8 * b6 + a9 * b5 + a10 * b4
+ + a11 * b3;
+ s15 = a4 * b11 + a5 * b10 + a6 * b9 + a7 * b8 + a8 * b7 + a9 * b6 + a10 * b5 + a11 * b4;
+ s16 = a5 * b11 + a6 * b10 + a7 * b9 + a8 * b8 + a9 * b7 + a10 * b6 + a11 * b5;
+ s17 = a6 * b11 + a7 * b10 + a8 * b9 + a9 * b8 + a10 * b7 + a11 * b6;
+ s18 = a7 * b11 + a8 * b10 + a9 * b9 + a10 * b8 + a11 * b7;
+ s19 = a8 * b11 + a9 * b10 + a10 * b9 + a11 * b8;
+ s20 = a9 * b11 + a10 * b10 + a11 * b9;
+ s21 = a10 * b11 + a11 * b10;
+ s22 = a11 * b11;
+ s23 = 0;
+
+ carry0 = (s0 + (1 << 20)) >> 21;
+ s1 += carry0;
+ s0 -= carry0 << 21;
+ carry2 = (s2 + (1 << 20)) >> 21;
+ s3 += carry2;
+ s2 -= carry2 << 21;
+ carry4 = (s4 + (1 << 20)) >> 21;
+ s5 += carry4;
+ s4 -= carry4 << 21;
+ carry6 = (s6 + (1 << 20)) >> 21;
+ s7 += carry6;
+ s6 -= carry6 << 21;
+ carry8 = (s8 + (1 << 20)) >> 21;
+ s9 += carry8;
+ s8 -= carry8 << 21;
+ carry10 = (s10 + (1 << 20)) >> 21;
+ s11 += carry10;
+ s10 -= carry10 << 21;
+ carry12 = (s12 + (1 << 20)) >> 21;
+ s13 += carry12;
+ s12 -= carry12 << 21;
+ carry14 = (s14 + (1 << 20)) >> 21;
+ s15 += carry14;
+ s14 -= carry14 << 21;
+ carry16 = (s16 + (1 << 20)) >> 21;
+ s17 += carry16;
+ s16 -= carry16 << 21;
+ carry18 = (s18 + (1 << 20)) >> 21;
+ s19 += carry18;
+ s18 -= carry18 << 21;
+ carry20 = (s20 + (1 << 20)) >> 21;
+ s21 += carry20;
+ s20 -= carry20 << 21;
+ carry22 = (s22 + (1 << 20)) >> 21;
+ s23 += carry22;
+ s22 -= carry22 << 21;
+
+ carry1 = (s1 + (1 << 20)) >> 21;
+ s2 += carry1;
+ s1 -= carry1 << 21;
+ carry3 = (s3 + (1 << 20)) >> 21;
+ s4 += carry3;
+ s3 -= carry3 << 21;
+ carry5 = (s5 + (1 << 20)) >> 21;
+ s6 += carry5;
+ s5 -= carry5 << 21;
+ carry7 = (s7 + (1 << 20)) >> 21;
+ s8 += carry7;
+ s7 -= carry7 << 21;
+ carry9 = (s9 + (1 << 20)) >> 21;
+ s10 += carry9;
+ s9 -= carry9 << 21;
+ carry11 = (s11 + (1 << 20)) >> 21;
+ s12 += carry11;
+ s11 -= carry11 << 21;
+ carry13 = (s13 + (1 << 20)) >> 21;
+ s14 += carry13;
+ s13 -= carry13 << 21;
+ carry15 = (s15 + (1 << 20)) >> 21;
+ s16 += carry15;
+ s15 -= carry15 << 21;
+ carry17 = (s17 + (1 << 20)) >> 21;
+ s18 += carry17;
+ s17 -= carry17 << 21;
+ carry19 = (s19 + (1 << 20)) >> 21;
+ s20 += carry19;
+ s19 -= carry19 << 21;
+ carry21 = (s21 + (1 << 20)) >> 21;
+ s22 += carry21;
+ s21 -= carry21 << 21;
+
+ s11 += s23 * 666643;
+ s12 += s23 * 470296;
+ s13 += s23 * 654183;
+ s14 -= s23 * 997805;
+ s15 += s23 * 136657;
+ s16 -= s23 * 683901;
+ // s23 = 0;
+
+ s10 += s22 * 666643;
+ s11 += s22 * 470296;
+ s12 += s22 * 654183;
+ s13 -= s22 * 997805;
+ s14 += s22 * 136657;
+ s15 -= s22 * 683901;
+ // s22 = 0;
+
+ s9 += s21 * 666643;
+ s10 += s21 * 470296;
+ s11 += s21 * 654183;
+ s12 -= s21 * 997805;
+ s13 += s21 * 136657;
+ s14 -= s21 * 683901;
+ // s21 = 0;
+
+ s8 += s20 * 666643;
+ s9 += s20 * 470296;
+ s10 += s20 * 654183;
+ s11 -= s20 * 997805;
+ s12 += s20 * 136657;
+ s13 -= s20 * 683901;
+ // s20 = 0;
+
+ s7 += s19 * 666643;
+ s8 += s19 * 470296;
+ s9 += s19 * 654183;
+ s10 -= s19 * 997805;
+ s11 += s19 * 136657;
+ s12 -= s19 * 683901;
+ // s19 = 0;
+
+ s6 += s18 * 666643;
+ s7 += s18 * 470296;
+ s8 += s18 * 654183;
+ s9 -= s18 * 997805;
+ s10 += s18 * 136657;
+ s11 -= s18 * 683901;
+ // s18 = 0;
+
+ carry6 = (s6 + (1 << 20)) >> 21;
+ s7 += carry6;
+ s6 -= carry6 << 21;
+ carry8 = (s8 + (1 << 20)) >> 21;
+ s9 += carry8;
+ s8 -= carry8 << 21;
+ carry10 = (s10 + (1 << 20)) >> 21;
+ s11 += carry10;
+ s10 -= carry10 << 21;
+ carry12 = (s12 + (1 << 20)) >> 21;
+ s13 += carry12;
+ s12 -= carry12 << 21;
+ carry14 = (s14 + (1 << 20)) >> 21;
+ s15 += carry14;
+ s14 -= carry14 << 21;
+ carry16 = (s16 + (1 << 20)) >> 21;
+ s17 += carry16;
+ s16 -= carry16 << 21;
+
+ carry7 = (s7 + (1 << 20)) >> 21;
+ s8 += carry7;
+ s7 -= carry7 << 21;
+ carry9 = (s9 + (1 << 20)) >> 21;
+ s10 += carry9;
+ s9 -= carry9 << 21;
+ carry11 = (s11 + (1 << 20)) >> 21;
+ s12 += carry11;
+ s11 -= carry11 << 21;
+ carry13 = (s13 + (1 << 20)) >> 21;
+ s14 += carry13;
+ s13 -= carry13 << 21;
+ carry15 = (s15 + (1 << 20)) >> 21;
+ s16 += carry15;
+ s15 -= carry15 << 21;
+
+ s5 += s17 * 666643;
+ s6 += s17 * 470296;
+ s7 += s17 * 654183;
+ s8 -= s17 * 997805;
+ s9 += s17 * 136657;
+ s10 -= s17 * 683901;
+ // s17 = 0;
+
+ s4 += s16 * 666643;
+ s5 += s16 * 470296;
+ s6 += s16 * 654183;
+ s7 -= s16 * 997805;
+ s8 += s16 * 136657;
+ s9 -= s16 * 683901;
+ // s16 = 0;
+
+ s3 += s15 * 666643;
+ s4 += s15 * 470296;
+ s5 += s15 * 654183;
+ s6 -= s15 * 997805;
+ s7 += s15 * 136657;
+ s8 -= s15 * 683901;
+ // s15 = 0;
+
+ s2 += s14 * 666643;
+ s3 += s14 * 470296;
+ s4 += s14 * 654183;
+ s5 -= s14 * 997805;
+ s6 += s14 * 136657;
+ s7 -= s14 * 683901;
+ // s14 = 0;
+
+ s1 += s13 * 666643;
+ s2 += s13 * 470296;
+ s3 += s13 * 654183;
+ s4 -= s13 * 997805;
+ s5 += s13 * 136657;
+ s6 -= s13 * 683901;
+ // s13 = 0;
+
+ s0 += s12 * 666643;
+ s1 += s12 * 470296;
+ s2 += s12 * 654183;
+ s3 -= s12 * 997805;
+ s4 += s12 * 136657;
+ s5 -= s12 * 683901;
+ s12 = 0;
+
+ carry0 = (s0 + (1 << 20)) >> 21;
+ s1 += carry0;
+ s0 -= carry0 << 21;
+ carry2 = (s2 + (1 << 20)) >> 21;
+ s3 += carry2;
+ s2 -= carry2 << 21;
+ carry4 = (s4 + (1 << 20)) >> 21;
+ s5 += carry4;
+ s4 -= carry4 << 21;
+ carry6 = (s6 + (1 << 20)) >> 21;
+ s7 += carry6;
+ s6 -= carry6 << 21;
+ carry8 = (s8 + (1 << 20)) >> 21;
+ s9 += carry8;
+ s8 -= carry8 << 21;
+ carry10 = (s10 + (1 << 20)) >> 21;
+ s11 += carry10;
+ s10 -= carry10 << 21;
+
+ carry1 = (s1 + (1 << 20)) >> 21;
+ s2 += carry1;
+ s1 -= carry1 << 21;
+ carry3 = (s3 + (1 << 20)) >> 21;
+ s4 += carry3;
+ s3 -= carry3 << 21;
+ carry5 = (s5 + (1 << 20)) >> 21;
+ s6 += carry5;
+ s5 -= carry5 << 21;
+ carry7 = (s7 + (1 << 20)) >> 21;
+ s8 += carry7;
+ s7 -= carry7 << 21;
+ carry9 = (s9 + (1 << 20)) >> 21;
+ s10 += carry9;
+ s9 -= carry9 << 21;
+ carry11 = (s11 + (1 << 20)) >> 21;
+ s12 += carry11;
+ s11 -= carry11 << 21;
+
+ s0 += s12 * 666643;
+ s1 += s12 * 470296;
+ s2 += s12 * 654183;
+ s3 -= s12 * 997805;
+ s4 += s12 * 136657;
+ s5 -= s12 * 683901;
+ s12 = 0;
+
+ carry0 = s0 >> 21;
+ s1 += carry0;
+ s0 -= carry0 << 21;
+ carry1 = s1 >> 21;
+ s2 += carry1;
+ s1 -= carry1 << 21;
+ carry2 = s2 >> 21;
+ s3 += carry2;
+ s2 -= carry2 << 21;
+ carry3 = s3 >> 21;
+ s4 += carry3;
+ s3 -= carry3 << 21;
+ carry4 = s4 >> 21;
+ s5 += carry4;
+ s4 -= carry4 << 21;
+ carry5 = s5 >> 21;
+ s6 += carry5;
+ s5 -= carry5 << 21;
+ carry6 = s6 >> 21;
+ s7 += carry6;
+ s6 -= carry6 << 21;
+ carry7 = s7 >> 21;
+ s8 += carry7;
+ s7 -= carry7 << 21;
+ carry8 = s8 >> 21;
+ s9 += carry8;
+ s8 -= carry8 << 21;
+ carry9 = s9 >> 21;
+ s10 += carry9;
+ s9 -= carry9 << 21;
+ carry10 = s10 >> 21;
+ s11 += carry10;
+ s10 -= carry10 << 21;
+ carry11 = s11 >> 21;
+ s12 += carry11;
+ s11 -= carry11 << 21;
+
+ s0 += s12 * 666643;
+ s1 += s12 * 470296;
+ s2 += s12 * 654183;
+ s3 -= s12 * 997805;
+ s4 += s12 * 136657;
+ s5 -= s12 * 683901;
+ // s12 = 0;
+
+ carry0 = s0 >> 21;
+ s1 += carry0;
+ s0 -= carry0 << 21;
+ carry1 = s1 >> 21;
+ s2 += carry1;
+ s1 -= carry1 << 21;
+ carry2 = s2 >> 21;
+ s3 += carry2;
+ s2 -= carry2 << 21;
+ carry3 = s3 >> 21;
+ s4 += carry3;
+ s3 -= carry3 << 21;
+ carry4 = s4 >> 21;
+ s5 += carry4;
+ s4 -= carry4 << 21;
+ carry5 = s5 >> 21;
+ s6 += carry5;
+ s5 -= carry5 << 21;
+ carry6 = s6 >> 21;
+ s7 += carry6;
+ s6 -= carry6 << 21;
+ carry7 = s7 >> 21;
+ s8 += carry7;
+ s7 -= carry7 << 21;
+ carry8 = s8 >> 21;
+ s9 += carry8;
+ s8 -= carry8 << 21;
+ carry9 = s9 >> 21;
+ s10 += carry9;
+ s9 -= carry9 << 21;
+ carry10 = s10 >> 21;
+ s11 += carry10;
+ s10 -= carry10 << 21;
+
+ s[0] = (byte) s0;
+ s[1] = (byte) (s0 >> 8);
+ s[2] = (byte) ((s0 >> 16) | (s1 << 5));
+ s[3] = (byte) (s1 >> 3);
+ s[4] = (byte) (s1 >> 11);
+ s[5] = (byte) ((s1 >> 19) | (s2 << 2));
+ s[6] = (byte) (s2 >> 6);
+ s[7] = (byte) ((s2 >> 14) | (s3 << 7));
+ s[8] = (byte) (s3 >> 1);
+ s[9] = (byte) (s3 >> 9);
+ s[10] = (byte) ((s3 >> 17) | (s4 << 4));
+ s[11] = (byte) (s4 >> 4);
+ s[12] = (byte) (s4 >> 12);
+ s[13] = (byte) ((s4 >> 20) | (s5 << 1));
+ s[14] = (byte) (s5 >> 7);
+ s[15] = (byte) ((s5 >> 15) | (s6 << 6));
+ s[16] = (byte) (s6 >> 2);
+ s[17] = (byte) (s6 >> 10);
+ s[18] = (byte) ((s6 >> 18) | (s7 << 3));
+ s[19] = (byte) (s7 >> 5);
+ s[20] = (byte) (s7 >> 13);
+ s[21] = (byte) s8;
+ s[22] = (byte) (s8 >> 8);
+ s[23] = (byte) ((s8 >> 16) | (s9 << 5));
+ s[24] = (byte) (s9 >> 3);
+ s[25] = (byte) (s9 >> 11);
+ s[26] = (byte) ((s9 >> 19) | (s10 << 2));
+ s[27] = (byte) (s10 >> 6);
+ s[28] = (byte) ((s10 >> 14) | (s11 << 7));
+ s[29] = (byte) (s11 >> 1);
+ s[30] = (byte) (s11 >> 9);
+ s[31] = (byte) (s11 >> 17);
+ }
+
+ // The order of the generator as unsigned bytes in little endian order.
+ // (2^252 + 0x14def9dea2f79cd65812631a5cf5d3ed, cf. RFC 7748)
+ private static final byte[] GROUP_ORDER = {
+ (byte) 0xed, (byte) 0xd3, (byte) 0xf5, (byte) 0x5c,
+ (byte) 0x1a, (byte) 0x63, (byte) 0x12, (byte) 0x58,
+ (byte) 0xd6, (byte) 0x9c, (byte) 0xf7, (byte) 0xa2,
+ (byte) 0xde, (byte) 0xf9, (byte) 0xde, (byte) 0x14,
+ (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
+ (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
+ (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
+ (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x10};
+
+ // Checks whether s represents an integer smaller than the order of the group.
+ // This is needed to ensure that EdDSA signatures are non-malleable, as failing to check
+ // the range of S allows to modify signatures (cf. RFC 8032, Section 5.2.7 and Section 8.4.)
+ // @param s an integer in little-endian order.
+ private static boolean isSmallerThanGroupOrder(byte[] s) {
+ for (int j = Field25519.FIELD_LEN - 1; j >= 0; j--) {
+ // compare unsigned bytes
+ int a = s[j] & 0xff;
+ int b = GROUP_ORDER[j] & 0xff;
+ if (a != b) {
+ return a < b;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if the EdDSA {@code signature} with {@code message}, can be verified with
+ * {@code publicKey}.
+ */
+ public static boolean verify(final byte[] message, final byte[] signature,
+ final byte[] publicKey) {
+ try {
+ if (signature.length != SIGNATURE_LEN) {
+ return false;
+ }
+ if (publicKey.length != PUBLIC_KEY_LEN) {
+ return false;
+ }
+ byte[] s = Arrays.copyOfRange(signature, Field25519.FIELD_LEN, SIGNATURE_LEN);
+ if (!isSmallerThanGroupOrder(s)) {
+ return false;
+ }
+ MessageDigest digest = MessageDigest.getInstance("SHA-512");
+ digest.update(signature, 0, Field25519.FIELD_LEN);
+ digest.update(publicKey);
+ digest.update(message);
+ byte[] h = digest.digest();
+ reduce(h);
+
+ XYZT negPublicKey = XYZT.fromBytesNegateVarTime(publicKey);
+ XYZ xyz = doubleScalarMultVarTime(h, negPublicKey, s);
+ byte[] expectedR = xyz.toBytes();
+ for (int i = 0; i < Field25519.FIELD_LEN; i++) {
+ if (expectedR[i] != signature[i]) {
+ return false;
+ }
+ }
+ return true;
+ } catch (final GeneralSecurityException ignored) {
+ return false;
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/updater/SnackbarUpdateShower.kt b/ui/src/main/java/com/wireguard/android/updater/SnackbarUpdateShower.kt
new file mode 100644
index 0000000..e613499
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/updater/SnackbarUpdateShower.kt
@@ -0,0 +1,173 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.updater
+
+import android.content.Intent
+import android.net.Uri
+import android.view.View
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.snackbar.BaseTransientBottomBar
+import com.google.android.material.snackbar.Snackbar
+import com.wireguard.android.R
+import com.wireguard.android.util.ErrorMessages
+import com.wireguard.android.util.QuantityFormatter
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlin.time.Duration.Companion.seconds
+
+class SnackbarUpdateShower(private val fragment: Fragment) {
+ private var lastUserIntervention: Updater.Progress.NeedsUserIntervention? = null
+ private val intentLauncher = fragment.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ lastUserIntervention?.markAsDone()
+ }
+
+ private class SwapableSnackbar(fragment: Fragment, view: View, anchor: View?) {
+ private val actionSnackbar = makeSnackbar(fragment, view, anchor)
+ private val statusSnackbar = makeSnackbar(fragment, view, anchor)
+ private var showingAction: Boolean = false
+ private var showingStatus: Boolean = false
+
+ private fun makeSnackbar(fragment: Fragment, view: View, anchor: View?): Snackbar {
+ val snackbar = Snackbar.make(fragment.requireContext(), view, "", Snackbar.LENGTH_INDEFINITE)
+ if (anchor != null)
+ snackbar.anchorView = anchor
+ snackbar.setTextMaxLines(6)
+ snackbar.behavior = object : BaseTransientBottomBar.Behavior() {
+ override fun canSwipeDismissView(child: View): Boolean {
+ return false
+ }
+ }
+ snackbar.addCallback(object : BaseTransientBottomBar.BaseCallback() {
+ override fun onDismissed(snackbar: Snackbar?, @DismissEvent event: Int) {
+ super.onDismissed(snackbar, event)
+ if (event == DISMISS_EVENT_MANUAL || event == DISMISS_EVENT_ACTION ||
+ (snackbar == actionSnackbar && !showingAction) || (snackbar == statusSnackbar && !showingStatus)
+ )
+ return
+ fragment.lifecycleScope.launch {
+ delay(5.seconds)
+ snackbar?.show()
+ }
+ }
+ })
+ return snackbar
+ }
+
+ fun showAction(text: String, action: String, listener: View.OnClickListener) {
+ if (showingStatus) {
+ showingStatus = false
+ statusSnackbar.dismiss()
+ }
+ actionSnackbar.setText(text)
+ actionSnackbar.setAction(action, listener)
+ if (!showingAction) {
+ actionSnackbar.show()
+ showingAction = true
+ }
+ }
+
+ fun showText(text: String) {
+ if (showingAction) {
+ showingAction = false
+ actionSnackbar.dismiss()
+ }
+ statusSnackbar.setText(text)
+ if (!showingStatus) {
+ statusSnackbar.show()
+ showingStatus = true
+ }
+ }
+
+ fun dismiss() {
+ actionSnackbar.dismiss()
+ statusSnackbar.dismiss()
+ showingAction = false
+ showingStatus = false
+ }
+ }
+
+ fun attach(view: View, anchor: View?) {
+ val snackbar = SwapableSnackbar(fragment, view, anchor)
+ val context = fragment.requireContext()
+
+ Updater.state.onEach { progress ->
+ when (progress) {
+ is Updater.Progress.Complete ->
+ snackbar.dismiss()
+
+ is Updater.Progress.Available ->
+ snackbar.showAction(context.getString(R.string.updater_avalable), context.getString(R.string.updater_action)) {
+ progress.update()
+ }
+
+ is Updater.Progress.NeedsUserIntervention -> {
+ lastUserIntervention = progress
+ intentLauncher.launch(progress.intent)
+ }
+
+ is Updater.Progress.Installing ->
+ snackbar.showText(context.getString(R.string.updater_installing))
+
+ is Updater.Progress.Rechecking ->
+ snackbar.showText(context.getString(R.string.updater_rechecking))
+
+ is Updater.Progress.Downloading -> {
+ if (progress.bytesTotal != 0UL) {
+ snackbar.showText(
+ context.getString(
+ R.string.updater_download_progress,
+ QuantityFormatter.formatBytes(progress.bytesDownloaded.toLong()),
+ QuantityFormatter.formatBytes(progress.bytesTotal.toLong()),
+ progress.bytesDownloaded.toFloat() * 100.0 / progress.bytesTotal.toFloat()
+ )
+ )
+ } else {
+ snackbar.showText(
+ context.getString(
+ R.string.updater_download_progress_nototal,
+ QuantityFormatter.formatBytes(progress.bytesDownloaded.toLong())
+ )
+ )
+ }
+ }
+
+ is Updater.Progress.Failure -> {
+ snackbar.showText(context.getString(R.string.updater_failure, ErrorMessages[progress.error]))
+ delay(5.seconds)
+ progress.retry()
+ }
+
+ is Updater.Progress.Corrupt -> {
+ MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.updater_corrupt_title)
+ .setMessage(R.string.updater_corrupt_message)
+ .setPositiveButton(R.string.updater_corrupt_navigate) { _, _ ->
+ val intent = Intent(Intent.ACTION_VIEW)
+ intent.data = Uri.parse(progress.downloadUrl)
+ try {
+ context.startActivity(intent)
+ } catch (e: Throwable) {
+ Toast.makeText(context, ErrorMessages[e], Toast.LENGTH_SHORT).show()
+ }
+ }.setCancelable(false).setOnDismissListener {
+ val intent = Intent(Intent.ACTION_MAIN)
+ intent.addCategory(Intent.CATEGORY_HOME)
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(intent)
+ System.exit(0)
+ }.show()
+ }
+ }
+ }.launchIn(fragment.lifecycleScope)
+ }
+}
\ No newline at end of file
diff --git a/ui/src/main/java/com/wireguard/android/updater/Updater.kt b/ui/src/main/java/com/wireguard/android/updater/Updater.kt
new file mode 100644
index 0000000..4a3ee50
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/updater/Updater.kt
@@ -0,0 +1,460 @@
+/*
+ * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.updater
+
+import android.Manifest
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageInstaller
+import android.content.pm.PackageManager
+import android.os.Build
+import android.util.Base64
+import android.util.Log
+import androidx.core.content.ContextCompat
+import androidx.core.content.IntentCompat
+import com.wireguard.android.Application
+import com.wireguard.android.BuildConfig
+import com.wireguard.android.activity.MainActivity
+import com.wireguard.android.util.UserKnobs
+import com.wireguard.android.util.applicationScope
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.URL
+import java.nio.charset.StandardCharsets
+import java.security.InvalidKeyException
+import java.security.InvalidParameterException
+import java.security.MessageDigest
+import java.util.UUID
+import kotlin.math.max
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+
+object Updater {
+ private const val TAG = "WireGuard/Updater"
+ private const val UPDATE_URL_FMT = "https://download.wireguard.com/android-client/%s"
+ private const val APK_NAME_PREFIX = BuildConfig.APPLICATION_ID + "-"
+ private const val APK_NAME_SUFFIX = ".apk"
+ private const val LATEST_FILE = "latest.sig"
+ private const val RELEASE_PUBLIC_KEY_BASE64 = "RWTAzwGRYr3EC9px0Ia3fbttz8WcVN6wrOwWp2delz4el6SI8XmkKSMp"
+ private val CURRENT_VERSION by lazy { Version(BuildConfig.VERSION_NAME) }
+
+ private val updaterScope = CoroutineScope(Job() + Dispatchers.IO)
+
+ private fun installer(context: Context): String = try {
+ val packageName = context.packageName
+ val pm = context.packageManager
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ pm.getInstallSourceInfo(packageName).installingPackageName ?: ""
+ } else {
+ @Suppress("DEPRECATION")
+ pm.getInstallerPackageName(packageName) ?: ""
+ }
+ } catch (_: Throwable) {
+ ""
+ }
+
+ fun installerIsGooglePlay(context: Context): Boolean = installer(context) == "com.android.vending"
+
+ sealed class Progress {
+ object Complete : Progress()
+ class Available(val version: String) : Progress() {
+ fun update() {
+ applicationScope.launch {
+ UserKnobs.setUpdaterNewerVersionConsented(version)
+ }
+ }
+ }
+
+ object Rechecking : Progress()
+ class Downloading(val bytesDownloaded: ULong, val bytesTotal: ULong) : Progress()
+ object Installing : Progress()
+ class NeedsUserIntervention(val intent: Intent, private val id: Int) : Progress() {
+
+ private suspend fun installerActive(): Boolean {
+ if (mutableState.firstOrNull() != this@NeedsUserIntervention)
+ return true
+ try {
+ if (Application.get().packageManager.packageInstaller.getSessionInfo(id)?.isActive == true)
+ return true
+ } catch (_: SecurityException) {
+ return true
+ }
+ return false
+ }
+
+ fun markAsDone() {
+ applicationScope.launch {
+ if (installerActive())
+ return@launch
+ delay(7.seconds)
+ if (installerActive())
+ return@launch
+ emitProgress(Failure(Exception("Ignored by user")))
+ }
+ }
+ }
+
+ class Failure(val error: Throwable) : Progress() {
+ fun retry() {
+ updaterScope.launch {
+ downloadAndUpdateWrapErrors()
+ }
+ }
+ }
+
+ class Corrupt(private val betterFile: String?) : Progress() {
+ val downloadUrl: String
+ get() = UPDATE_URL_FMT.format(betterFile ?: "")
+ }
+ }
+
+ private val mutableState = MutableStateFlow