diff --git a/Android/app/build.gradle b/Android/app/build.gradle index a1590b07..fc887fd6 100644 --- a/Android/app/build.gradle +++ b/Android/app/build.gradle @@ -21,6 +21,10 @@ def goSourcePackages = ["${goSourceDir}/backend", "${goSourceDir}/intra/split", "${goSourceDir}/intra/protect"] def goBuildDir = file("${buildDir}/go") +def goExecutableSuffix = System.getProperty('os.name').toLowerCase().contains('windows') ? + '.exe' : '' +def goMobileExecutable = file("${goBuildDir}/gomobile${goExecutableSuffix}") +def goBindExecutable = file("${goBuildDir}/gobind${goExecutableSuffix}") def goBackendAAR = file("${goBuildDir}/backend.aar") // gomobile won't use the Go version in go.mod. So we need to manually read it from go.mod and @@ -156,7 +160,7 @@ tasks.register('compileGoBackend', Exec) { System.getProperty('path.separator') + System.getenv('PATH') - commandLine("${goBuildDir}/gomobile", 'bind', + commandLine(goMobileExecutable, 'bind', '-ldflags=-s -w', '-target=android', "-androidapi=${android.defaultConfig.minSdk}", @@ -166,8 +170,8 @@ tasks.register('compileGoBackend', Exec) { tasks.register('ensureGoMobile', Exec) { // install gomobile and gobind into the build folder - outputs.file("${goBuildDir}/gomobile") - outputs.file("${goBuildDir}/gobind") + outputs.file(goMobileExecutable) + outputs.file(goBindExecutable) doFirst { goBuildDir.mkdirs() } diff --git a/Android/app/src/go/backend/doh.go b/Android/app/src/go/backend/doh.go index 67366898..2e8a03aa 100644 --- a/Android/app/src/go/backend/doh.go +++ b/Android/app/src/go/backend/doh.go @@ -104,16 +104,13 @@ type DoHListener interface { OnResponse(DoHQueryToken, *DoHQuerySumary) } -// DoHStatus is an integer representing the status of a DoH transaction. -type DoHStatus = int - const ( - DoHStatusComplete DoHStatus = doh.Complete // Transaction completed successfully - DoHStatusSendFailed DoHStatus = doh.SendFailed // Failed to send query - DoHStatusHTTPError DoHStatus = doh.HTTPError // Got a non-200 HTTP status - DoHStatusBadQuery DoHStatus = doh.BadQuery // Malformed input - DoHStatusBadResponse DoHStatus = doh.BadResponse // Response was invalid - DoHStatusInternalError DoHStatus = doh.InternalError // This should never happen + DoHStatusComplete = doh.Complete // Transaction completed successfully + DoHStatusSendFailed = doh.SendFailed // Failed to send query + DoHStatusHTTPError = doh.HTTPError // Got a non-200 HTTP status + DoHStatusBadQuery = doh.BadQuery // Malformed input + DoHStatusBadResponse = doh.BadResponse // Response was invalid + DoHStatusInternalError = doh.InternalError // This should never happen ) // DoHQuerySumary is the summary of a DNS transaction. @@ -122,12 +119,12 @@ type DoHQuerySumary struct { summ *doh.Summary } -func (q DoHQuerySumary) GetQuery() []byte { return q.summ.Query } -func (q DoHQuerySumary) GetResponse() []byte { return q.summ.Response } -func (q DoHQuerySumary) GetServer() string { return q.summ.Server } -func (q DoHQuerySumary) GetStatus() DoHStatus { return q.summ.Status } -func (q DoHQuerySumary) GetHTTPStatus() int { return q.summ.HTTPStatus } -func (q DoHQuerySumary) GetLatency() float64 { return q.summ.Latency } +func (q DoHQuerySumary) GetQuery() []byte { return q.summ.Query } +func (q DoHQuerySumary) GetResponse() []byte { return q.summ.Response } +func (q DoHQuerySumary) GetServer() string { return q.summ.Server } +func (q DoHQuerySumary) GetStatus() int { return q.summ.Status } +func (q DoHQuerySumary) GetHTTPStatus() int { return q.summ.HTTPStatus } +func (q DoHQuerySumary) GetLatency() float64 { return q.summ.Latency } // dohListenerAdapter is an adapter for the internal [doh.Listener]. type dohListenerAdapter struct { diff --git a/Android/app/src/go/intra/engine.go b/Android/app/src/go/intra/engine.go new file mode 100644 index 00000000..3e867ccc --- /dev/null +++ b/Android/app/src/go/intra/engine.go @@ -0,0 +1,32 @@ +// Copyright 2026 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package intra + +import ( + "context" + + "localhost/Intra/Android/app/src/go/logging" + + "github.com/Jigsaw-Code/outline-sdk/network" +) + +func newTunnelEngine(ctx context.Context, sd *intraStreamDialer, pp *intraPacketProxy) (network.IPDevice, error) { + if useXJasonlyuEngine { + logging.Debug("Intra tunnel engine selected", "engine", "xjasonlyu") + return newXJasonlyuEngine(ctx, sd, pp) + } + logging.Debug("Intra tunnel engine selected", "engine", "lwip") + return newLWIPTunnelEngine(sd, pp) +} diff --git a/Android/app/src/go/intra/engine_default.go b/Android/app/src/go/intra/engine_default.go new file mode 100644 index 00000000..3f375d19 --- /dev/null +++ b/Android/app/src/go/intra/engine_default.go @@ -0,0 +1,19 @@ +// Copyright 2026 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !xjasonlyu_experiment + +package intra + +const useXJasonlyuEngine = false diff --git a/Android/app/src/go/intra/engine_lwip.go b/Android/app/src/go/intra/engine_lwip.go new file mode 100644 index 00000000..b03f3b4e --- /dev/null +++ b/Android/app/src/go/intra/engine_lwip.go @@ -0,0 +1,24 @@ +// Copyright 2026 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package intra + +import ( + "github.com/Jigsaw-Code/outline-sdk/network" + "github.com/Jigsaw-Code/outline-sdk/network/lwip2transport" +) + +func newLWIPTunnelEngine(sd *intraStreamDialer, pp *intraPacketProxy) (network.IPDevice, error) { + return lwip2transport.ConfigureDevice(sd, pp) +} diff --git a/Android/app/src/go/intra/engine_xjasonlyu.go b/Android/app/src/go/intra/engine_xjasonlyu.go new file mode 100644 index 00000000..56632212 --- /dev/null +++ b/Android/app/src/go/intra/engine_xjasonlyu.go @@ -0,0 +1,297 @@ +//go:build xjasonlyu_experiment + +// Copyright 2026 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package intra + +import ( + "context" + "errors" + "io" + "net" + "net/netip" + "sync" + "time" + + "localhost/Intra/Android/app/src/go/logging" + + "github.com/Jigsaw-Code/outline-sdk/network" + "github.com/Jigsaw-Code/outline-sdk/transport" + "github.com/xjasonlyu/tun2socks/v2/buffer" + "github.com/xjasonlyu/tun2socks/v2/core" + "github.com/xjasonlyu/tun2socks/v2/core/adapter" + "github.com/xjasonlyu/tun2socks/v2/core/device/iobased" + "gvisor.dev/gvisor/pkg/tcpip" +) + +const xjasonlyuMTU = 1500 + +type xjasonlyuEngine struct { + ctx context.Context + cancel context.CancelFunc + device *xjasonlyuPacketDevice + stack interface{ Close() } + sd *intraStreamDialer + pp *intraPacketProxy +} + +var _ adapter.TransportHandler = (*xjasonlyuEngine)(nil) +var _ network.IPDevice = (*xjasonlyuEngine)(nil) + +func newXJasonlyuEngine(ctx context.Context, sd *intraStreamDialer, pp *intraPacketProxy) (network.IPDevice, error) { + engineCtx, cancel := context.WithCancel(ctx) + engine := &xjasonlyuEngine{ + ctx: engineCtx, + cancel: cancel, + device: newXJasonlyuPacketDevice(xjasonlyuMTU), + sd: sd, + pp: pp, + } + + linkEndpoint, err := iobased.New(engine.device.endpointRW(), xjasonlyuMTU, 0) + if err != nil { + cancel() + return nil, err + } + stack, err := core.CreateStack(&core.Config{ + LinkEndpoint: linkEndpoint, + TransportHandler: engine, + }) + if err == nil { + engine.stack = stack + return engine, nil + } + cancel() + return nil, err +} + +func (e *xjasonlyuEngine) HandleTCP(conn adapter.TCPConn) { + go e.handleTCP(conn) +} + +func (e *xjasonlyuEngine) handleTCP(conn adapter.TCPConn) { + defer conn.Close() + + id := conn.ID() + dest, err := tcpipAddrPort(id.LocalAddress, id.LocalPort) + if err != nil { + logging.Warn("xjasonlyu TCP destination parse failed", "error", err) + return + } + + remoteConn, err := e.sd.Dial(e.ctx, dest.String()) + if err != nil { + logging.Warn("xjasonlyu TCP dial failed", "dest", dest.String(), "error", err) + return + } + defer remoteConn.Close() + + pipeXJasonlyuTCP(conn, remoteConn) +} + +func (e *xjasonlyuEngine) HandleUDP(conn adapter.UDPConn) { + go e.handleUDP(conn) +} + +func (e *xjasonlyuEngine) handleUDP(conn adapter.UDPConn) { + defer conn.Close() + + id := conn.ID() + dest, err := tcpipAddrPort(id.LocalAddress, id.LocalPort) + if err != nil { + logging.Warn("xjasonlyu UDP destination parse failed", "error", err) + return + } + + resp := &xjasonlyuUDPResponseReceiver{conn: conn} + req, err := e.pp.NewSession(resp) + if err != nil { + logging.Warn("xjasonlyu UDP session failed", "dest", dest.String(), "error", err) + return + } + defer req.Close() + defer resp.Close() + + buf := buffer.Get(buffer.MaxSegmentSize) + defer buffer.Put(buf) + + for { + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Minute)) + n, _, err := conn.ReadFrom(buf) + if ne, ok := err.(net.Error); ok && ne.Timeout() { + return + } + if err == io.EOF { + return + } + if err != nil { + logging.Debug("xjasonlyu UDP read failed", "dest", dest.String(), "error", err) + return + } + if _, err := req.WriteTo(buf[:n], dest); err != nil { + logging.Warn("xjasonlyu UDP write failed", "dest", dest.String(), "error", err) + return + } + } +} + +func (e *xjasonlyuEngine) Read(p []byte) (int, error) { + return e.device.Read(p) +} + +func (e *xjasonlyuEngine) Write(p []byte) (int, error) { + return e.device.Write(p) +} + +func (e *xjasonlyuEngine) Close() error { + e.cancel() + if e.stack != nil { + e.stack.Close() + } + return e.device.Close() +} + +func (e *xjasonlyuEngine) MTU() int { + return xjasonlyuMTU +} + +func tcpipAddrPort(addr tcpip.Address, port uint16) (netip.AddrPort, error) { + ip, ok := netip.AddrFromSlice(addr.AsSlice()) + if !ok { + return netip.AddrPort{}, errors.New("invalid TCP endpoint address") + } + return netip.AddrPortFrom(ip, port), nil +} + +func pipeXJasonlyuTCP(origin adapter.TCPConn, remote transport.StreamConn) { + var wg sync.WaitGroup + wg.Add(2) + + go copyXJasonlyuTCP(remote, origin, &wg) + go copyXJasonlyuTCP(origin, remote, &wg) + + wg.Wait() +} + +func copyXJasonlyuTCP(dst io.Writer, src io.Reader, wg *sync.WaitGroup) { + defer wg.Done() + + buf := buffer.Get(buffer.RelayBufferSize) + _, _ = io.CopyBuffer(dst, src, buf) + _ = buffer.Put(buf) + + if cr, ok := src.(interface{ CloseRead() error }); ok { + _ = cr.CloseRead() + } + if cw, ok := dst.(interface{ CloseWrite() error }); ok { + _ = cw.CloseWrite() + } + if deadline, ok := dst.(interface{ SetReadDeadline(time.Time) error }); ok { + _ = deadline.SetReadDeadline(time.Now().Add(10 * time.Second)) + } +} + +type xjasonlyuPacketDevice struct { + mtu int + tunToStack chan []byte + stackToTun chan []byte + done chan struct{} + once sync.Once +} + +func newXJasonlyuPacketDevice(mtu int) *xjasonlyuPacketDevice { + return &xjasonlyuPacketDevice{ + mtu: mtu, + tunToStack: make(chan []byte, 1024), + stackToTun: make(chan []byte, 1024), + done: make(chan struct{}), + } +} + +func (d *xjasonlyuPacketDevice) Read(p []byte) (int, error) { + return d.readPacket(p, d.stackToTun) +} + +func (d *xjasonlyuPacketDevice) Write(p []byte) (int, error) { + return d.writePacket(p, d.tunToStack) +} + +func (d *xjasonlyuPacketDevice) Close() error { + d.once.Do(func() { + close(d.done) + }) + return nil +} + +func (d *xjasonlyuPacketDevice) MTU() int { + return d.mtu +} + +func (d *xjasonlyuPacketDevice) endpointRW() io.ReadWriter { + return &xjasonlyuEndpointRW{device: d} +} + +func (d *xjasonlyuPacketDevice) readPacket(p []byte, ch <-chan []byte) (int, error) { + select { + case pkt := <-ch: + return copy(p, pkt), nil + case <-d.done: + return 0, io.EOF + } +} + +func (d *xjasonlyuPacketDevice) writePacket(p []byte, ch chan<- []byte) (int, error) { + if len(p) > d.mtu { + return 0, network.ErrMsgSize + } + pkt := append([]byte(nil), p...) + select { + case ch <- pkt: + return len(p), nil + case <-d.done: + return 0, io.ErrClosedPipe + } +} + +type xjasonlyuEndpointRW struct { + device *xjasonlyuPacketDevice +} + +func (rw *xjasonlyuEndpointRW) Read(p []byte) (int, error) { + return rw.device.readPacket(p, rw.device.tunToStack) +} + +func (rw *xjasonlyuEndpointRW) Write(p []byte) (int, error) { + return rw.device.writePacket(p, rw.device.stackToTun) +} + +type xjasonlyuUDPResponseReceiver struct { + conn adapter.UDPConn + once sync.Once +} + +var _ network.PacketResponseReceiver = (*xjasonlyuUDPResponseReceiver)(nil) + +func (r *xjasonlyuUDPResponseReceiver) WriteFrom(p []byte, source net.Addr) (int, error) { + return r.conn.WriteTo(p, nil) +} + +func (r *xjasonlyuUDPResponseReceiver) Close() error { + var err error + r.once.Do(func() { + err = r.conn.Close() + }) + return err +} diff --git a/Android/app/src/go/intra/engine_xjasonlyu_flag.go b/Android/app/src/go/intra/engine_xjasonlyu_flag.go new file mode 100644 index 00000000..660d8f66 --- /dev/null +++ b/Android/app/src/go/intra/engine_xjasonlyu_flag.go @@ -0,0 +1,19 @@ +// Copyright 2026 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build xjasonlyu_experiment + +package intra + +const useXJasonlyuEngine = true diff --git a/Android/app/src/go/intra/engine_xjasonlyu_stub.go b/Android/app/src/go/intra/engine_xjasonlyu_stub.go new file mode 100644 index 00000000..69c62fb0 --- /dev/null +++ b/Android/app/src/go/intra/engine_xjasonlyu_stub.go @@ -0,0 +1,28 @@ +// Copyright 2026 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !xjasonlyu_experiment + +package intra + +import ( + "context" + "errors" + + "github.com/Jigsaw-Code/outline-sdk/network" +) + +func newXJasonlyuEngine(context.Context, *intraStreamDialer, *intraPacketProxy) (network.IPDevice, error) { + return nil, errors.New("xjasonlyu tun2socks engine requires building with -tags xjasonlyu_experiment") +} diff --git a/Android/app/src/go/intra/tunnel.go b/Android/app/src/go/intra/tunnel.go index a8098dec..bff0e043 100644 --- a/Android/app/src/go/intra/tunnel.go +++ b/Android/app/src/go/intra/tunnel.go @@ -27,7 +27,6 @@ import ( "localhost/Intra/Android/app/src/go/intra/protect" "github.com/Jigsaw-Code/outline-sdk/network" - "github.com/Jigsaw-Code/outline-sdk/network/lwip2transport" ) // Listener receives usage statistics when a UDP or TCP socket is closed, @@ -92,8 +91,8 @@ func NewTunnel( return nil, fmt.Errorf("failed to create packet proxy: %w", err) } - if t.IPDevice, err = lwip2transport.ConfigureDevice(t.sd, t.pp); err != nil { - return nil, fmt.Errorf("failed to configure lwIP stack: %w", err) + if t.IPDevice, err = newTunnelEngine(t.ctx, t.sd, t.pp); err != nil { + return nil, fmt.Errorf("failed to configure tunnel engine: %w", err) } t.SetDNS(dohdns) diff --git a/Android/app/src/main/java/app/intra/net/go/GoVpnAdapter.java b/Android/app/src/main/java/app/intra/net/go/GoVpnAdapter.java index 6b0d379e..b616249d 100644 --- a/Android/app/src/main/java/app/intra/net/go/GoVpnAdapter.java +++ b/Android/app/src/main/java/app/intra/net/go/GoVpnAdapter.java @@ -164,8 +164,8 @@ private static ParcelFileDescriptor establishVpn(IntraVpnService vpnService) { .setSession("Intra go-tun2socks VPN") .setMtu(VPN_INTERFACE_MTU) .addAddress(LanIp.GATEWAY.make(IPV4_TEMPLATE), IPV4_PREFIX_LENGTH) - .addRoute("0.0.0.0", 0) - .addDnsServer(LanIp.DNS.make(IPV4_TEMPLATE)); + .addDnsServer(LanIp.DNS.make(IPV4_TEMPLATE)) + .addRoute("0.0.0.0", 0); if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { builder.addDisallowedApplication(vpnService.getPackageName()); } diff --git a/Android/app/src/main/java/app/intra/ui/IntroDialog.java b/Android/app/src/main/java/app/intra/ui/IntroDialog.java index d22958a3..83bcf273 100644 --- a/Android/app/src/main/java/app/intra/ui/IntroDialog.java +++ b/Android/app/src/main/java/app/intra/ui/IntroDialog.java @@ -114,8 +114,15 @@ public void onClick(View view) { } }); - pager.registerOnPageChangeCallback( - new ButtonVisibilityUpdater(backButton, nextButton, acceptButton)); + final ButtonVisibilityUpdater buttonVisibilityUpdater = + new ButtonVisibilityUpdater(backButton, nextButton, acceptButton); + pager.registerOnPageChangeCallback(buttonVisibilityUpdater); + pager.post(new Runnable() { + @Override + public void run() { + buttonVisibilityUpdater.update(pager.getCurrentItem()); + } + }); // Register the dots for actions and updates. TabLayout dots = welcomeView.findViewById(R.id.intro_dots); @@ -207,6 +214,10 @@ private static class ButtonVisibilityUpdater extends ViewPager2.OnPageChangeCall @Override public void onPageSelected(int position) { super.onPageSelected(position); + update(position); + } + + void update(int position) { backButton.setVisibility(position == 0 ? View.GONE : View.VISIBLE); nextButton.setVisibility(position == NUM_PAGES - 1 ? View.GONE : View.VISIBLE); acceptButton.setVisibility(position == NUM_PAGES - 1 ? View.VISIBLE : View.GONE); diff --git a/Android/app/src/main/java/app/intra/ui/settings/ServerChooserFragment.java b/Android/app/src/main/java/app/intra/ui/settings/ServerChooserFragment.java index 8dcfcc67..93cec8db 100644 --- a/Android/app/src/main/java/app/intra/ui/settings/ServerChooserFragment.java +++ b/Android/app/src/main/java/app/intra/ui/settings/ServerChooserFragment.java @@ -35,8 +35,8 @@ import androidx.preference.PreferenceDialogFragmentCompat; import app.intra.R; import app.intra.sys.PersistentState; -import java.net.MalformedURLException; -import java.net.URL; +import java.net.URI; +import java.net.URISyntaxException; import java.util.Arrays; /** @@ -73,7 +73,7 @@ static ServerChooserFragment newInstance(String key) { private String getUrl() { int checkedId = buttons.getCheckedRadioButtonId(); if (checkedId == R.id.pref_server_custom) { - return customServerUrl.getText().toString(); + return normalizeCustomUrl(customServerUrl.getText().toString()); } return urls[spinner.getSelectedItemPosition()]; @@ -87,7 +87,7 @@ private void updateUI() { description.setEnabled(!custom); serverWebsite.setEnabled(!custom); if (custom) { - setValid(checkUrl(Untemplate.strip(getUrl()))); + setValid(isValidCustomUrl(getUrl())); } else { setValid(true); } @@ -111,14 +111,18 @@ public void afterTextChanged(Editable s) { updateUI(); } - // Check that the URL is a plausible DOH server: https with a domain, a path (at least "/"), - // and no query parameters or fragment. - private boolean checkUrl(String url) { + static String normalizeCustomUrl(String url) { + return url == null ? "" : url.trim(); + } + + // Check that the URL is a plausible DoH server: https with a domain, a path (at least "/"), + // and no query parameters or fragment after removing URI-template variables. + static boolean isValidCustomUrl(String url) { try { - URL parsed = new URL(url); - return parsed.getProtocol().equals("https") && !parsed.getHost().isEmpty() && - !parsed.getPath().isEmpty() && parsed.getQuery() == null && parsed.getRef() == null; - } catch (MalformedURLException e) { + URI parsed = new URI(Untemplate.strip(normalizeCustomUrl(url))); + return "https".equals(parsed.getScheme()) && parsed.getHost() != null && + !parsed.getPath().isEmpty() && parsed.getQuery() == null && parsed.getFragment() == null; + } catch (URISyntaxException e) { return false; } } @@ -128,7 +132,7 @@ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { // Usability optimization: // If the user is typing in the free text field and presses "enter" or "go" on the keyboard // while the URL is valid, treat that the same as closing the keyboard and pressing "OK". - if (checkUrl(Untemplate.strip(v.getText().toString()))) { + if (isValidCustomUrl(v.getText().toString())) { Dialog dialog = getDialog(); if (dialog instanceof AlertDialog) { Button ok = ((AlertDialog)dialog).getButton(AlertDialog.BUTTON_POSITIVE); diff --git a/Android/app/src/main/java/app/intra/ui/settings/SettingsFragment.java b/Android/app/src/main/java/app/intra/ui/settings/SettingsFragment.java index a0ec2d93..4388bb9e 100644 --- a/Android/app/src/main/java/app/intra/ui/settings/SettingsFragment.java +++ b/Android/app/src/main/java/app/intra/ui/settings/SettingsFragment.java @@ -112,7 +112,7 @@ public void onDisplayPreferenceDialog(Preference preference) { DialogFragment dialogFragment = ServerChooserFragment.newInstance(preference.getKey()); dialogFragment.setTargetFragment(this, 0); dialogFragment.show(getFragmentManager(), null); - } else { + } else if (preference == appPref) { // This is the app exclusion dialog. final ArrayList appList = getAppList(); if (appList != null) { @@ -128,6 +128,8 @@ public void onDisplayPreferenceDialog(Preference preference) { appPref.setEntryValues(packageNames); } + super.onDisplayPreferenceDialog(preference); + } else { super.onDisplayPreferenceDialog(preference); } } diff --git a/Android/app/src/main/res/xml/preferences.xml b/Android/app/src/main/res/xml/preferences.xml index 691b0822..f9b7ff59 100644 --- a/Android/app/src/main/res/xml/preferences.xml +++ b/Android/app/src/main/res/xml/preferences.xml @@ -9,4 +9,4 @@ android:title="@string/excluded_apps" android:summary="@string/excluded_apps_summary" android:dialogTitle="@string/excluded_apps_title"/> - \ No newline at end of file + diff --git a/Android/app/src/test/java/app/intra/ui/settings/ServerChooserFragmentTest.java b/Android/app/src/test/java/app/intra/ui/settings/ServerChooserFragmentTest.java new file mode 100644 index 00000000..862bffce --- /dev/null +++ b/Android/app/src/test/java/app/intra/ui/settings/ServerChooserFragmentTest.java @@ -0,0 +1,74 @@ +/* +Copyright 2026 Jigsaw Operations LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package app.intra.ui.settings; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class ServerChooserFragmentTest { + + @Test + public void testValidCustomUrl() throws Exception { + assertTrue(ServerChooserFragment.isValidCustomUrl("https://foo.example/dns-query")); + assertTrue(ServerChooserFragment.isValidCustomUrl("https://foo.example/")); + assertTrue(ServerChooserFragment.isValidCustomUrl(" https://foo.example/dns-query\n")); + } + + @Test + public void testValidCustomUrlTemplate() throws Exception { + assertTrue(ServerChooserFragment.isValidCustomUrl("https://foo.example/dns-query{?dns}")); + } + + @Test + public void testInvalidCustomUrlScheme() throws Exception { + assertFalse(ServerChooserFragment.isValidCustomUrl("http://foo.example/dns-query")); + } + + @Test + public void testInvalidCustomUrlHost() throws Exception { + assertFalse(ServerChooserFragment.isValidCustomUrl("https:///dns-query")); + } + + @Test + public void testInvalidCustomUrlPath() throws Exception { + assertFalse(ServerChooserFragment.isValidCustomUrl("https://foo.example")); + } + + @Test + public void testInvalidCustomUrlQuery() throws Exception { + assertFalse(ServerChooserFragment.isValidCustomUrl("https://foo.example/dns-query?dns=abc")); + } + + @Test + public void testInvalidCustomUrlFragment() throws Exception { + assertFalse(ServerChooserFragment.isValidCustomUrl("https://foo.example/dns-query#dns")); + } + + @Test + public void testInvalidCustomUrlMalformed() throws Exception { + assertFalse(ServerChooserFragment.isValidCustomUrl("not a url")); + assertFalse(ServerChooserFragment.isValidCustomUrl("https://foo.example/dns query")); + assertFalse(ServerChooserFragment.isValidCustomUrl("https://foo.example:bad/dns-query")); + assertFalse(ServerChooserFragment.isValidCustomUrl(null)); + } + + @Test + public void testNormalizeCustomUrl() throws Exception { + assertEquals("https://foo.example/dns-query", + ServerChooserFragment.normalizeCustomUrl(" https://foo.example/dns-query\n")); + } +} diff --git a/Android/gradle/verification-metadata.xml b/Android/gradle/verification-metadata.xml index 3cd57ea5..ef787599 100644 --- a/Android/gradle/verification-metadata.xml +++ b/Android/gradle/verification-metadata.xml @@ -594,6 +594,9 @@ + + + diff --git a/go.mod b/go.mod index 9f6368f2..6f5a7290 100644 --- a/go.mod +++ b/go.mod @@ -1,25 +1,29 @@ module localhost/Intra -go 1.21.1 +go 1.23.1 require ( github.com/Jigsaw-Code/choir v1.0.1 github.com/Jigsaw-Code/getsni v1.0.0 github.com/Jigsaw-Code/outline-sdk v0.0.7 - github.com/stretchr/testify v1.8.2 + github.com/stretchr/testify v1.9.0 + github.com/xjasonlyu/tun2socks/v2 v2.6.0 golang.org/x/mobile v0.0.0-20231006135142-2b44d11868fe - golang.org/x/net v0.16.0 - golang.org/x/sys v0.13.0 + golang.org/x/net v0.40.0 + golang.org/x/sys v0.33.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/eycorsican/go-tun2socks v1.16.11 // indirect + github.com/google/btree v1.1.3 // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/crypto v0.14.0 // indirect - golang.org/x/mod v0.13.0 // indirect - golang.org/x/sync v0.4.0 // indirect - golang.org/x/tools v0.14.0 // indirect + golang.org/x/crypto v0.38.0 // indirect + golang.org/x/mod v0.21.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/time v0.11.0 // indirect + golang.org/x/tools v0.26.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20 // indirect ) diff --git a/go.sum b/go.sum index 9af645de..93c83c61 100644 --- a/go.sum +++ b/go.sum @@ -5,11 +5,12 @@ github.com/Jigsaw-Code/getsni v1.0.0/go.mod h1:Ps0Ec3fVMKLyAItVbMKoQFq1lDjtFQXZ+ github.com/Jigsaw-Code/outline-sdk v0.0.7 h1:WlFaV1tFpIQ/pflrKwrQuNIP3kJpgh7yJuqiTb54sGA= github.com/Jigsaw-Code/outline-sdk v0.0.7/go.mod h1:hhlKz0+r9wSDFT8usvN8Zv/BFToCIFAUn1P2Qk8G2CM= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/eycorsican/go-tun2socks v1.16.11 h1:+hJDNgisrYaGEqoSxhdikMgMJ4Ilfwm/IZDrWRrbaH8= github.com/eycorsican/go-tun2socks v1.16.11/go.mod h1:wgB2BFT8ZaPKyKOQ/5dljMG/YIow+AIXyq4KBwJ5sGQ= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -17,38 +18,38 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/songgao/water v0.0.0-20190725173103-fd331bda3f4b/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xjasonlyu/tun2socks/v2 v2.6.0 h1:gI9saJT3XgH4e6v9jBuHRLwK7l3aN9YFWec/SsDTDx4= +github.com/xjasonlyu/tun2socks/v2 v2.6.0/go.mod h1:35AwqxIxnMkfBfT0UJ1Lku7PZm2ZiZJ8sxHyp0gt1yw= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +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/mobile v0.0.0-20231006135142-2b44d11868fe h1:lrXv4yHeD9FA8PSJATWowP1QvexpyAPWmPia+Kbzql8= golang.org/x/mobile v0.0.0-20231006135142-2b44d11868fe/go.mod h1:BrnXpEObnFxpaT75Jo9hsCazwOWcp7nVIa8NNuH5cuA= -golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= -golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20191021144547-ec77196f6094/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos= -golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= -golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +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/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= -golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20 h1:0DxLu8hxI1OGp1qVRPqNd+2k1a7hMNUNqbZG0IrtKlM= +gvisor.dev/gvisor v0.0.0-20250523182742-eede7a881b20/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=