feat: implement ADB wrapper and device management backend

Adds full ADB client, device manager, static DeviceInfo fetcher, and
live DeviceLiveStats poller. Exposes ListDevices, ConnectDevice,
DisconnectDevice, GetDeviceInfo, GetDeviceLiveStats as Wails-bound
methods with a 1s devices:changed event loop. Bundles ADB binary
infrastructure via //go:embed all:bin with runtime fallback chain.
This commit is contained in:
achmad
2026-05-06 14:51:34 +07:00
parent 6a53f87e03
commit 14935db63e
14 changed files with 610 additions and 14 deletions
+80
View File
@@ -1 +1,81 @@
package adb
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"time"
)
type Client struct {
path string
}
func New(adbPath string) *Client {
return &Client{path: adbPath}
}
// run executes an adb command and returns stdout.
func (c *Client) run(ctx context.Context, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, c.path, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("adb %s: %s", strings.Join(args, " "), msg)
}
return strings.TrimSpace(stdout.String()), nil
}
// Devices returns raw output of `adb devices -l`.
func (c *Client) Devices(ctx context.Context) (string, error) {
return c.run(ctx, "devices", "-l")
}
// Shell runs a shell command on a specific device and returns stdout.
func (c *Client) Shell(ctx context.Context, deviceID, cmd string) (string, error) {
return c.run(ctx, "-s", deviceID, "shell", cmd)
}
// Connect connects to a device over TCP/IP (wireless ADB).
func (c *Client) Connect(ctx context.Context, address string) (string, error) {
return c.run(ctx, "connect", address)
}
// Disconnect disconnects a device.
func (c *Client) Disconnect(ctx context.Context, deviceID string) (string, error) {
return c.run(ctx, "disconnect", deviceID)
}
// Pull copies a file from the device to a local path.
func (c *Client) Pull(ctx context.Context, deviceID, remotePath, localPath string) error {
_, err := c.run(ctx, "-s", deviceID, "pull", remotePath, localPath)
return err
}
// Push copies a local file to the device.
func (c *Client) Push(ctx context.Context, deviceID, localPath, remotePath string) error {
_, err := c.run(ctx, "-s", deviceID, "push", localPath, remotePath)
return err
}
// StartServer starts the ADB server if not already running.
func (c *Client) StartServer(ctx context.Context) error {
_, err := c.run(ctx, "start-server")
return err
}
// Path returns the resolved ADB binary path.
func (c *Client) Path() string {
return c.path
}
+143
View File
@@ -0,0 +1,143 @@
package device
import (
"context"
"fmt"
"strings"
"git.achmad.dev/admin/droidscope/backend/adb"
)
// FetchInfo retrieves static device information (fetched once on device select).
func FetchInfo(ctx context.Context, client *adb.Client, deviceID string) (*DeviceInfo, error) {
info := &DeviceInfo{ID: deviceID}
shell := func(cmd string) string {
out, _ := client.Shell(ctx, deviceID, cmd)
return strings.TrimSpace(out)
}
info.Manufacturer = shell("getprop ro.product.manufacturer")
info.Model = shell("getprop ro.product.model")
info.Product = shell("getprop ro.product.name")
info.AndroidVersion = shell("getprop ro.build.version.release")
info.SDKVersion = shell("getprop ro.build.version.sdk")
info.SecurityPatch = shell("getprop ro.build.version.security_patch")
info.BuildFingerprint = shell("getprop ro.build.fingerprint")
info.ABI = shell("getprop ro.product.cpu.abi")
info.SupportedABIs = shell("getprop ro.product.cpu.abilist")
info.SerialNumber = shell("getprop ro.serialno")
info.ScreenResolution = shell("wm size | awk '/Physical/{print $3}'")
info.ScreenDensity = shell("wm density | awk '/Physical/{print $3}' | tr -d '\\n'")
info.TotalRAMMB = parseRAM(shell("cat /proc/meminfo | grep MemTotal"))
info.TotalStorageGB, _ = parseStorage(shell("df /data 2>/dev/null | tail -1"))
return info, nil
}
// FetchLiveStats retrieves only the fields that change at runtime.
// Called on a 1-second poll interval.
func FetchLiveStats(ctx context.Context, client *adb.Client, deviceID string) (*DeviceLiveStats, error) {
shell := func(cmd string) string {
out, _ := client.Shell(ctx, deviceID, cmd)
return strings.TrimSpace(out)
}
stats := &DeviceLiveStats{}
stats.AvailableRAMMB = parseRAM(shell("cat /proc/meminfo | grep MemAvailable"))
_, stats.AvailableStorageGB = parseStorage(shell("df /data 2>/dev/null | tail -1"))
batteryOut := shell("dumpsys battery")
stats.BatteryLevel = parseBatteryField(batteryOut, "level")
stats.BatteryStatus = parseBatteryStatusCode(parseBatteryField(batteryOut, "status"))
stats.ThermalStatus = parseThermalStatus(shell("dumpsys thermalservice 2>/dev/null | grep 'Current thermal status'"))
ip := shell("ip addr show wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1")
if ip == "" {
ip = shell("ip route get 8.8.8.8 2>/dev/null | grep src | awk '{print $7}'")
}
stats.IPAddress = ip
return stats, nil
}
func parseRAM(line string) int {
fields := strings.Fields(line)
if len(fields) < 2 {
return 0
}
return parseIntSafe(fields[1]) / 1024
}
func parseStorage(dfLine string) (total, available string) {
fields := strings.Fields(dfLine)
if len(fields) < 4 {
return "", ""
}
return formatGB(parseIntSafe(fields[1])), formatGB(parseIntSafe(fields[3]))
}
func formatGB(kb int) string {
if kb == 0 {
return ""
}
gb := float64(kb) / 1024 / 1024
if gb >= 1 {
return strings.TrimRight(strings.TrimRight(
strings.Replace(fmt.Sprintf("%.1f", gb), ".0", "", 1), "0"), ".") + " GB"
}
return fmt.Sprintf("%d MB", kb/1024)
}
func parseBatteryField(dumpsys, field string) int {
for _, line := range strings.Split(dumpsys, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, field+":") {
return parseIntSafe(strings.TrimSpace(strings.TrimPrefix(line, field+":")))
}
}
return 0
}
func parseBatteryStatusCode(code int) string {
switch code {
case 2:
return "charging"
case 3:
return "discharging"
case 4:
return "not charging"
case 5:
return "full"
default:
return "unknown"
}
}
func parseThermalStatus(line string) string {
parts := strings.Split(line, ":")
if len(parts) < 2 {
return "none"
}
switch parseIntSafe(strings.TrimSpace(parts[1])) {
case 1:
return "light"
case 2:
return "moderate"
case 3:
return "severe"
case 4:
return "critical"
case 5:
return "emergency"
case 6:
return "shutdown"
default:
return "none"
}
}
+141
View File
@@ -1 +1,142 @@
package device
import (
"context"
"strings"
"git.achmad.dev/admin/droidscope/backend/adb"
)
type Manager struct {
adb *adb.Client
Client *adb.Client // exported for use by other packages
}
func NewManager(client *adb.Client) *Manager {
return &Manager{adb: client, Client: client}
}
// Info fetches static device information for the given device ID.
func (m *Manager) Info(ctx context.Context, deviceID string) (*DeviceInfo, error) {
return FetchInfo(ctx, m.adb, deviceID)
}
// LiveStats fetches dynamic device stats for the given device ID.
func (m *Manager) LiveStats(ctx context.Context, deviceID string) (*DeviceLiveStats, error) {
return FetchLiveStats(ctx, m.adb, deviceID)
}
// List returns all currently connected devices with their details.
func (m *Manager) List(ctx context.Context) ([]Device, error) {
raw, err := m.adb.Devices(ctx)
if err != nil {
return nil, err
}
serials := parseDeviceLines(raw)
devices := make([]Device, 0, len(serials))
for serial, status := range serials {
d := Device{
ID: serial,
Status: status,
ConnectionType: connectionType(serial),
}
if status == StatusOnline {
enrichDevice(ctx, m.adb, &d)
}
devices = append(devices, d)
}
return devices, nil
}
// Connect connects to a wireless ADB device by address (host:port).
func (m *Manager) Connect(ctx context.Context, address string) error {
out, err := m.adb.Connect(ctx, address)
if err != nil {
return err
}
if strings.Contains(out, "failed") || strings.Contains(out, "error") {
return &Error{Message: out}
}
return nil
}
// Disconnect disconnects a device.
func (m *Manager) Disconnect(ctx context.Context, deviceID string) error {
_, err := m.adb.Disconnect(ctx, deviceID)
return err
}
// parseDeviceLines parses `adb devices -l` output into a serial→status map.
func parseDeviceLines(raw string) map[string]Status {
result := make(map[string]Status)
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "List of") {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
serial := fields[0]
result[serial] = parseStatus(fields[1])
}
return result
}
func parseStatus(s string) Status {
switch s {
case "device":
return StatusOnline
case "offline":
return StatusOffline
case "unauthorized":
return StatusUnauthorized
case "recovery":
return StatusRecovery
default:
return StatusOffline
}
}
func connectionType(serial string) ConnectionType {
if strings.Contains(serial, ":") {
return ConnectionWifi
}
return ConnectionUSB
}
// enrichDevice fetches device properties via adb shell getprop.
func enrichDevice(ctx context.Context, client *adb.Client, d *Device) {
props := map[string]*string{
"ro.product.model": &d.Model,
"ro.product.manufacturer": &d.Manufacturer,
"ro.build.version.release": &d.AndroidVersion,
"ro.product.cpu.abi": &d.ABI,
"ro.product.name": &d.Product,
}
for prop, dest := range props {
if val, err := client.Shell(ctx, d.ID, "getprop "+prop); err == nil {
*dest = strings.TrimSpace(val)
}
}
if level, err := client.Shell(ctx, d.ID, "dumpsys battery | grep level | tr -dc '0-9'"); err == nil {
d.BatteryLevel = parseIntSafe(strings.TrimSpace(level))
}
}
func parseIntSafe(s string) int {
n := 0
for _, c := range s {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
}
}
return n
}
type Error struct {
Message string
}
func (e *Error) Error() string { return e.Message }
+65
View File
@@ -0,0 +1,65 @@
package device
type ConnectionType string
const (
ConnectionUSB ConnectionType = "usb"
ConnectionWifi ConnectionType = "wifi"
)
type Status string
const (
StatusOnline Status = "online"
StatusOffline Status = "offline"
StatusUnauthorized Status = "unauthorized"
StatusRecovery Status = "recovery"
)
type Device struct {
ID string `json:"id"`
Model string `json:"model"`
Product string `json:"product"`
AndroidVersion string `json:"androidVersion"`
ABI string `json:"abi"`
ConnectionType ConnectionType `json:"connectionType"`
Status Status `json:"status"`
BatteryLevel int `json:"batteryLevel"`
Manufacturer string `json:"manufacturer"`
}
// DeviceLiveStats holds fields that change while the device is running.
// Polled separately from the static DeviceInfo.
type DeviceLiveStats struct {
AvailableRAMMB int `json:"availableRamMb"`
BatteryLevel int `json:"batteryLevel"`
BatteryStatus string `json:"batteryStatus"`
ThermalStatus string `json:"thermalStatus"`
AvailableStorageGB string `json:"availableStorageGb"`
IPAddress string `json:"ipAddress"`
}
// DeviceInfo holds extended device details fetched on demand.
type DeviceInfo struct {
ID string `json:"id"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
Product string `json:"product"`
AndroidVersion string `json:"androidVersion"`
SDKVersion string `json:"sdkVersion"`
SecurityPatch string `json:"securityPatch"`
BuildFingerprint string `json:"buildFingerprint"`
ABI string `json:"abi"`
SupportedABIs string `json:"supportedAbis"`
ScreenResolution string `json:"screenResolution"`
ScreenDensity string `json:"screenDensity"`
TotalRAMMB int `json:"totalRamMb"`
AvailableRAMMB int `json:"availableRamMb"`
TotalStorageGB string `json:"totalStorageGb"`
AvailableStorageGB string `json:"availableStorageGb"`
BatteryLevel int `json:"batteryLevel"`
BatteryStatus string `json:"batteryStatus"`
ThermalStatus string `json:"thermalStatus"`
IPAddress string `json:"ipAddress"`
SerialNumber string `json:"serialNumber"`
}