From d21f707fd5a9f43c44228a07e46ef15ff23e500c Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Wed, 27 May 2026 18:26:14 +0200 Subject: [PATCH 1/4] Add ADB device selection menu --- main.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/main.go b/main.go index 124961c..382be5e 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "time" "github.com/i582/cfmt/cmd/cfmt" + "github.com/manifoldco/promptui" "github.com/mvt-project/androidqf/acquisition" "github.com/mvt-project/androidqf/adb" "github.com/mvt-project/androidqf/log" @@ -38,6 +39,20 @@ func systemPause() { os.Stdin.Read(make([]byte, 1)) } +func selectADBDevice(devices []string) (string, error) { + promptDevice := promptui.Select{ + Label: "Multiple Android devices detected. Select the device to acquire", + Items: devices, + } + + _, device, err := promptDevice.Run() + if err != nil { + return "", fmt.Errorf("failed to select ADB device: %v", err) + } + + return device, nil +} + func main() { var err error var verbose bool @@ -107,6 +122,20 @@ func main() { // Initialization for { + if serial == "" { + devices, err := adb.Client.Devices() + if err != nil { + log.Error(fmt.Sprintf("Error listing ADB devices: %s", err)) + } else if len(devices) > 1 { + serial, err = selectADBDevice(devices) + if err != nil { + log.Error(fmt.Sprintf("Error selecting ADB device: %s", err)) + time.Sleep(5 * time.Second) + continue + } + } + } + serial, err = adb.Client.SetSerial(serial) if err != nil { log.Error(fmt.Sprintf("Error trying to connect over ADB: %s", err)) From 931b58caffb810cd08b484d37d3a91b9ddf44bf1 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Tue, 7 Jul 2026 21:10:45 +0200 Subject: [PATCH 2/4] Support concurrent ADB device selection --- acquisition/acquisition.go | 5 -- adb/adb.go | 6 +- adb/adb_test.go | 75 +++++++++++++++++++++ device_selection_test.go | 118 +++++++++++++++++++++++++++++++++ main.go | 72 ++++++++++++++++++-- run_state.go | 131 +++++++++++++++++++++++++++++++++++++ run_state_test.go | 69 +++++++++++++++++++ 7 files changed, 460 insertions(+), 16 deletions(-) create mode 100644 adb/adb_test.go create mode 100644 device_selection_test.go create mode 100644 run_state.go create mode 100644 run_state_test.go diff --git a/acquisition/acquisition.go b/acquisition/acquisition.go index 08cbda9..3f9fcab 100644 --- a/acquisition/acquisition.go +++ b/acquisition/acquisition.go @@ -19,7 +19,6 @@ import ( "github.com/botherder/go-savetime/hashes" "github.com/google/uuid" "github.com/mvt-project/androidqf/adb" - "github.com/mvt-project/androidqf/assets" "github.com/mvt-project/androidqf/log" "github.com/mvt-project/androidqf/utils" ) @@ -178,10 +177,6 @@ func (a *Acquisition) Complete() { if a.Collector != nil { a.Collector.Clean() } - - // Stop ADB server before trying to remove extracted assets - adb.Client.KillServer() - assets.CleanAssets() } func (a *Acquisition) GetSystemInformation() error { diff --git a/adb/adb.go b/adb/adb.go index 253cde7..978f024 100644 --- a/adb/adb.go +++ b/adb/adb.go @@ -32,9 +32,6 @@ func New() (*ADB, error) { } log.Debugf("ADB found at path: %s", adb.ExePath) - log.Debug("Killing existing ADB server if running") - adb.KillServer() - // Confirm that we can call "adb devices" without errors _, err = adb.Devices() if err != nil { @@ -63,11 +60,10 @@ func (a *ADB) SetSerial(serial string) (string, error) { } a.Serial = serial } else { - // Problem if multiple devices if len(devices) > 1 { return "", fmt.Errorf("multiple devices connected, please stop AndroidQF and provide a serial number") } - a.Serial = "" + a.Serial = devices[0] } return a.Serial, nil } diff --git a/adb/adb_test.go b/adb/adb_test.go new file mode 100644 index 0000000..cd493c1 --- /dev/null +++ b/adb/adb_test.go @@ -0,0 +1,75 @@ +package adb + +import ( + "fmt" + "os" + "strings" + "testing" +) + +func TestMain(m *testing.M) { + if os.Getenv("ANDROIDQF_FAKE_ADB") == "1" { + fakeADB() + return + } + os.Exit(m.Run()) +} + +func fakeADB() { + if len(os.Args) < 2 { + os.Exit(2) + } + + switch os.Args[1] { + case "devices": + fmt.Println("List of devices attached") + for _, device := range strings.Split(os.Getenv("ANDROIDQF_FAKE_ADB_DEVICES"), ",") { + device = strings.TrimSpace(device) + if device != "" { + fmt.Printf("%s\tdevice\n", device) + } + } + default: + os.Exit(2) + } +} + +func newFakeADB(t *testing.T, devices string) *ADB { + t.Helper() + t.Setenv("ANDROIDQF_FAKE_ADB", "1") + t.Setenv("ANDROIDQF_FAKE_ADB_DEVICES", devices) + return &ADB{ExePath: os.Args[0]} +} + +func TestSetSerialSingleDeviceUsesExplicitSerial(t *testing.T) { + client := newFakeADB(t, "device-1") + serial, err := client.SetSerial("") + if err != nil { + t.Fatalf("SetSerial returned error: %v", err) + } + if serial != "device-1" { + t.Fatalf("serial = %q, want device-1", serial) + } + if client.Serial != "device-1" { + t.Fatalf("client.Serial = %q, want device-1", client.Serial) + } +} + +func TestSetSerialMultipleDevicesWithoutSerialErrors(t *testing.T) { + client := newFakeADB(t, "device-1,device-2") + _, err := client.SetSerial("") + if err == nil { + t.Fatal("SetSerial returned nil error, want multiple devices error") + } +} + +func TestSetSerialExplicitSerial(t *testing.T) { + client := newFakeADB(t, "device-1,device-2") + serial, err := client.SetSerial("device-2") + if err != nil { + t.Fatalf("SetSerial returned error: %v", err) + } + if serial != "device-2" { + t.Fatalf("serial = %q, want device-2", serial) + } +} diff --git a/device_selection_test.go b/device_selection_test.go new file mode 100644 index 0000000..b170380 --- /dev/null +++ b/device_selection_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "errors" + "testing" + "time" +) + +func TestResolveADBSerialNoDevicesDoesNotPrompt(t *testing.T) { + called := false + serial, prompted, err := resolveADBSerial("", nil, func([]deviceMenuItem) (string, error) { + called = true + return "", nil + }, nil) + if err != nil { + t.Fatalf("resolveADBSerial returned error: %v", err) + } + if serial != "" { + t.Fatalf("serial = %q, want empty", serial) + } + if prompted { + t.Fatal("prompted = true, want false") + } + if called { + t.Fatal("selector was called for zero devices") + } +} + +func TestResolveADBSerialSingleDeviceDoesNotPrompt(t *testing.T) { + called := false + serial, prompted, err := resolveADBSerial("", []string{"device-1"}, func([]deviceMenuItem) (string, error) { + called = true + return "", nil + }, nil) + if err != nil { + t.Fatalf("resolveADBSerial returned error: %v", err) + } + if serial != "device-1" { + t.Fatalf("serial = %q, want device-1", serial) + } + if prompted { + t.Fatal("prompted = true, want false") + } + if called { + t.Fatal("selector was called for one device") + } +} + +func TestResolveADBSerialMultipleDevicesPromptsWithRunningStatus(t *testing.T) { + started := time.Date(2026, 7, 7, 10, 11, 12, 0, time.UTC) + running := map[string]runningExtraction{ + "device-2": { + Serial: "device-2", + PID: 1234, + Started: started, + }, + } + + var gotItems []deviceMenuItem + serial, prompted, err := resolveADBSerial("", []string{"device-1", "device-2"}, func(items []deviceMenuItem) (string, error) { + gotItems = items + return items[1].Serial, nil + }, running) + if err != nil { + t.Fatalf("resolveADBSerial returned error: %v", err) + } + if serial != "device-2" { + t.Fatalf("serial = %q, want device-2", serial) + } + if !prompted { + t.Fatal("prompted = false, want true") + } + if len(gotItems) != 2 { + t.Fatalf("selector got %d items, want 2", len(gotItems)) + } + if gotItems[0].Status != "" { + t.Fatalf("first item status = %q, want empty", gotItems[0].Status) + } + if gotItems[1].Status == "" { + t.Fatal("second item status is empty, want running extraction status") + } +} + +func TestResolveADBSerialMultipleDevicesReturnsSelectorError(t *testing.T) { + wantErr := errors.New("selection failed") + serial, prompted, err := resolveADBSerial("", []string{"device-1", "device-2"}, func([]deviceMenuItem) (string, error) { + return "", wantErr + }, nil) + if !errors.Is(err, wantErr) { + t.Fatalf("err = %v, want %v", err, wantErr) + } + if serial != "" { + t.Fatalf("serial = %q, want empty", serial) + } + if !prompted { + t.Fatal("prompted = false, want true") + } +} + +func TestResolveADBSerialExplicitSerialDoesNotPrompt(t *testing.T) { + called := false + serial, prompted, err := resolveADBSerial("requested", []string{"device-1", "device-2"}, func([]deviceMenuItem) (string, error) { + called = true + return "", nil + }, nil) + if err != nil { + t.Fatalf("resolveADBSerial returned error: %v", err) + } + if serial != "requested" { + t.Fatalf("serial = %q, want requested", serial) + } + if prompted { + t.Fatal("prompted = true, want false") + } + if called { + t.Fatal("selector was called for explicit serial") + } +} diff --git a/main.go b/main.go index 382be5e..3eead02 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,11 @@ import ( "github.com/mvt-project/androidqf/utils" ) +type deviceMenuItem struct { + Serial string + Status string +} + func init() { cfmt.Print(` {{ __ _ __ ____ }}::green @@ -39,18 +44,52 @@ func systemPause() { os.Stdin.Read(make([]byte, 1)) } -func selectADBDevice(devices []string) (string, error) { +func buildDeviceMenuItems(devices []string, running map[string]runningExtraction) []deviceMenuItem { + items := make([]deviceMenuItem, 0, len(devices)) + for _, device := range devices { + item := deviceMenuItem{Serial: device} + if state, ok := running[device]; ok { + item.Status = fmt.Sprintf("(extraction running, pid %d, started %s)", state.PID, state.Started.Local().Format("2006-01-02 15:04:05")) + } + items = append(items, item) + } + return items +} + +func selectADBDeviceFromMenu(items []deviceMenuItem) (string, error) { promptDevice := promptui.Select{ Label: "Multiple Android devices detected. Select the device to acquire", - Items: devices, + Items: items, + Templates: &promptui.SelectTemplates{ + Active: "> {{ .Serial | cyan }} {{ .Status | yellow }}", + Inactive: " {{ .Serial }} {{ .Status }}", + Selected: "{{ .Serial }}", + }, } - _, device, err := promptDevice.Run() + index, _, err := promptDevice.Run() if err != nil { return "", fmt.Errorf("failed to select ADB device: %v", err) } - return device, nil + return items[index].Serial, nil +} + +func selectADBDevice(devices []string) (string, error) { + return selectADBDeviceFromMenu(buildDeviceMenuItems(devices, activeRunningExtractionsBySerial())) +} + +func resolveADBSerial(serial string, devices []string, selectDevice func([]deviceMenuItem) (string, error), running map[string]runningExtraction) (string, bool, error) { + serial = strings.TrimSpace(serial) + if serial != "" || len(devices) == 0 { + return serial, false, nil + } + if len(devices) == 1 { + return devices[0], false, nil + } + + selectedSerial, err := selectDevice(buildDeviceMenuItems(devices, running)) + return selectedSerial, true, err } func main() { @@ -119,6 +158,7 @@ func main() { } } } + specificDeviceRequested := serial != "" // Initialization for { @@ -126,8 +166,8 @@ func main() { devices, err := adb.Client.Devices() if err != nil { log.Error(fmt.Sprintf("Error listing ADB devices: %s", err)) - } else if len(devices) > 1 { - serial, err = selectADBDevice(devices) + } else { + serial, _, err = resolveADBSerial(serial, devices, selectADBDeviceFromMenu, activeRunningExtractionsBySerial()) if err != nil { log.Error(fmt.Sprintf("Error selecting ADB device: %s", err)) time.Sleep(5 * time.Second) @@ -139,6 +179,9 @@ func main() { serial, err = adb.Client.SetSerial(serial) if err != nil { log.Error(fmt.Sprintf("Error trying to connect over ADB: %s", err)) + if !specificDeviceRequested { + serial = "" + } } else { _, err = adb.Client.GetState() if err == nil { @@ -146,10 +189,25 @@ func main() { } log.Debug(err) log.Error("Unable to get device state. Please make sure it is connected and authorized. Trying again in 5 seconds...") + if !specificDeviceRequested { + serial = "" + } } time.Sleep(5 * time.Second) } + releaseRunning, err := registerRunningExtraction(adb.Client.Serial, "") + if err != nil { + log.Warningf("Unable to record running extraction state: %v", err) + releaseRunning = func() {} + } + runningReleased := false + defer func() { + if !runningReleased { + releaseRunning() + } + }() + acq, err := acquisition.New(output_folder) if err != nil { log.Debug(err) @@ -201,6 +259,8 @@ func main() { } acq.Complete() + releaseRunning() + runningReleased = true log.Info("Acquisition completed.") systemPause() diff --git a/run_state.go b/run_state.go new file mode 100644 index 0000000..bd424c6 --- /dev/null +++ b/run_state.go @@ -0,0 +1,131 @@ +// androidqf - Android Quick Forensics +// Copyright (c) 2021-2023 Claudio Guarnieri. +// Use of this software is governed by the MVT License 1.1 that can be found at +// https://license.mvt.re/1.1/ + +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +type runningExtraction struct { + Serial string `json:"serial"` + PID int `json:"pid"` + Started time.Time `json:"started"` + StoragePath string `json:"storage_path,omitempty"` +} + +var ( + runningStateDir = defaultRunningStateDir + processExists = defaultProcessExists +) + +func defaultRunningStateDir() string { + cacheDir, err := os.UserCacheDir() + if err != nil { + return filepath.Join(os.TempDir(), "androidqf", "running") + } + return filepath.Join(cacheDir, "androidqf", "running") +} + +func runningExtractionFileName(pid int, serial string) string { + encodedSerial := base64.RawURLEncoding.EncodeToString([]byte(serial)) + return fmt.Sprintf("%d-%s.json", pid, encodedSerial) +} + +func registerRunningExtraction(serial, storagePath string) (func(), error) { + if strings.TrimSpace(serial) == "" { + return func() {}, nil + } + + stateDir := runningStateDir() + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return nil, err + } + + state := runningExtraction{ + Serial: serial, + PID: os.Getpid(), + Started: time.Now().UTC(), + StoragePath: storagePath, + } + statePath := filepath.Join(stateDir, runningExtractionFileName(state.PID, state.Serial)) + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return nil, err + } + if err := os.WriteFile(statePath, data, 0o644); err != nil { + return nil, err + } + + return func() { + _ = os.Remove(statePath) + }, nil +} + +func activeRunningExtractionsBySerial() map[string]runningExtraction { + result := make(map[string]runningExtraction) + stateDir := runningStateDir() + + entries, err := os.ReadDir(stateDir) + if err != nil { + return result + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + + statePath := filepath.Join(stateDir, entry.Name()) + data, err := os.ReadFile(statePath) + if err != nil { + continue + } + + var state runningExtraction + if err := json.Unmarshal(data, &state); err != nil || state.Serial == "" || state.PID == 0 { + _ = os.Remove(statePath) + continue + } + + if !processExists(state.PID) { + _ = os.Remove(statePath) + continue + } + + result[state.Serial] = state + } + + return result +} + +func defaultProcessExists(pid int) bool { + if pid <= 0 { + return false + } + if pid == os.Getpid() { + return true + } + + if runtime.GOOS == "windows" { + out, err := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid), "/FO", "CSV", "/NH").Output() + if err != nil { + return false + } + return strings.Contains(string(out), fmt.Sprintf("\"%d\"", pid)) + } + + return exec.Command("kill", "-0", strconv.Itoa(pid)).Run() == nil +} diff --git a/run_state_test.go b/run_state_test.go new file mode 100644 index 0000000..a27b09b --- /dev/null +++ b/run_state_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "os" + "testing" +) + +func TestRegisterRunningExtractionAppearsActiveAndReleases(t *testing.T) { + stateDir := t.TempDir() + oldStateDir := runningStateDir + oldProcessExists := processExists + runningStateDir = func() string { return stateDir } + processExists = func(pid int) bool { return pid == os.Getpid() } + t.Cleanup(func() { + runningStateDir = oldStateDir + processExists = oldProcessExists + }) + + release, err := registerRunningExtraction("device-1", "out") + if err != nil { + t.Fatalf("registerRunningExtraction returned error: %v", err) + } + + active := activeRunningExtractionsBySerial() + state, ok := active["device-1"] + if !ok { + t.Fatal("device-1 was not found in active running extractions") + } + if state.StoragePath != "out" { + t.Fatalf("storage path = %q, want out", state.StoragePath) + } + + release() + active = activeRunningExtractionsBySerial() + if _, ok := active["device-1"]; ok { + t.Fatal("device-1 remained active after release") + } +} + +func TestActiveRunningExtractionsRemovesStaleState(t *testing.T) { + stateDir := t.TempDir() + oldStateDir := runningStateDir + oldProcessExists := processExists + runningStateDir = func() string { return stateDir } + processExists = func(int) bool { return false } + t.Cleanup(func() { + runningStateDir = oldStateDir + processExists = oldProcessExists + }) + + release, err := registerRunningExtraction("device-1", "out") + if err != nil { + t.Fatalf("registerRunningExtraction returned error: %v", err) + } + defer release() + + active := activeRunningExtractionsBySerial() + if len(active) != 0 { + t.Fatalf("active state count = %d, want 0", len(active)) + } + + entries, err := os.ReadDir(stateDir) + if err != nil { + t.Fatalf("ReadDir returned error: %v", err) + } + if len(entries) != 0 { + t.Fatalf("state files remaining = %d, want 0", len(entries)) + } +} From 37f85aa9ed15fef6a1f80d155dc16dc6ba328191 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Tue, 7 Jul 2026 21:15:06 +0200 Subject: [PATCH 3/4] Fix staticcheck device selector warning --- main.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/main.go b/main.go index 3eead02..389051e 100644 --- a/main.go +++ b/main.go @@ -75,10 +75,6 @@ func selectADBDeviceFromMenu(items []deviceMenuItem) (string, error) { return items[index].Serial, nil } -func selectADBDevice(devices []string) (string, error) { - return selectADBDeviceFromMenu(buildDeviceMenuItems(devices, activeRunningExtractionsBySerial())) -} - func resolveADBSerial(serial string, devices []string, selectDevice func([]deviceMenuItem) (string, error), running map[string]runningExtraction) (string, bool, error) { serial = strings.TrimSpace(serial) if serial != "" || len(devices) == 0 { From 128ebef5d6356da01a0c7f04927704237945925b Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Tue, 7 Jul 2026 21:24:34 +0200 Subject: [PATCH 4/4] Show Android model names in device menu --- adb/adb.go | 58 ++++++++++++++++++++++++++++++++++++++++ adb/adb_test.go | 39 ++++++++++++++++++++++++++- device_selection_test.go | 29 +++++++++++++++++--- main.go | 50 +++++++++++++++++++++++++++------- 4 files changed, 161 insertions(+), 15 deletions(-) diff --git a/adb/adb.go b/adb/adb.go index 978f024..3bdcf5f 100644 --- a/adb/adb.go +++ b/adb/adb.go @@ -20,6 +20,14 @@ type ADB struct { Serial string } +type DeviceInfo struct { + Serial string + State string + Product string + Model string + Device string +} + var Client *ADB // New returns a new ADB instance. @@ -89,6 +97,56 @@ func (a *ADB) Devices() ([]string, error) { return devices, nil } +func (a *ADB) DeviceInfos() ([]DeviceInfo, error) { + var devices []DeviceInfo + out, err := exec.Command(a.ExePath, "devices", "-l").Output() + if err != nil { + return devices, fmt.Errorf("failed to use the adb executable: %v", + err) + } + + lines := strings.Split(string(out), "\n") + for _, line := range lines[1:] { + info, ok := parseDeviceInfoLine(line) + if !ok { + continue + } + devices = append(devices, info) + log.Debug("Found new device: ", info.Serial) + } + + return devices, nil +} + +func parseDeviceInfoLine(line string) (DeviceInfo, bool) { + fields := strings.Fields(line) + if len(fields) < 2 { + return DeviceInfo{}, false + } + + info := DeviceInfo{ + Serial: fields[0], + State: fields[1], + } + for _, field := range fields[2:] { + key, value, ok := strings.Cut(field, ":") + if !ok { + continue + } + + switch key { + case "product": + info.Product = value + case "model": + info.Model = value + case "device": + info.Device = value + } + } + + return info, true +} + // Run a command to the given phone using exec // Returns string and/or error func (a *ADB) Exec(args ...string) ([]byte, error) { diff --git a/adb/adb_test.go b/adb/adb_test.go index cd493c1..10a87bb 100644 --- a/adb/adb_test.go +++ b/adb/adb_test.go @@ -26,7 +26,11 @@ func fakeADB() { for _, device := range strings.Split(os.Getenv("ANDROIDQF_FAKE_ADB_DEVICES"), ",") { device = strings.TrimSpace(device) if device != "" { - fmt.Printf("%s\tdevice\n", device) + if len(os.Args) > 2 && os.Args[2] == "-l" { + fmt.Printf("%s device product:fake model:%s_Model device:%s transport_id:1\n", device, device, device) + } else { + fmt.Printf("%s\tdevice\n", device) + } } } default: @@ -34,6 +38,39 @@ func fakeADB() { } } +func TestDeviceInfosParsesLongDeviceList(t *testing.T) { + client := newFakeADB(t, "device-1,device-2") + devices, err := client.DeviceInfos() + if err != nil { + t.Fatalf("DeviceInfos returned error: %v", err) + } + if len(devices) != 2 { + t.Fatalf("DeviceInfos returned %d devices, want 2", len(devices)) + } + if devices[0].Serial != "device-1" { + t.Fatalf("first serial = %q, want device-1", devices[0].Serial) + } + if devices[0].Model != "device-1_Model" { + t.Fatalf("first model = %q, want device-1_Model", devices[0].Model) + } +} + +func TestParseDeviceInfoLineUnauthorizedWithoutModel(t *testing.T) { + info, ok := parseDeviceInfoLine("5B221JEBF18336 unauthorized usb:336592896X transport_id:1") + if !ok { + t.Fatal("parseDeviceInfoLine returned ok=false") + } + if info.Serial != "5B221JEBF18336" { + t.Fatalf("serial = %q, want 5B221JEBF18336", info.Serial) + } + if info.State != "unauthorized" { + t.Fatalf("state = %q, want unauthorized", info.State) + } + if info.Model != "" { + t.Fatalf("model = %q, want empty", info.Model) + } +} + func newFakeADB(t *testing.T, devices string) *ADB { t.Helper() t.Setenv("ANDROIDQF_FAKE_ADB", "1") diff --git a/device_selection_test.go b/device_selection_test.go index b170380..1c79197 100644 --- a/device_selection_test.go +++ b/device_selection_test.go @@ -4,6 +4,8 @@ import ( "errors" "testing" "time" + + "github.com/mvt-project/androidqf/adb" ) func TestResolveADBSerialNoDevicesDoesNotPrompt(t *testing.T) { @@ -28,7 +30,7 @@ func TestResolveADBSerialNoDevicesDoesNotPrompt(t *testing.T) { func TestResolveADBSerialSingleDeviceDoesNotPrompt(t *testing.T) { called := false - serial, prompted, err := resolveADBSerial("", []string{"device-1"}, func([]deviceMenuItem) (string, error) { + serial, prompted, err := resolveADBSerial("", []adb.DeviceInfo{{Serial: "device-1"}}, func([]deviceMenuItem) (string, error) { called = true return "", nil }, nil) @@ -57,7 +59,10 @@ func TestResolveADBSerialMultipleDevicesPromptsWithRunningStatus(t *testing.T) { } var gotItems []deviceMenuItem - serial, prompted, err := resolveADBSerial("", []string{"device-1", "device-2"}, func(items []deviceMenuItem) (string, error) { + serial, prompted, err := resolveADBSerial("", []adb.DeviceInfo{ + {Serial: "device-1", State: "device", Model: "Pixel_9a"}, + {Serial: "device-2", State: "device", Model: "XQ_DC54"}, + }, func(items []deviceMenuItem) (string, error) { gotItems = items return items[1].Serial, nil }, running) @@ -76,6 +81,9 @@ func TestResolveADBSerialMultipleDevicesPromptsWithRunningStatus(t *testing.T) { if gotItems[0].Status != "" { t.Fatalf("first item status = %q, want empty", gotItems[0].Status) } + if gotItems[0].Title != "Pixel 9a (device-1)" { + t.Fatalf("first item title = %q, want Pixel 9a (device-1)", gotItems[0].Title) + } if gotItems[1].Status == "" { t.Fatal("second item status is empty, want running extraction status") } @@ -83,7 +91,7 @@ func TestResolveADBSerialMultipleDevicesPromptsWithRunningStatus(t *testing.T) { func TestResolveADBSerialMultipleDevicesReturnsSelectorError(t *testing.T) { wantErr := errors.New("selection failed") - serial, prompted, err := resolveADBSerial("", []string{"device-1", "device-2"}, func([]deviceMenuItem) (string, error) { + serial, prompted, err := resolveADBSerial("", []adb.DeviceInfo{{Serial: "device-1"}, {Serial: "device-2"}}, func([]deviceMenuItem) (string, error) { return "", wantErr }, nil) if !errors.Is(err, wantErr) { @@ -99,7 +107,7 @@ func TestResolveADBSerialMultipleDevicesReturnsSelectorError(t *testing.T) { func TestResolveADBSerialExplicitSerialDoesNotPrompt(t *testing.T) { called := false - serial, prompted, err := resolveADBSerial("requested", []string{"device-1", "device-2"}, func([]deviceMenuItem) (string, error) { + serial, prompted, err := resolveADBSerial("requested", []adb.DeviceInfo{{Serial: "device-1"}, {Serial: "device-2"}}, func([]deviceMenuItem) (string, error) { called = true return "", nil }, nil) @@ -116,3 +124,16 @@ func TestResolveADBSerialExplicitSerialDoesNotPrompt(t *testing.T) { t.Fatal("selector was called for explicit serial") } } + +func TestBuildDeviceMenuItemsFallsBackForUnauthorizedDevice(t *testing.T) { + items := buildDeviceMenuItems([]adb.DeviceInfo{{Serial: "device-1", State: "unauthorized"}}, nil) + if len(items) != 1 { + t.Fatalf("got %d items, want 1", len(items)) + } + if items[0].Title != "device-1" { + t.Fatalf("title = %q, want device-1", items[0].Title) + } + if items[0].Status != "(unauthorized)" { + t.Fatalf("status = %q, want (unauthorized)", items[0].Status) + } +} diff --git a/main.go b/main.go index 803c949..d830b61 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,7 @@ import ( type deviceMenuItem struct { Serial string + Title string Status string } @@ -44,26 +45,55 @@ func systemPause() { os.Stdin.Read(make([]byte, 1)) } -func buildDeviceMenuItems(devices []string, running map[string]runningExtraction) []deviceMenuItem { +func buildDeviceMenuItems(devices []adb.DeviceInfo, running map[string]runningExtraction) []deviceMenuItem { items := make([]deviceMenuItem, 0, len(devices)) for _, device := range devices { - item := deviceMenuItem{Serial: device} - if state, ok := running[device]; ok { - item.Status = fmt.Sprintf("(extraction running, pid %d, started %s)", state.PID, state.Started.Local().Format("2006-01-02 15:04:05")) + item := deviceMenuItem{ + Serial: device.Serial, + Title: deviceMenuTitle(device), + Status: deviceMenuStatus(device), + } + if state, ok := running[device.Serial]; ok { + if item.Status != "" { + item.Status += " " + } + item.Status += fmt.Sprintf("(extraction running, pid %d, started %s)", state.PID, state.Started.Local().Format("2006-01-02 15:04:05")) } items = append(items, item) } return items } +func deviceMenuTitle(device adb.DeviceInfo) string { + name := device.Model + if name == "" { + name = device.Device + } + if name == "" { + name = device.Product + } + name = strings.ReplaceAll(name, "_", " ") + if name == "" { + return device.Serial + } + return fmt.Sprintf("%s (%s)", name, device.Serial) +} + +func deviceMenuStatus(device adb.DeviceInfo) string { + if device.State == "" || device.State == "device" { + return "" + } + return fmt.Sprintf("(%s)", device.State) +} + func selectADBDeviceFromMenu(items []deviceMenuItem) (string, error) { promptDevice := promptui.Select{ Label: "Multiple Android devices detected. Select the device to acquire", Items: items, Templates: &promptui.SelectTemplates{ - Active: "> {{ .Serial | cyan }} {{ .Status | yellow }}", - Inactive: " {{ .Serial }} {{ .Status }}", - Selected: "{{ .Serial }}", + Active: "> {{ .Title | cyan }} {{ .Status | yellow }}", + Inactive: " {{ .Title }} {{ .Status }}", + Selected: "{{ .Title }}", }, } @@ -75,13 +105,13 @@ func selectADBDeviceFromMenu(items []deviceMenuItem) (string, error) { return items[index].Serial, nil } -func resolveADBSerial(serial string, devices []string, selectDevice func([]deviceMenuItem) (string, error), running map[string]runningExtraction) (string, bool, error) { +func resolveADBSerial(serial string, devices []adb.DeviceInfo, selectDevice func([]deviceMenuItem) (string, error), running map[string]runningExtraction) (string, bool, error) { serial = strings.TrimSpace(serial) if serial != "" || len(devices) == 0 { return serial, false, nil } if len(devices) == 1 { - return devices[0], false, nil + return devices[0].Serial, false, nil } selectedSerial, err := selectDevice(buildDeviceMenuItems(devices, running)) @@ -159,7 +189,7 @@ func main() { // Initialization for { if serial == "" { - devices, err := adb.Client.Devices() + devices, err := adb.Client.DeviceInfos() if err != nil { log.Error(fmt.Sprintf("Error listing ADB devices: %s", err)) } else {