Skip to content

Commit 63bb7dc

Browse files
feat: card-row device list and brand-aware side panel (#5)
2 parents 400486a + 37871f9 commit 63bb7dc

25 files changed

Lines changed: 691 additions & 140 deletions

internal/adb/adb.go

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net"
88
"os/exec"
99
"strings"
10+
"sync"
1011
"time"
1112

1213
"mirroid/internal/model"
@@ -20,9 +21,10 @@ const (
2021

2122
// Device represents a connected Android device.
2223
type Device struct {
23-
Serial string `json:"serial"`
24-
Model string `json:"model"`
25-
Source string `json:"source"` // "usb", "wireless", "mdns"
24+
Serial string `json:"serial"`
25+
Model string `json:"model"`
26+
Manufacturer string `json:"manufacturer,omitempty"`
27+
Source string `json:"source"` // "usb", "wireless", "mdns"
2628
}
2729

2830
// String returns a display-friendly label.
@@ -45,13 +47,24 @@ func isIPPort(serial string) bool {
4547

4648
type Client struct {
4749
adbPath string
50+
51+
propsMu sync.Mutex
52+
propsCache map[string]deviceProps // serial → cached manufacturer/model from getprop
53+
}
54+
55+
type deviceProps struct {
56+
manufacturer string
57+
model string
4858
}
4959

5060
func NewClient(adbPath string) *Client {
5161
if adbPath == "" {
5262
adbPath = "adb"
5363
}
54-
return &Client{adbPath: adbPath}
64+
return &Client{
65+
adbPath: adbPath,
66+
propsCache: make(map[string]deviceProps),
67+
}
5568
}
5669

5770
// Path returns the configured adb binary path.
@@ -109,7 +122,7 @@ func (c *Client) GetDevices() ([]Device, error) {
109122
devices = append(devices, Device{
110123
Serial: serial,
111124
Model: devModel,
112-
Source: source,
125+
Source: source,
113126
})
114127
}
115128

@@ -134,9 +147,77 @@ func (c *Client) GetDevices() ([]Device, error) {
134147
result = append(result, d)
135148
}
136149
}
150+
151+
// fetch manufacturer + model per device in parallel; tolerate failures
152+
c.fillDeviceProperties(result)
153+
137154
return result, nil
138155
}
139156

157+
// fillDeviceProperties populates the Manufacturer field and refreshes Model
158+
// for each device via getprop, preferring the getprop model (with proper
159+
// spaces) over `adb devices -l`'s underscore-encoded form. Results are cached
160+
// per serial since these props don't change at runtime; cache lookups skip the
161+
// adb roundtrip entirely. Per-device failures are silently ignored.
162+
func (c *Client) fillDeviceProperties(devices []Device) {
163+
var wg sync.WaitGroup
164+
for i := range devices {
165+
// fast path: cached
166+
c.propsMu.Lock()
167+
cached, ok := c.propsCache[devices[i].Serial]
168+
c.propsMu.Unlock()
169+
if ok {
170+
if cached.manufacturer != "" {
171+
devices[i].Manufacturer = cached.manufacturer
172+
}
173+
if cached.model != "" {
174+
devices[i].Model = cached.model
175+
}
176+
continue
177+
}
178+
179+
wg.Add(1)
180+
go func(idx int) {
181+
defer wg.Done()
182+
ctx, cancel := context.WithTimeout(context.Background(), adbTimeout)
183+
defer cancel()
184+
cmd := exec.CommandContext(ctx, c.adbPath, "-s", devices[idx].Serial, "shell",
185+
"getprop ro.product.manufacturer; getprop ro.product.model")
186+
platform.HideConsole(cmd)
187+
out, err := cmd.Output()
188+
if err != nil {
189+
return
190+
}
191+
// strip embedded \r (adb on Windows emits \r\n) up front
192+
normalized := strings.ReplaceAll(string(out), "\r", "")
193+
lines := strings.Split(strings.TrimRight(normalized, "\n"), "\n")
194+
var mfr, mdl string
195+
if len(lines) > 0 {
196+
mfr = strings.TrimSpace(lines[0])
197+
if strings.EqualFold(mfr, "unknown") {
198+
mfr = "" // treat the literal Android sentinel as missing
199+
}
200+
}
201+
if len(lines) > 1 {
202+
mdl = strings.TrimSpace(lines[1])
203+
if strings.EqualFold(mdl, "unknown") {
204+
mdl = "" // android sentinel; let `adb devices -l` model win
205+
}
206+
}
207+
if mfr != "" {
208+
devices[idx].Manufacturer = mfr
209+
}
210+
if mdl != "" {
211+
devices[idx].Model = mdl
212+
}
213+
c.propsMu.Lock()
214+
c.propsCache[devices[idx].Serial] = deviceProps{manufacturer: mfr, model: mdl}
215+
c.propsMu.Unlock()
216+
}(i)
217+
}
218+
wg.Wait()
219+
}
220+
140221
// Pair runs `adb pair <addr> <password>`.
141222
func (c *Client) Pair(addr, password string) error {
142223
ctx, cancel := context.WithTimeout(context.Background(), adbLongTimeout)
@@ -196,4 +277,3 @@ func (c *Client) VerifyConnection(serial string) bool {
196277
}
197278
return strings.TrimSpace(string(out)) == "ok"
198279
}
199-

internal/icons/brand_colors.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package icons
2+
3+
import (
4+
"image/color"
5+
"strings"
6+
)
7+
8+
// brandColors maps Simple Icons slugs to the brand's official hex color.
9+
// values sourced from simple-icons/data/simple-icons.json.
10+
var brandColors = map[string]color.NRGBA{
11+
"google": {R: 0x42, G: 0x85, B: 0xF4, A: 0xff},
12+
"samsung": {R: 0x14, G: 0x28, B: 0xA0, A: 0xff},
13+
"xiaomi": {R: 0xFF, G: 0x69, B: 0x00, A: 0xff},
14+
"oneplus": {R: 0xF5, G: 0x01, B: 0x0C, A: 0xff},
15+
"huawei": {R: 0xFF, G: 0x00, B: 0x00, A: 0xff},
16+
"lg": {R: 0xA5, G: 0x00, B: 0x34, A: 0xff},
17+
"motorola": {R: 0xE1, G: 0x14, B: 0x0A, A: 0xff},
18+
"sony": {R: 0xFF, G: 0xFF, B: 0xFF, A: 0xff},
19+
"oppo": {R: 0x2D, G: 0x68, B: 0x3D, A: 0xff},
20+
"vivo": {R: 0x41, G: 0x5F, B: 0xFF, A: 0xff},
21+
"asus": {R: 0x00, G: 0x00, B: 0x00, A: 0xff},
22+
"honor": {R: 0x00, G: 0x00, B: 0x00, A: 0xff},
23+
"nokia": {R: 0x00, G: 0x5A, B: 0xFF, A: 0xff},
24+
"nothing": {R: 0x00, G: 0x00, B: 0x00, A: 0xff},
25+
}
26+
27+
// BrandColor returns the official brand color for the given manufacturer, or
28+
// (nil, false) if no color is bundled. Lookup matches BrandIcon's rules.
29+
func BrandColor(manufacturer string) (color.Color, bool) {
30+
key := resolveBrandKey(manufacturer)
31+
if key == "" {
32+
return nil, false
33+
}
34+
if c, ok := brandColors[key]; ok {
35+
return c, true
36+
}
37+
if alt := firstTokenKey(key); alt != "" {
38+
if c, ok := brandColors[alt]; ok {
39+
return c, true
40+
}
41+
}
42+
return nil, false
43+
}
44+
45+
// firstTokenKey returns the prefix of key up to the first space/comma/period,
46+
// or "" when there is no separator.
47+
func firstTokenKey(key string) string {
48+
if i := strings.IndexAny(key, " ,."); i > 0 {
49+
return key[:i]
50+
}
51+
return ""
52+
}
53+
54+
// IsLightColor reports whether a color is bright enough that it would
55+
// disappear against a white background (e.g., Sony's white).
56+
func IsLightColor(c color.Color) bool {
57+
return perceivedBrightness(c) > 0.85
58+
}
59+
60+
// IsDarkColor reports whether a color is dark enough that it would disappear
61+
// against a dark-gray background (e.g., Honor's black).
62+
func IsDarkColor(c color.Color) bool {
63+
return perceivedBrightness(c) < 0.05
64+
}
65+
66+
// IsDarkBackground reports whether the given color is dark enough to warrant
67+
// dark-theme treatment. Threshold (0.5) tuned for the default Fyne themes.
68+
func IsDarkBackground(c color.Color) bool {
69+
return perceivedBrightness(c) < 0.5
70+
}
71+
72+
// perceivedBrightness is a fast sRGB-weighted approximation, not WCAG
73+
// relative luminance — it skips the gamma-to-linear step. Thresholds in
74+
// IsLightColor / IsDarkColor / IsDarkBackground are tuned against this scale.
75+
func perceivedBrightness(c color.Color) float64 {
76+
nr := color.NRGBAModel.Convert(c).(color.NRGBA)
77+
r := float64(nr.R) / 255.0
78+
g := float64(nr.G) / 255.0
79+
b := float64(nr.B) / 255.0
80+
return 0.2126*r + 0.7152*g + 0.0722*b
81+
}

internal/icons/icons.go

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,49 @@ import (
1414
"fyne.io/fyne/v2/theme"
1515
)
1616

17-
//go:embed svg/*.svg
17+
//go:embed svg/*.svg svg/brands/*.svg
1818
var svgFS embed.FS
1919

20+
var brandAliases = map[string]string{
21+
"google llc": "google",
22+
"samsung electronics": "samsung",
23+
"xiaomi communications co ltd": "xiaomi",
24+
"redmi": "xiaomi",
25+
"poco": "xiaomi",
26+
"oneplus technology": "oneplus",
27+
"motorola mobility": "motorola",
28+
"hmd global": "nokia",
29+
}
30+
31+
func BrandIcon(manufacturer string) fyne.Resource {
32+
key := resolveBrandKey(manufacturer)
33+
if key == "" {
34+
return nil
35+
}
36+
if b, err := svgFS.ReadFile("svg/brands/" + key + ".svg"); err == nil {
37+
return &fyne.StaticResource{StaticName: "brand-" + key + ".svg", StaticContent: b}
38+
}
39+
if alt := firstTokenKey(key); alt != "" {
40+
if b, err := svgFS.ReadFile("svg/brands/" + alt + ".svg"); err == nil {
41+
return &fyne.StaticResource{StaticName: "brand-" + alt + ".svg", StaticContent: b}
42+
}
43+
}
44+
return nil
45+
}
46+
47+
// resolveBrandKey normalizes a manufacturer string to its brand-icon lookup
48+
// key (lowercased, trimmed, alias-mapped). Returns "" for empty input.
49+
func resolveBrandKey(manufacturer string) string {
50+
key := strings.ToLower(strings.TrimSpace(manufacturer))
51+
if key == "" {
52+
return ""
53+
}
54+
if alias, ok := brandAliases[key]; ok {
55+
return alias
56+
}
57+
return key
58+
}
59+
2060
func mustLoad(name string) *fyne.StaticResource {
2161
b, err := svgFS.ReadFile("svg/" + name)
2262
if err != nil {
@@ -62,16 +102,33 @@ func NewThemedIcon(res fyne.Resource) fyne.Resource {
62102
return &themedStrokeResource{src: res}
63103
}
64104

65-
// NewTintedIcon substitutes `currentColor` once at construction; use NewThemedIcon when the color should follow the theme.
66-
// the tint hex is embedded in the resource name so each tinted variant has a
67-
// unique key in fyne's name-based svg cache.
105+
var (
106+
tintedMu sync.Mutex
107+
tintedCache = make(map[string]*fyne.StaticResource)
108+
)
109+
110+
// NewTintedIcon substitutes `currentColor` once at construction. Use
111+
// NewThemedIcon when the color should follow the active theme. Resources are
112+
// cached per (source name, tint hex) so repeated calls in list bind callbacks
113+
// don't re-allocate.
68114
func NewTintedIcon(res fyne.Resource, c color.Color) fyne.Resource {
69115
hex := colorToHex(c)
70-
content := strings.ReplaceAll(string(res.Content()), "currentColor", hex)
71-
return &fyne.StaticResource{
72-
StaticName: res.Name() + "@" + hex,
73-
StaticContent: []byte(content),
116+
key := res.Name() + "@" + hex
117+
118+
tintedMu.Lock()
119+
if cached, ok := tintedCache[key]; ok {
120+
tintedMu.Unlock()
121+
return cached
74122
}
123+
tintedMu.Unlock()
124+
125+
content := strings.ReplaceAll(string(res.Content()), "currentColor", hex)
126+
out := &fyne.StaticResource{StaticName: key, StaticContent: []byte(content)}
127+
128+
tintedMu.Lock()
129+
tintedCache[key] = out
130+
tintedMu.Unlock()
131+
return out
75132
}
76133

77134
func colorToHex(c color.Color) string {

internal/icons/svg/brands/asus.svg

Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading

internal/icons/svg/brands/lg.svg

Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading

0 commit comments

Comments
 (0)