Store the Android split tunnelling settings per profile (#7349)

Which applications the tunnel carries belongs with the rest of a
profile's preferences rather than on the Android side, so the choice
follows the profile the user is on.

Adds a split tunnel store beside the SSH session store, over a
"split-tunnel" namespace holding the mode and both selections. The two
selections are kept apart because the platform applies an allow list or
a deny list and never both, and so that switching mode does not throw
away the picks made in the other one.

Only the store itself needs the android build tag; the rest stays
untagged so it is covered by the package's host tests.
This commit is contained in:
eYey
2026-08-31 10:29:05 +02:00
committed by GitHub
parent 11733fd718
commit 945b0b6be2
3 changed files with 249 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
package android
// Split tunnelling modes, stored as strings so an unknown value written by a
// newer build degrades to "off" rather than to some other mode's behaviour.
const (
SplitTunnelModeOff = "off"
SplitTunnelModeExclude = "exclude"
SplitTunnelModeInclude = "include"
)
type splitTunnelSection struct {
Mode string `json:"mode"`
Excluded []string `json:"excluded"`
Included []string `json:"included"`
}
// PackageList wraps []string for gomobile compatibility.
type PackageList struct {
items []string
}
// NewPackageList creates an empty list to fill via Add.
func NewPackageList() *PackageList {
return &PackageList{}
}
// Add appends a package name, ignoring empty ones.
func (l *PackageList) Add(s string) {
if s == "" {
return
}
l.items = append(l.items, s)
}
// Size returns the number of entries.
func (l *PackageList) Size() int {
return len(l.items)
}
// Get returns the entry at index i, or an empty string when out of range.
func (l *PackageList) Get(i int) string {
if i < 0 || i >= len(l.items) {
return ""
}
return l.items[i]
}
// SplitTunnelSettings is one profile's choice of which applications the tunnel
// carries. The two selections are kept apart because the platform applies one
// or the other and never both, and so that switching mode does not throw away
// the picks made in the other one.
type SplitTunnelSettings struct {
Mode string
Excluded *PackageList
Included *PackageList
}
// NewSplitTunnelSettings creates settings that carry every application.
func NewSplitTunnelSettings() *SplitTunnelSettings {
return &SplitTunnelSettings{
Mode: SplitTunnelModeOff,
Excluded: NewPackageList(),
Included: NewPackageList(),
}
}
func packagesOf(list *PackageList) []string {
if list == nil {
return nil
}
out := make([]string, 0, len(list.items))
out = append(out, list.items...)
return out
}
func normalizeSplitTunnelMode(mode string) string {
switch mode {
case SplitTunnelModeExclude, SplitTunnelModeInclude:
return mode
default:
return SplitTunnelModeOff
}
}
func settingsFromSection(section splitTunnelSection) *SplitTunnelSettings {
out := NewSplitTunnelSettings()
out.Mode = normalizeSplitTunnelMode(section.Mode)
for _, pkg := range section.Excluded {
out.Excluded.Add(pkg)
}
for _, pkg := range section.Included {
out.Included.Add(pkg)
}
return out
}
func sectionFromSettings(settings *SplitTunnelSettings) splitTunnelSection {
if settings == nil {
settings = NewSplitTunnelSettings()
}
return splitTunnelSection{
Mode: normalizeSplitTunnelMode(settings.Mode),
Excluded: packagesOf(settings.Excluded),
Included: packagesOf(settings.Included),
}
}

View File

@@ -0,0 +1,34 @@
//go:build android
package android
const splitTunnelNamespace = "split-tunnel"
// SplitTunnelStore reads and writes a profile's split tunnelling settings.
type SplitTunnelStore struct {
prefs prefsStore
}
// NewSplitTunnelStore opens the split tunnelling store of the given profile.
func NewSplitTunnelStore(configDir, profileID string) (*SplitTunnelStore, error) {
prefs, err := newProfilePrefs(configDir, profileID)
if err != nil {
return nil, err
}
return &SplitTunnelStore{prefs: prefs}, nil
}
// Load returns the stored settings, or settings that carry every application
// when the profile has none saved.
func (s *SplitTunnelStore) Load() (*SplitTunnelSettings, error) {
var section splitTunnelSection
if _, err := s.prefs.Get(splitTunnelNamespace, &section); err != nil {
return nil, err
}
return settingsFromSection(section), nil
}
// Save replaces the stored settings.
func (s *SplitTunnelStore) Save(settings *SplitTunnelSettings) error {
return s.prefs.Put(splitTunnelNamespace, sectionFromSettings(settings))
}

View File

@@ -0,0 +1,109 @@
package android
import (
"reflect"
"testing"
)
func TestNormalizeSplitTunnelMode(t *testing.T) {
tests := []struct {
name string
mode string
want string
}{
{name: "exclude is kept", mode: SplitTunnelModeExclude, want: SplitTunnelModeExclude},
{name: "include is kept", mode: SplitTunnelModeInclude, want: SplitTunnelModeInclude},
{name: "off is kept", mode: SplitTunnelModeOff, want: SplitTunnelModeOff},
{name: "empty falls back to off", mode: "", want: SplitTunnelModeOff},
{name: "a mode from a newer build falls back to off", mode: "only-work-apps", want: SplitTunnelModeOff},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeSplitTunnelMode(tt.mode); got != tt.want {
t.Errorf("normalizeSplitTunnelMode(%q) = %q, want %q", tt.mode, got, tt.want)
}
})
}
}
func TestSettingsFromSection(t *testing.T) {
got := settingsFromSection(splitTunnelSection{
Mode: SplitTunnelModeExclude,
Excluded: []string{"com.example.a", "com.example.b"},
Included: []string{"com.example.c"},
})
if got.Mode != SplitTunnelModeExclude {
t.Errorf("mode = %q, want %q", got.Mode, SplitTunnelModeExclude)
}
if got.Excluded.Size() != 2 || got.Excluded.Get(0) != "com.example.a" {
t.Errorf("excluded = %v, want the two stored packages", packagesOf(got.Excluded))
}
if got.Included.Size() != 1 || got.Included.Get(0) != "com.example.c" {
t.Errorf("included = %v, want the stored package", packagesOf(got.Included))
}
}
// A profile that has never stored anything decodes into an empty section, and
// must come back as settings that carry every application rather than as nil
// lists the caller would have to guard against.
func TestSettingsFromEmptySectionCarriesEverything(t *testing.T) {
got := settingsFromSection(splitTunnelSection{})
if got.Mode != SplitTunnelModeOff {
t.Errorf("mode = %q, want %q", got.Mode, SplitTunnelModeOff)
}
if got.Excluded == nil || got.Included == nil {
t.Fatal("both selections must be usable lists, not nil")
}
if got.Excluded.Size() != 0 || got.Included.Size() != 0 {
t.Errorf("selections = %v/%v, want both empty", packagesOf(got.Excluded), packagesOf(got.Included))
}
}
func TestSectionFromSettingsRoundTrip(t *testing.T) {
settings := NewSplitTunnelSettings()
settings.Mode = SplitTunnelModeInclude
settings.Included.Add("com.example.a")
settings.Excluded.Add("com.example.b")
section := sectionFromSettings(settings)
back := settingsFromSection(section)
if back.Mode != SplitTunnelModeInclude {
t.Errorf("mode = %q, want %q", back.Mode, SplitTunnelModeInclude)
}
if !reflect.DeepEqual(packagesOf(back.Included), []string{"com.example.a"}) {
t.Errorf("included = %v, want [com.example.a]", packagesOf(back.Included))
}
// The inactive selection survives, so switching mode back does not make the
// user pick their applications again.
if !reflect.DeepEqual(packagesOf(back.Excluded), []string{"com.example.b"}) {
t.Errorf("excluded = %v, want [com.example.b]", packagesOf(back.Excluded))
}
}
func TestSectionFromNilSettings(t *testing.T) {
section := sectionFromSettings(nil)
if section.Mode != SplitTunnelModeOff {
t.Errorf("mode = %q, want %q", section.Mode, SplitTunnelModeOff)
}
if len(section.Excluded) != 0 || len(section.Included) != 0 {
t.Errorf("selections = %v/%v, want both empty", section.Excluded, section.Included)
}
}
func TestPackageListIgnoresEmptyAndBounds(t *testing.T) {
list := NewPackageList()
list.Add("com.example.a")
list.Add("")
if list.Size() != 1 {
t.Errorf("size = %d, want 1", list.Size())
}
if list.Get(-1) != "" || list.Get(5) != "" {
t.Error("out of range access must return an empty string")
}
}