From 34df8d2296f4f633925b94b25c964476cc703d36 Mon Sep 17 00:00:00 2001 From: iamanaws <78835633+Iamanaws@users.noreply.github.com> Date: Tue, 2 Dec 2025 22:28:29 -0800 Subject: [PATCH 1/3] feat(adb): add built-in Rust ADB backend --- Cargo.lock | 538 ++++++++++++++++++++++++--- Cargo.toml | 1 + crates/uad-cli/src/commands.rs | 67 +++- crates/uad-cli/src/device.rs | 8 +- crates/uad-cli/src/main.rs | 50 ++- crates/uad-cli/src/output.rs | 12 +- crates/uad-cli/src/repl.rs | 43 ++- crates/uad-core/Cargo.toml | 1 + crates/uad-core/src/adb.rs | 504 +++++++++++++++---------- crates/uad-core/src/config.rs | 5 + crates/uad-core/src/sync.rs | 99 +++-- crates/uad-gui/src/gui.rs | 29 +- crates/uad-gui/src/views/about.rs | 34 +- crates/uad-gui/src/views/list.rs | 64 ++-- crates/uad-gui/src/views/settings.rs | 84 ++++- flake.lock | 6 +- 16 files changed, 1190 insertions(+), 355 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e40c813..5c53a1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,31 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +[[package]] +name = "adb_client" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4974ac06396fce4424d9d2193f80ea5a177d6e2ddde6214ff7c14aaedd2a3104" +dependencies = [ + "base64", + "byteorder", + "chrono", + "image", + "log", + "num-bigint-dig", + "num-traits", + "num_enum", + "quick-protobuf", + "rand 0.10.1", + "rcgen", + "regex", + "rsa", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", +] + [[package]] name = "adler2" version = "2.0.1" @@ -37,6 +62,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "aligned" version = "0.4.3" @@ -227,6 +261,45 @@ dependencies = [ "zbus", ] +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -473,7 +546,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom", + "nom 8.0.0", "num-rational", "v_frame", ] @@ -493,13 +566,19 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] [[package]] @@ -508,6 +587,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bit_field" version = "0.10.3" @@ -541,6 +629,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -604,6 +701,12 @@ dependencies = [ "syn", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -704,10 +807,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] -name = "chrono" -version = "0.4.43" +name = "chacha20" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", @@ -854,6 +968,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cookie" version = "0.18.1" @@ -986,6 +1106,24 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1039,6 +1177,16 @@ dependencies = [ "wgpu", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "csv" version = "1.4.0" @@ -1086,6 +1234,37 @@ dependencies = [ "winreg 0.52.0", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.6" @@ -1095,6 +1274,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1597,6 +1787,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1639,6 +1839,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -2177,9 +2378,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", @@ -2195,8 +2396,8 @@ dependencies = [ "rayon", "rgb", "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.12", + "zune-core", + "zune-jpeg", ] [[package]] @@ -2351,6 +2552,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -2457,9 +2661,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" dependencies = [ "value-bag", ] @@ -2537,6 +2741,12 @@ dependencies = [ "paste", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2560,9 +2770,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.11" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", @@ -2682,6 +2892,16 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -2707,6 +2927,23 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "serde", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2733,6 +2970,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" @@ -2766,9 +3014,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -2776,9 +3024,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -3145,6 +3393,15 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -3242,6 +3499,25 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3291,6 +3567,27 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -3446,6 +3743,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-protobuf" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d6da84cc204722a989e01ba2f6e1e276e190f22263d0cb6ce8526fcdb0d2e1f" +dependencies = [ + "byteorder", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -3501,6 +3807,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.1", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3539,6 +3856,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "range-alloc" version = "0.1.4" @@ -3588,9 +3911,9 @@ dependencies = [ [[package]] name = "ravif" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" dependencies = [ "avif-serialize", "imgref", @@ -3627,6 +3950,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "read-fonts" version = "0.35.0" @@ -3676,6 +4013,35 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -3744,6 +4110,26 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3765,6 +4151,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "0.38.44" @@ -3793,9 +4188,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "log", "once_cell", @@ -3808,9 +4203,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "zeroize", ] @@ -3969,6 +4364,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3985,6 +4391,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -4144,6 +4560,12 @@ dependencies = [ "x11rb", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" @@ -4153,6 +4575,16 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4306,16 +4738,16 @@ dependencies = [ [[package]] name = "tiff" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ "fax", "flate2", "half", "quick-error", "weezl", - "zune-jpeg 0.4.21", + "zune-jpeg", ] [[package]] @@ -4532,6 +4964,12 @@ dependencies = [ "core_maths", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "uad-cli" version = "1.2.0" @@ -4556,6 +4994,7 @@ dependencies = [ name = "uad-core" version = "1.2.0" dependencies = [ + "adb_client", "chrono", "csv", "dirs", @@ -5119,7 +5558,7 @@ checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" dependencies = [ "arrayvec", "bit-set", - "bit-vec", + "bit-vec 0.8.0", "bitflags 2.10.0", "bytemuck", "cfg_aliases 0.2.1", @@ -5891,6 +6330,24 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -5938,6 +6395,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + [[package]] name = "yazi" version = "0.2.1" @@ -6120,12 +6587,6 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - [[package]] name = "zune-core" version = "0.5.1" @@ -6141,22 +6602,13 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - [[package]] name = "zune-jpeg" version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" dependencies = [ - "zune-core 0.5.1", + "zune-core", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0464938..9104739 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ edition = "2024" [workspace.dependencies] # Core dependencies +adb_client = "3.2.2" serde = { version = "^1.0", features = ["derive"] } serde_json = "^1.0" log = "^0.4" diff --git a/crates/uad-cli/src/commands.rs b/crates/uad-cli/src/commands.rs index 62286bd..1944160 100644 --- a/crates/uad-cli/src/commands.rs +++ b/crates/uad-cli/src/commands.rs @@ -2,10 +2,10 @@ use clap::CommandFactory; use clap_complete::{Shell, generate}; use std::collections::{HashMap, HashSet}; use std::io::Write; -use uad_core::adb::{ACommand, PmListPacksFlag}; +use uad_core::adb::{ACommand, AdbBackend, PmListPacksFlag}; use uad_core::sync::{ CorePackage, Phone, User, apply_pkg_state_commands, get_devices_list, get_package_state, - run_adb_shell_action, + run_adb_shell_action_with_backend, }; use uad_core::uad_lists::{Package, PackageState, Removal, UadList, load_debloat_lists}; use uad_core::utils::{matches_search, truncate_description}; @@ -15,9 +15,12 @@ use crate::filters::{ListFilter, RemovalFilter, StateFilter}; use crate::{Cli, print_or_exit, println_or_exit}; /// List all connected Android devices -pub fn list_devices() -> Result<(), Box> { - println_or_exit!("Scanning for connected devices..."); - let devices = get_devices_list(); +pub fn list_devices(backend: AdbBackend) -> Result<(), Box> { + println_or_exit!( + "Scanning for connected devices (using {} backend)...", + backend + ); + let devices = get_devices_list(backend); if devices.is_empty() { return Err(NO_DEVICES_FOUND.into()); @@ -104,8 +107,18 @@ pub struct DisplayConfig { pub show_removal: bool, } +pub(crate) fn resolve_pm_flag(state_filter: Option) -> Option { + match state_filter { + Some(StateFilter::Enabled | StateFilter::Disabled) => { + state_filter.and_then(StateFilter::to_pm_flag) + } + _ => Some(PmListPacksFlag::IncludeUninstalled), + } +} + /// List packages on a device with filtering pub fn list_packages( + backend: AdbBackend, device: Option, state_filter: Option, removal_filter: Option, @@ -113,7 +126,7 @@ pub fn list_packages( search: Option, user_id: Option, ) -> Result<(), Box> { - let target_device = get_target_device(device)?; + let target_device = get_target_device(backend, device)?; let uad_lists = load_debloat_lists(false).unwrap_or_else(|lists| lists); println_or_exit!( @@ -129,8 +142,8 @@ pub fn list_packages( search, }; - let pm_flag = state_filter.and_then(StateFilter::to_pm_flag); - let system_packages = ACommand::new() + let pm_flag = resolve_pm_flag(state_filter); + let system_packages = ACommand::with_backend(backend) .shell(&target_device.adb_id) .pm() .list_packages_sys(pm_flag, user_id)?; @@ -141,6 +154,7 @@ pub fn list_packages( &target_device.adb_id, user_id, &context, + backend, )?; if displayed_count == 0 { @@ -159,18 +173,19 @@ pub fn display_package_list( device_serial: &str, user_id: Option, context: &PackageListContext, + backend: AdbBackend, ) -> Result> { let config = context.display_config(); let mut displayed_count = 0; - let enabled_packages: HashSet = ACommand::new() + let enabled_packages: HashSet = ACommand::with_backend(backend) .shell(device_serial) .pm() .list_packages_sys(Some(PmListPacksFlag::OnlyEnabled), user_id) .unwrap_or_default() .into_iter() .collect(); - let disabled_packages: HashSet = ACommand::new() + let disabled_packages: HashSet = ACommand::with_backend(backend) .shell(device_serial) .pm() .list_packages_sys(Some(PmListPacksFlag::OnlyDisabled), user_id) @@ -241,6 +256,7 @@ pub fn display_package_entry( /// Change the state of one or more packages pub fn change_package_state( + backend: AdbBackend, packages: &[String], device: Option, user_id: Option, @@ -252,7 +268,7 @@ pub fn change_package_state( return Err("No packages specified".into()); } - let target_device = get_target_device(device)?; + let target_device = get_target_device(backend, device)?; let user = get_user(&target_device, user_id)?; let uad_lists = load_debloat_lists(false).unwrap_or_else(|lists| lists); @@ -275,6 +291,7 @@ pub fn change_package_state( user, target_state, dry_run, + backend, &uad_lists, )?; println!(); @@ -296,6 +313,7 @@ fn process_package_state_change( user: User, target_state: PackageState, dry_run: bool, + backend: AdbBackend, uad_lists: &HashMap, ) -> Result<(), Box> { let current_state = get_package_state(&device.adb_id, pkg_name, Some(user.id)) @@ -337,6 +355,7 @@ fn process_package_state_change( user, device, &commands, + backend, " ", )?; } @@ -345,6 +364,10 @@ fn process_package_state_change( } /// Execute commands and verify package state with fallback +#[allow( + clippy::too_many_arguments, + reason = "Keeps the shared fallback helper simple across CLI and REPL callers" +)] pub fn execute_with_fallback( package: &str, target_state: PackageState, @@ -352,6 +375,7 @@ pub fn execute_with_fallback( user: User, device: &Phone, commands: &[String], + backend: AdbBackend, indent: &str, ) -> Result<(), Box> { // Capture the before-state of packages on other users for cross-user detection @@ -360,7 +384,7 @@ pub fn execute_with_fallback( // Execute commands for cmd in commands { - match run_adb_shell_action(&device.adb_id, cmd.as_str()) { + match run_adb_shell_action_with_backend(&device.adb_id, cmd.as_str(), backend) { Ok(_) => println!("{}✓ {}", indent, cmd), Err(e) => { return Err(format!("{indent}✗ Failed to execute `{cmd}`: {e:?}").into()); @@ -417,6 +441,7 @@ pub fn execute_with_fallback( /// Show detailed information about a package pub fn show_package_info( + backend: AdbBackend, package: &str, device: Option, user_id: Option, @@ -436,7 +461,7 @@ pub fn show_package_info( } if let Some(device_id) = device { - let target_device = get_target_device(Some(device_id))?; + let target_device = get_target_device(backend, Some(device_id))?; let user = get_user(&target_device, user_id)?; println!("Device: {} ({})", target_device.model, target_device.adb_id); println!("User: {}", user.id); @@ -468,3 +493,19 @@ pub fn generate_completions(shell: Shell) { let name = cmd.get_name().to_string(); generate(shell, &mut cmd, name, &mut std::io::stdout()); } + +/// Show ADB backend and version information +pub fn show_adb_info(backend: AdbBackend) -> Result<(), Box> { + println!("ADB Backend: {}\n", backend); + + match ACommand::with_backend(backend).version() { + Ok(version) => { + println!("{version}"); + Ok(()) + } + Err(e) => { + eprintln!("Failed to get ADB version: {e}"); + Err(e.into()) + } + } +} diff --git a/crates/uad-cli/src/device.rs b/crates/uad-cli/src/device.rs index 54fee8c..5c9d366 100644 --- a/crates/uad-cli/src/device.rs +++ b/crates/uad-cli/src/device.rs @@ -1,11 +1,15 @@ +use uad_core::adb::AdbBackend; use uad_core::sync::{Phone, User, get_devices_list}; pub const NO_DEVICES_FOUND: &str = "No devices found. Make sure ADB is installed and devices are connected."; /// Get target device, either by serial or first available -pub fn get_target_device(device: Option) -> Result> { - let devices = get_devices_list(); +pub fn get_target_device( + backend: AdbBackend, + device: Option, +) -> Result> { + let devices = get_devices_list(backend); if devices.is_empty() { return Err(NO_DEVICES_FOUND.into()); diff --git a/crates/uad-cli/src/main.rs b/crates/uad-cli/src/main.rs index f473e28..83b3143 100644 --- a/crates/uad-cli/src/main.rs +++ b/crates/uad-cli/src/main.rs @@ -4,12 +4,14 @@ clippy::uninlined_format_args, clippy::map_unwrap_or, clippy::unnecessary_wraps, + clippy::exit, reason = "Suppress non-critical pedantic/style lints to keep build green" )] -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; use clap_complete::Shell; use std::process::ExitCode; +use uad_core::adb::AdbBackend; use uad_core::uad_lists::PackageState; mod commands; @@ -20,12 +22,40 @@ mod repl; use filters::{ListFilter, RemovalFilter, StateFilter}; +/// CLI-compatible ADB backend selection +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum AdbBackendArg { + /// Built-in ADB implementation (no external dependencies) + Builtin, + /// Use system-installed adb binary + System, +} + +impl From for AdbBackend { + fn from(arg: AdbBackendArg) -> Self { + match arg { + AdbBackendArg::Builtin => AdbBackend::Builtin, + AdbBackendArg::System => AdbBackend::System, + } + } +} + #[derive(Parser)] #[command(name = "uad")] #[command(about = "Universal Android Debloater - Command Line Interface", long_about = None)] #[command(version)] #[command(propagate_version = true)] pub struct Cli { + /// ADB backend to use: system (default, uses adb binary) or builtin (no dependencies) + #[arg( + short = 'B', + long = "backend", + value_enum, + global = true, + default_value = "system" + )] + backend: AdbBackendArg, + #[command(subcommand)] command: Commands, } @@ -136,6 +166,9 @@ enum Commands { /// Update UAD package lists from remote repository Update, + /// Show ADB backend and version information + Adb, + /// Generate shell completion script Completions { /// Shell to generate completions for @@ -169,10 +202,11 @@ async fn main() -> ExitCode { fn run() -> Result<(), Box> { let cli = Cli::parse(); + let backend: AdbBackend = cli.backend.into(); match cli.command { Commands::Devices => { - commands::list_devices()?; + commands::list_devices(backend)?; } Commands::List { device, @@ -182,7 +216,7 @@ fn run() -> Result<(), Box> { search, user, } => { - commands::list_packages(device, state, removal, list, search, user)?; + commands::list_packages(backend, device, state, removal, list, search, user)?; } Commands::Uninstall { packages, @@ -191,6 +225,7 @@ fn run() -> Result<(), Box> { dry_run, } => { commands::change_package_state( + backend, &packages, device, user, @@ -206,6 +241,7 @@ fn run() -> Result<(), Box> { dry_run, } => { commands::change_package_state( + backend, &packages, device, user, @@ -221,6 +257,7 @@ fn run() -> Result<(), Box> { dry_run, } => { commands::change_package_state( + backend, &packages, device, user, @@ -234,16 +271,19 @@ fn run() -> Result<(), Box> { device, user, } => { - commands::show_package_info(&package, device, user)?; + commands::show_package_info(backend, &package, device, user)?; } Commands::Update => { commands::update_lists()?; } + Commands::Adb => { + commands::show_adb_info(backend)?; + } Commands::Completions { shell } => { commands::generate_completions(shell); } Commands::Repl { device, user } => { - repl::repl_mode(device, user)?; + repl::repl_mode(backend, device, user)?; } } diff --git a/crates/uad-cli/src/output.rs b/crates/uad-cli/src/output.rs index cff4724..ae62a74 100644 --- a/crates/uad-cli/src/output.rs +++ b/crates/uad-cli/src/output.rs @@ -3,16 +3,22 @@ #[macro_export] macro_rules! println_or_exit { () => { - let _ = writeln!(std::io::stdout()); + if writeln!(std::io::stdout()).is_err() { + std::process::exit(0); + } }; ($($arg:tt)*) => { - let _ = writeln!(std::io::stdout(), $($arg)*); + if writeln!(std::io::stdout(), $($arg)*).is_err() { + std::process::exit(0); + } }; } #[macro_export] macro_rules! print_or_exit { ($($arg:tt)*) => { - let _ = write!(std::io::stdout(), $($arg)*); + if write!(std::io::stdout(), $($arg)*).is_err() { + std::process::exit(0); + } }; } diff --git a/crates/uad-cli/src/repl.rs b/crates/uad-cli/src/repl.rs index eeee0c6..47f75ee 100644 --- a/crates/uad-cli/src/repl.rs +++ b/crates/uad-cli/src/repl.rs @@ -2,7 +2,7 @@ use rustyline::DefaultEditor; use rustyline::error::ReadlineError; use std::collections::HashMap; use std::io::Write; -use uad_core::adb::ACommand; +use uad_core::adb::{ACommand, AdbBackend}; use uad_core::sync::{CorePackage, Phone, User, apply_pkg_state_commands, get_package_state}; use uad_core::uad_lists::{Package, PackageState, Removal, UadList, load_debloat_lists}; @@ -18,13 +18,15 @@ enum ReplAction { /// Start interactive REPL mode pub fn repl_mode( + backend: AdbBackend, device: Option, user_id: Option, ) -> Result<(), Box> { println!("Universal Android Debloater - Interactive Mode"); + println!("Using {} ADB backend", backend); println!("Type 'help' for available commands, 'exit' or 'quit' to leave\n"); - let target_device = get_target_device(device)?; + let target_device = get_target_device(backend, device)?; let user = get_user(&target_device, user_id)?; println!( @@ -46,7 +48,15 @@ pub fn repl_mode( let readline = rl.readline("uad> "); match readline { Ok(line) => { - match handle_repl_line(&line, &mut rl, &target_device, user, user_id, &uad_lists) { + match handle_repl_line( + &line, + &mut rl, + &target_device, + user, + user_id, + &uad_lists, + backend, + ) { Ok(ReplAction::Continue) => {} Ok(ReplAction::Exit) => break, Err(e) => { @@ -88,6 +98,7 @@ fn handle_repl_line( user: User, user_id: Option, uad_lists: &HashMap, + backend: AdbBackend, ) -> Result> { let line = line.trim(); if line.is_empty() { @@ -108,7 +119,7 @@ fn handle_repl_line( return Ok(ReplAction::Exit); } "list" | "ls" => { - handle_list_command(&parts[1..], device, user_id, uad_lists)?; + handle_list_command(&parts[1..], device, user_id, uad_lists, backend)?; } "info" => { handle_info_command(&parts[1..], device, user.id, uad_lists)?; @@ -121,6 +132,7 @@ fn handle_repl_line( PackageState::Uninstalled, "Uninstalling", uad_lists, + backend, )?; } "enable" | "restore" => { @@ -131,6 +143,7 @@ fn handle_repl_line( PackageState::Enabled, "Enabling", uad_lists, + backend, )?; } "disable" => { @@ -141,12 +154,13 @@ fn handle_repl_line( PackageState::Disabled, "Disabling", uad_lists, + backend, )?; } "device" => { println!( - "Device: {} ({}), Android SDK: {}, User: {}", - device.model, device.adb_id, device.android_sdk, user.id + "Device: {} ({}), Android SDK: {}, User: {}, Backend: {}", + device.model, device.adb_id, device.android_sdk, user.id, backend ); } "clear" => { @@ -216,11 +230,12 @@ fn handle_list_command( device: &Phone, user_id: Option, uad_lists: &HashMap, + backend: AdbBackend, ) -> Result<(), Box> { let parsed = ReplListArgs::parse(args)?; let pm_flag = parsed.state_filter.and_then(StateFilter::to_pm_flag); - let system_packages = ACommand::new() + let system_packages = ACommand::with_backend(backend) .shell(&device.adb_id) .pm() .list_packages_sys(pm_flag, user_id)?; @@ -238,6 +253,7 @@ fn handle_list_command( &device.adb_id, user_id, &context, + backend, )?; if displayed_count == 0 { @@ -287,6 +303,7 @@ fn handle_state_change_command( target_state: PackageState, action_name: &str, uad_lists: &HashMap, + backend: AdbBackend, ) -> Result<(), Box> { if args.is_empty() { eprintln!( @@ -297,7 +314,15 @@ fn handle_state_change_command( } for pkg_name in args { - process_package_change(pkg_name, device, user, target_state, action_name, uad_lists)?; + process_package_change( + pkg_name, + device, + user, + target_state, + action_name, + uad_lists, + backend, + )?; } Ok(()) @@ -311,6 +336,7 @@ fn process_package_change( target_state: PackageState, action_name: &str, uad_lists: &HashMap, + backend: AdbBackend, ) -> Result<(), Box> { let current_state = get_package_state(&device.adb_id, pkg_name, Some(user.id)) .ok_or("Package not found on device")?; @@ -346,6 +372,7 @@ fn process_package_change( user, device, &commands, + backend, " ", ) } diff --git a/crates/uad-core/Cargo.toml b/crates/uad-core/Cargo.toml index f7d3a43..16e1dbd 100644 --- a/crates/uad-core/Cargo.toml +++ b/crates/uad-core/Cargo.toml @@ -15,6 +15,7 @@ default = [] self-update = ["dep:flate2", "dep:tar"] [dependencies] +adb_client.workspace = true serde.workspace = true serde_json.workspace = true log.workspace = true diff --git a/crates/uad-core/src/adb.rs b/crates/uad-core/src/adb.rs index f2335e0..777e56a 100644 --- a/crates/uad-core/src/adb.rs +++ b/crates/uad-core/src/adb.rs @@ -4,8 +4,7 @@ //! //! Following the design philosophy of most of Rust `std`, //! `*Command` are intended to be "thin wrappers" (low-overhead abstractions) -//! around the ADB CLI or `adb_client` -//! ([in the future](https://github.com/Universal-Debloater-Alliance/universal-android-debloater-next-generation/issues/700) ), +//! around `adb_client` or the system ADB CLI, //! which implies: //! - no "magic" //! - no custom commands @@ -33,11 +32,24 @@ //! please, **PLEASE** refrain from falling-back to any `Command`-like API. //! Rather, please extend these APIs in a consistent way. //! +//! ## Backend Selection +//! +//! This module supports two ADB backends: +//! - **Builtin** (`adb_client`): Pure Rust implementation, no external dependencies +//! - **System**: Uses the system-installed `adb` binary +//! +//! Use [`ACommand::with_backend`] to select a specific backend, +//! or [`ACommand::new`] to use the default (System backend). +//! //! Thank you! ❤️ //! //! For comprehensive info about ADB, //! [see this](https://android.googlesource.com/platform/packages/modules/adb/+/refs/heads/master/docs/) +use adb_client::{ADBDeviceExt, server::ADBServer}; +use serde::{Deserialize, Serialize}; +use std::fmt::Write as _; +use std::io::Cursor; use std::rc::Rc; #[cfg(target_os = "windows")] @@ -53,25 +65,83 @@ pub fn to_trimmed_utf8(v: &[u8]) -> String { String::from_utf8_lossy(v).trim_end().to_string() } +/// ADB backend selection. +/// +/// - **Builtin**: Uses the `adb_client` crate (pure Rust, no external dependencies) +/// - **System**: Uses the system-installed `adb` binary +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum AdbBackend { + /// Built-in ADB implementation via `adb_client` crate. + /// The application can communicate with devices without needing `adb` installed. + Builtin, + /// Uses the system-installed `adb` binary. + /// This is the default to preserve existing behavior. + /// Requires `adb` to be available in PATH. + /// Useful if you prefer using your own ADB installation or need specific ADB features. + #[default] + System, +} + +impl AdbBackend { + /// Returns all available backend variants for UI enumeration + pub const ALL: [Self; 2] = [Self::Builtin, Self::System]; +} + +impl std::fmt::Display for AdbBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Builtin => write!(f, "Builtin"), + Self::System => write!(f, "System (adb)"), + } + } +} + #[cfg(debug_assertions)] #[must_use] fn is_version_triple(s: &str) -> bool { - let mut components = s.split('.'); - for _ in 0..3 { - if !components - .next() - .is_some_and(|comp| comp.as_bytes().iter().all(u8::is_ascii_digit)) - { - return false; - } - } - if components.next().is_some() { - return false; - } - true + let mut parts = s.split('.'); + let is_digits = |c: &str| !c.is_empty() && c.bytes().all(|b| b.is_ascii_digit()); + parts.next().is_some_and(is_digits) + && parts.next().is_some_and(is_digits) + && parts.next().is_some_and(is_digits) + && parts.next().is_none() } -/// Builder object for an Android Debug Bridge CLI command, +#[cfg(debug_assertions)] +fn assert_adb_version_output_format(out: &str) { + const ADBV: &str = "Android Debug Bridge version "; + const V: &str = "Version "; + + let mut lns = out.lines(); + + assert!( + lns.next() + .is_some_and(|ln| ln.starts_with(ADBV) && is_version_triple(&ln[ADBV.len()..])) + ); + assert!( + lns.next().is_some_and(|ln| ln.starts_with(V) + && is_version_triple(&ln[V.len()..ln.find('-').unwrap_or(ln.len())])) + ); + // missing test for valid path + assert!( + lns.next().is_some_and(|ln| ln.starts_with("Installed as ") + && (ln.ends_with("adb") || ln.ends_with("adb.exe"))) + ); + // missing test for x86/ARM (both 64b) + assert!(lns.next().is_some_and(|ln| ln.starts_with("Running on "))); + if lns.next().is_some() { + unreachable!("Expected < 5 lines") + } +} + +/// Internal state for `ACommand` - tracks the device serial and backend to use +#[derive(Debug)] +struct ACommandState { + device_serial: Option, + backend: AdbBackend, +} + +/// Builder object for an Android Debug Bridge command, /// using the type-state and new-type patterns. /// /// This is not intended to model the entire ADB API. @@ -79,12 +149,22 @@ fn is_version_triple(s: &str) -> bool { /// /// [More info here](https://developer.android.com/tools/adb) #[derive(Debug)] -pub struct ACommand(std::process::Command); +pub struct ACommand(ACommandState); + impl ACommand { - /// `adb` command builder + /// `adb` command builder with the default backend. #[must_use] pub fn new() -> Self { - Self(std::process::Command::new("adb")) + Self::with_backend(AdbBackend::default()) + } + + /// `adb` command builder with a specific backend + #[must_use] + pub fn with_backend(backend: AdbBackend) -> Self { + Self(ACommandState { + device_serial: None, + backend, + }) } /// `shell` sub-command builder. @@ -94,9 +174,8 @@ impl ACommand { pub fn shell>(mut self, device_serial: S) -> ShellCommand { let serial = device_serial.as_ref(); if !serial.is_empty() { - self.0.args(["-s", serial]); + self.0.device_serial = Some(serial.to_string()); } - self.0.arg("shell"); ShellCommand(self) } @@ -108,109 +187,189 @@ impl ACommand { /// Status can be (but not limited to): /// - "unauthorized" /// - "device" - pub fn devices(mut self) -> Result, String> { - self.0.arg("devices"); - Ok(self - .run()? - .lines() - .skip(1) // header - .map(|dev_stat| { - let tab_idx = dev_stat - // OS-specific? - .find('\t') - // True on Linux, - // no matter if ADB is piped or connected to terminal - .expect("There must be 1 tab after serial"); - ( - // serial - dev_stat[..tab_idx].to_string(), - // status - dev_stat[(tab_idx + 1)..].to_string(), - ) - }) - .collect()) + pub fn devices(self) -> Result, String> { + match self.0.backend { + AdbBackend::Builtin => Self::devices_builtin(), + AdbBackend::System => Self::devices_system(), + } } - /// `version` sub-command + /// Returns version information from the ADB server/binary. /// - /// ## Format - /// This is just a sample, - /// we don't know which guarantees are stable (yet): + /// ## Builtin backend + /// Returns the ADB server protocol version: + /// ```txt + /// ADB Server Version: 1.0.41 + /// ``` + /// + /// ## System backend + /// Returns the full `adb version` output (format may vary by installation): /// ```txt /// Android Debug Bridge version 1.0.41 - /// Version 34.0.5-debian - /// Installed as /usr/lib/android-sdk/platform-tools/adb - /// Running on Linux 6.12.12-amd64 (x86_64) + /// Version 35.0.2-android-tools + /// Installed as /usr/bin/adb + /// Running on Linux 6.18 (x86_64) /// ``` - /// - /// The expected format should be like: - /// ```txt - /// Android Debug Bridge version .. - /// Version ..- - /// Installed as /platform-tools/adb[.exe] - /// Running on () - /// ``` - #[expect(clippy::panic_in_result_fn, reason = "Assertions are fine")] - pub fn version(mut self) -> Result { - self.0.arg("version"); - let out = self.run()?; + pub fn version(self) -> Result { + match self.0.backend { + AdbBackend::Builtin => Self::version_builtin(), + AdbBackend::System => Self::version_system(), + } + } - #[cfg(debug_assertions)] - { - const ADBV: &str = "Android Debug Bridge version "; - const V: &str = "Version "; + // ========== Builtin backend implementation (adb_client) ========== - let mut lns = out.lines(); - - assert!( - lns.next() - .is_some_and(|ln| ln.starts_with(ADBV) && is_version_triple(&ln[ADBV.len()..])) - ); - assert!(lns.next().is_some_and(|ln| ln.starts_with(V) - && is_version_triple(&ln[V.len()..ln.find('-').unwrap_or(ln.len())]))); - // missing test for valid path - assert!(lns.next().is_some_and(|ln| ln.starts_with("Installed as ") - && (ln.ends_with("adb") || ln.ends_with("adb.exe")))); - // missing test for x86/ARM (both 64b) - assert!(lns.next().is_some_and(|ln| ln.starts_with("Running on "))); - if lns.next().is_some() { - unreachable!("Expected < 5 lines") + /// Get ADB server version using the builtin `adb_client` + fn version_builtin() -> Result { + let mut server = ADBServer::default(); + match server.version() { + Ok(version) => Ok(format!("ADB Server Version: {version}")), + Err(e) => { + error!("Failed to get ADB server version: {e}"); + Err(format!("Cannot get ADB server version: {e}")) } } + } + + /// List devices using the builtin `adb_client` + fn devices_builtin() -> Result, String> { + let mut server = ADBServer::default(); + server + .devices() + .map(|device_list| { + device_list + .into_iter() + .map(|dev| (dev.identifier, dev.state.to_string())) + .collect() + }) + .map_err(|e| { + error!("ADB: {e}"); + format!("Cannot connect to ADB server: {e}") + }) + } + + /// Execute a shell command via `adb_client` (builtin backend) + fn run_shell_command_builtin(&self, shell_command: &str) -> Result { + let mut server = ADBServer::default(); + + // Validate device availability and serial + if let Some(ref serial) = self.0.device_serial { + let device_list = server + .devices() + .map_err(|e| format!("Cannot get device list: {e}"))?; + + if !device_list.iter().any(|d| d.identifier == *serial) { + let available = device_list + .iter() + .map(|d| d.identifier.as_str()) + .collect::>() + .join(", "); + return Err(format!( + "Device '{serial}' not found. Available: {available}" + )); + } + } + + let mut device = server + .get_device() + .map_err(|e| format!("Cannot connect to device: {e}"))?; + + if shell_command.trim().is_empty() { + return Err("Empty shell command".into()); + } + + info!("Ran command: adb shell {shell_command}"); + + let mut buffer = Vec::new(); + let _exit_code = device + .shell_command(&shell_command, Some(&mut Cursor::new(&mut buffer)), None) + .map_err(|e| { + error!("ADB shell command failed: {e}"); + format!("Shell command failed: {e}") + })?; + + Ok(String::from_utf8_lossy(&buffer).trim_end().to_string()) + } + + // ========== System backend implementation (adb binary) ========== + + /// Get ADB version using the system `adb` binary + fn version_system() -> Result { + let mut cmd = std::process::Command::new("adb"); + cmd.arg("version"); + let out = Self::run_system_command(cmd)?; + + #[cfg(debug_assertions)] + assert_adb_version_output_format(&out); Ok(out) } - /// General executor - fn run(self) -> Result { - let mut cmd = self.0; + /// List devices using the system `adb` binary + fn devices_system() -> Result, String> { + let mut cmd = std::process::Command::new("adb"); + cmd.arg("devices"); + Ok(Self::run_system_command(cmd)? + .lines() + .skip(1) // header + .filter_map(|line| { + let (serial, status) = line.split_once('\t')?; + Some((serial.to_string(), status.to_string())) + }) + .collect()) + } + + /// Execute a shell command via system `adb` binary + fn run_shell_command_system(&self, shell_command: &str) -> Result { + let mut cmd = std::process::Command::new("adb"); + + if let Some(ref serial) = self.0.device_serial { + cmd.args(["-s", serial]); + } + + cmd.arg("shell"); + cmd.arg(shell_command); + + info!("Ran command: adb shell {}", shell_command); + Self::run_system_command(cmd) + } + + /// General system command executor for adb binary + fn run_system_command(mut cmd: std::process::Command) -> Result { #[cfg(target_os = "windows")] let cmd = cmd.creation_flags(0x0800_0000); // do not open a cmd window info!( "Ran command: adb {}", cmd.get_args() - .map(|s| s.to_str().unwrap_or_else(|| unreachable!())) + .filter_map(|s| s.to_str()) .collect::>() .join(" ") ); - match cmd.output() { - Err(e) => { - error!("ADB: {e}"); - Err("Cannot run ADB, likely not found".to_string()) - } - Ok(o) => { - let stdout = to_trimmed_utf8(&o.stdout); - if o.status.success() { - Ok(stdout) - } else { - let stderr = to_trimmed_utf8(&o.stderr); - // ADB does really weird things: - // Some errors are not redirected to `stderr` - let err = if stdout.is_empty() { stderr } else { stdout }; - Err(err) - } - } + + let output = cmd.output().map_err(|e| { + error!("ADB: {e}"); + "Cannot run ADB, likely not found".to_string() + })?; + + let stdout = to_trimmed_utf8(&output.stdout); + if output.status.success() { + Ok(stdout) + } else { + // ADB sometimes outputs errors to stdout instead of stderr + Err(if stdout.is_empty() { + to_trimmed_utf8(&output.stderr) + } else { + stdout + }) + } + } + + /// Execute a shell command using the configured backend + fn run_shell_command(&self, shell_command: &str) -> Result { + match self.0.backend { + AdbBackend::Builtin => self.run_shell_command_builtin(shell_command), + AdbBackend::System => self.run_shell_command_system(shell_command), } } } @@ -227,12 +386,13 @@ impl Default for ACommand { /// [More info](https://chromium.googlesource.com/aosp/platform/system/core/+/refs/heads/upstream/shell_and_utilities). #[derive(Debug)] pub struct ShellCommand(ACommand); + impl ShellCommand { /// `pm` command builder - pub fn pm(mut self) -> PmCommand { - self.0.0.arg("pm"); + pub fn pm(self) -> PmCommand { PmCommand(self) } + /// Query a device property value, by its key. /// These can be of any type: /// - `boolean` @@ -241,36 +401,26 @@ impl ShellCommand { /// - etc... /// /// So to avoid lossy conversions, we return strs - pub fn getprop(mut self, key: &str) -> Result { - self.0.0.args(["getprop", key]); - self.0.run() + pub fn getprop(self, key: &str) -> Result { + self.0.run_shell_command(&format!("getprop {key}")) } + /// Reboots device - pub fn reboot(mut self) -> Result { - self.0.0.arg("reboot"); - self.0.run() + pub fn reboot(self) -> Result { + self.0.run_shell_command("reboot") } /// Execute an arbitrary shell action string on the device's default shell. /// The action string is passed as a single argument to `adb shell` and /// interpreted by the remote shell (which splits on spaces). - pub fn raw(mut self, action: &str) -> Result { - self.0.0.arg(action); - self.0.run() + pub fn raw(self, action: &str) -> Result { + self.0.run_shell_command(action) } } #[must_use] pub const fn is_pkg_component(s: &[u8]) -> bool { - if s.is_empty() { - return false; - } - s[0].is_ascii_alphabetic() - && if s.len() > 1 { - is_all_w_c(s.split_at(1).1) - } else { - true - } + !s.is_empty() && s[0].is_ascii_alphabetic() && (s.len() == 1 || is_all_w_c(s.split_at(1).1)) } /// String with the invariant of being a valid package-name. @@ -314,8 +464,7 @@ pub enum PmListPacksFlag { OnlyDisabled, } impl PmListPacksFlag { - // is there a trait for this? - fn to_str(self) -> &'static str { + const fn as_str(self) -> &'static str { match self { Self::IncludeUninstalled => "-u", Self::OnlyEnabled => "-e", @@ -323,10 +472,10 @@ impl PmListPacksFlag { } } } -#[expect(clippy::to_string_trait_impl, reason = "This is not user-facing")] -impl ToString for PmListPacksFlag { - fn to_string(&self) -> String { - self.to_str().to_string() + +impl std::fmt::Display for PmListPacksFlag { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) } } @@ -348,31 +497,27 @@ impl PmCommand { /// - isn't sorted /// - duplicates never _seem_ to happen, but don't assume uniqueness pub fn list_packages_sys( - mut self, - f: Option, + self, + flag: Option, user_id: Option, ) -> Result, String> { - let cmd = &mut self.0.0.0; - - cmd.args(["list", "packages", "-s"]); - if let Some(s) = f { - cmd.arg(s.to_str()); + let mut command = String::from("pm list packages -s"); + if let Some(f) = flag { + write!(&mut command, " {f}").ok(); } - if let Some(u) = user_id { - cmd.arg("--user"); - cmd.arg(u.to_string()); + if let Some(uid) = user_id { + write!(&mut command, " --user {uid}").ok(); } - self.0.0.run().map(|pack_ls| { - pack_ls + self.0.raw(&command).map(|output| { + output .lines() - .filter_map(|p_ln| { - debug_assert!(p_ln.starts_with(PACK_PREFIX)); - let p = &p_ln[PACK_PREFIX.len()..]; - if PackageId::new(p).is_some() { - Some(String::from(p)) + .filter_map(|line| { + let pkg = line.strip_prefix(PACK_PREFIX)?; + if PackageId::new(pkg).is_some() { + Some(pkg.to_string()) } else { - warn!("skipping nonstandard package name: {p:?}"); + warn!("skipping nonstandard package name: {pkg:?}"); None } }) @@ -384,56 +529,22 @@ impl PmCommand { /// /// - /// - - pub fn list_users(mut self) -> Result, String> { - self.0.0.0.args(["list", "users"]); + pub fn list_users(self) -> Result, String> { + // Expected shape: "UserInfo{::}[ running]" + // https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/android/content/pm/UserInfo.java Ok(self .0 - .0 - .run()? + .raw("pm list users")? .lines() .skip(1) // omit header - .map(|ln| { - // this could be optimized by making more API-stability assumptions - let ln = ln.trim_ascii_start(); - let ln = ln.strip_prefix("UserInfo").unwrap_or(ln).trim_ascii_start(); - let ln = ln.strip_prefix('{').unwrap_or(ln).trim_ascii(); - //let run; - let ln = if let Some(l) = ln.strip_suffix("running") { - //run = true; - l.trim_ascii_end() - } else { - //run = false; - ln - }; - let ln = ln.strip_suffix('}').unwrap_or(ln).trim_ascii_end(); - // https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/android/content/pm/UserInfo.java - // The format looks stable today, but google may change it in future Android versions - // (and very old Androids might differ). Keep parsing defensive. - // Expected shape: "UserInfo{::}[ running]" - - let mut comps = ln.split(':'); - - let id = comps - .next() - .expect("There must be at least 1 ':'-separated component") - .parse() - .expect("string assumed to be UID numeral"); - //let name = comps - // .next() - // .expect("There must be at least 2 ':'-separated components. 2nd is user-name"); - //let flags = u32::from_str_radix( - // comps.next().expect( - // "There must be at least 3 ':'-separated components. 3rd is user bit-flags", - // ), - // 16, - //) - //.expect("string assumed to be hexadecimal bit-flags"); - UserInfo { - id, - //name: name.into(), - //flags, - //running: run, - } + .filter_map(|line| { + // Defensive parsing: strip known prefixes/suffixes, extract ID + let s = line.trim_ascii(); + let s = s.strip_prefix("UserInfo{").unwrap_or(s); + let s = s.strip_suffix("running").unwrap_or(s).trim_ascii_end(); + let s = s.strip_suffix('}').unwrap_or(s); + let id = s.split(':').next()?.parse().ok()?; + Some(UserInfo { id }) }) .collect()) } @@ -500,4 +611,15 @@ mod tests { assert_ne!(PackageId::new(p_id), None); } } + + #[test] + fn backend_display() { + assert_eq!(AdbBackend::Builtin.to_string(), "Builtin"); + assert_eq!(AdbBackend::System.to_string(), "System (adb)"); + } + + #[test] + fn backend_default_is_system() { + assert_eq!(AdbBackend::default(), AdbBackend::System); + } } diff --git a/crates/uad-core/src/config.rs b/crates/uad-core/src/config.rs index 5c41d78..515cd63 100644 --- a/crates/uad-core/src/config.rs +++ b/crates/uad-core/src/config.rs @@ -1,5 +1,6 @@ use crate::CACHE_DIR; use crate::CONFIG_DIR; +use crate::adb::AdbBackend; use crate::sync::User; use crate::utils::DisplayablePath; use log::error; @@ -22,6 +23,9 @@ pub struct GeneralSettings { pub theme: String, pub expert_mode: bool, pub backup_folder: PathBuf, + /// ADB backend to use: Builtin (no external dependencies) or System (uses installed adb) + #[serde(default)] + pub adb_backend: AdbBackend, } #[derive(Default, Debug, Clone)] @@ -49,6 +53,7 @@ impl Default for GeneralSettings { theme: DEFAULT_THEME.to_string(), expert_mode: false, backup_folder: CACHE_DIR.join("backups"), + adb_backend: AdbBackend::default(), } } } diff --git a/crates/uad-core/src/sync.rs b/crates/uad-core/src/sync.rs index f90b6ee..64399f8 100644 --- a/crates/uad-core/src/sync.rs +++ b/crates/uad-core/src/sync.rs @@ -1,5 +1,5 @@ use crate::{ - adb::{ACommand as AdbCommand, PM_CLEAR_PACK, PackageId}, + adb::{ACommand as AdbCommand, AdbBackend, PM_CLEAR_PACK, PackageId}, uad_lists::PackageState, }; use log::{error, info}; @@ -61,13 +61,25 @@ pub enum AdbError { /// This replaces the deprecated `adb_shell_command`. /// /// If `serial` is empty, it lets ADB choose the default device. +/// Uses the default ADB backend. pub fn run_adb_shell_action>( device_serial: S, action: &str, +) -> Result { + run_adb_shell_action_with_backend(device_serial, action, AdbBackend::default()) +} + +/// Run an arbitrary shell action via the typed ADB wrapper with a specific backend. +/// +/// If `serial` is empty, it lets ADB choose the default device. +pub fn run_adb_shell_action_with_backend>( + device_serial: S, + action: &str, + backend: AdbBackend, ) -> Result { let serial = device_serial.as_ref(); - match AdbCommand::new().shell(serial).raw(action) { + match AdbCommand::with_backend(backend).shell(serial).raw(action) { Ok(o) => { if ["Error", "Failure"].iter().any(|&e| o.contains(e)) { let friendly_msg = make_friendly_error_message(&o, action); @@ -227,23 +239,48 @@ pub fn request_builder(commands: &[&str], package: &str, user: Option) -> .collect() } +#[must_use] +fn sanitize_device_prop(raw: &str) -> String { + raw.chars() + .filter(|c| !c.is_control()) + .collect::() + .trim() + .to_string() +} + +#[must_use] +fn parse_sdk_version(raw: &str) -> Option { + let sanitized = sanitize_device_prop(raw); + let digits: String = sanitized + .chars() + .skip_while(|c| !c.is_ascii_digit()) + .take_while(char::is_ascii_digit) + .collect(); + + if digits.is_empty() { + None + } else { + digits.parse().ok() + } +} + /// Get the model by querying the `ro.product.model` property. /// /// If `serial` is empty, it lets ADB choose the default device. #[must_use] pub fn get_device_model(serial: &str) -> String { - AdbCommand::new() - .shell(serial) - .getprop("ro.product.model") - .unwrap_or_else(|err| { + match AdbCommand::new().shell(serial).getprop("ro.product.model") { + Ok(model) => sanitize_device_prop(&model), + Err(err) => { eprintln!("ERROR: {err}"); error!("{err}"); if err.contains("adb: no devices/emulators found") { "no devices/emulators found".to_string() } else { - err + sanitize_device_prop(&err) } - }) + } + } } /// Get the brand by querying the `ro.product.brand` property. @@ -254,8 +291,7 @@ pub fn get_device_brand(serial: &str) -> String { AdbCommand::new() .shell(serial) .getprop("ro.product.brand") - // `trim` is just-in-case - .map(|s| s.trim().to_string()) + .map(|s| sanitize_device_prop(&s)) .unwrap_or_default() } @@ -269,7 +305,14 @@ pub fn get_android_sdk(device_serial: &str) -> u8 { .shell(device_serial) .getprop("ro.build.version.sdk") .map_or(0, |sdk| { - sdk.parse().expect("SDK version numeral must be valid") + if let Some(v) = parse_sdk_version(&sdk) { + v + } else { + error!( + "Invalid SDK version {sdk:?} for device '{device_serial}'. Falling back to 0" + ); + 0 + } }) } @@ -457,31 +500,39 @@ pub fn list_users_idx_prot(device_serial: &str) -> Vec { .unwrap_or_default() } -/// This matches serials (`getprop ro.serialno`) -/// that are authorized by the user. +/// Get list of connected devices using a specific ADB backend. #[must_use] -pub fn get_devices_list() -> Vec { +pub fn get_devices_list(backend: AdbBackend) -> Vec { retry( Fixed::from_millis(500).take(if cfg!(debug_assertions) { 3 } else { 10 }), - || match AdbCommand::new().devices() { + || match AdbCommand::with_backend(backend).devices() { Ok(devices) => { let mut device_list: Vec = vec![]; if devices.iter().all(|(_, stat)| stat != "device") { return OperationResult::Retry(vec![]); } - for device in devices { - let serial = &device.0; + for (serial, status) in devices { + if status != "device" { + continue; + } device_list.push(Phone { - model: format!("{} {}", get_device_brand(serial), get_device_model(serial)), - android_sdk: get_android_sdk(serial), - user_list: list_users_idx_prot(serial), - adb_id: serial.clone(), + model: format!( + "{} {}", + get_device_brand(&serial), + get_device_model(&serial) + ), + android_sdk: get_android_sdk(&serial), + user_list: list_users_idx_prot(&serial), + adb_id: serial, }); } + if device_list.is_empty() { + return OperationResult::Retry(vec![]); + } OperationResult::Ok(device_list) } Err(err) => { - error!("get_devices_list() -> {err}"); + error!("get_devices_list(backend) -> {err}"); let test: Vec = vec![]; OperationResult::Retry(test) } @@ -491,8 +542,8 @@ pub fn get_devices_list() -> Vec { } #[must_use] -pub fn initial_load() -> bool { - match AdbCommand::new().devices() { +pub fn initial_load(backend: AdbBackend) -> bool { + match AdbCommand::with_backend(backend).devices() { Ok(_devices) => true, Err(_err) => false, } diff --git a/crates/uad-gui/src/gui.rs b/crates/uad-gui/src/gui.rs index 925aedf..75994f1 100644 --- a/crates/uad-gui/src/gui.rs +++ b/crates/uad-gui/src/gui.rs @@ -83,21 +83,27 @@ impl UadGui { reason = "required by iced's Application trait interface" )] fn title(&self) -> String { - self.selected_device.as_ref().map_or_else( - || FULL_NAME.to_string(), - |device| format!("{FULL_NAME} - {}", device.model), - ) + if self.selected_device.is_some() { + format!("{FULL_NAME} - device connected") + } else { + FULL_NAME.to_string() + } } fn new() -> (Self, Task) { + let app = Self::default(); + let backend = app.settings_view.general.adb_backend; ( - Self::default(), + app, Task::batch([ // Used in crate::widgets::navigation_menu::ICONS. Name is `icomoon`. font::load(include_bytes!("../../../resources/assets/icons.ttf").as_slice()) .map(Message::FontLoaded), - Task::perform(async { initial_load() }, Message::ADBSatisfied), - Task::perform(async { get_devices_list() }, Message::LoadDevices), + Task::perform(async move { initial_load(backend) }, Message::ADBSatisfied), + Task::perform( + async move { get_devices_list(backend) }, + Message::LoadDevices, + ), Task::perform( async move { get_latest_release() }, Message::GetLatestRelease, @@ -196,16 +202,21 @@ impl UadGui { } Message::RefreshButtonPressed => { self.apps_view = AppsView::default(); + let backend = self.settings_view.general.adb_backend; #[expect(unused_must_use, reason = "side-effect")] { self.update(Message::AppsAction(AppsMessage::ADBSatisfied( self.adb_satisfied, ))); } - Task::perform(async { get_devices_list() }, Message::LoadDevices) + Task::perform( + async move { get_devices_list(backend) }, + Message::LoadDevices, + ) } Message::RebootButtonPressed => { self.apps_view = AppsView::default(); + let backend = self.settings_view.general.adb_backend; let serial = match &self.selected_device { Some(d) => d.adb_id.clone(), _ => String::default(), @@ -213,7 +224,7 @@ impl UadGui { self.selected_device = None; self.devices_list = vec![]; Task::perform( - async { adb::ACommand::new().shell(serial).reboot() }, + async move { adb::ACommand::with_backend(backend).shell(serial).reboot() }, |_| Message::Nothing, ) } diff --git a/crates/uad-gui/src/views/about.rs b/crates/uad-gui/src/views/about.rs index 42cb723..93d81df 100644 --- a/crates/uad-gui/src/views/about.rs +++ b/crates/uad-gui/src/views/about.rs @@ -3,10 +3,8 @@ use crate::theme::Theme; use crate::{gui::UpdateState, style, widgets::text}; use iced::widget::{Space, column, container, row}; use iced::{Alignment, Element, Length, Renderer}; -use log::error; use std::path::PathBuf; use uad_core::CACHE_DIR; -use uad_core::adb; use uad_core::uad_lists::LIST_FNAME; use uad_core::utils::{FULL_NAME, NAME, last_modified_date, open_url}; @@ -95,38 +93,10 @@ impl About { .spacing(10) .width(550); - /* - There's no need to fetch this info every time the view is updated, - we could cache it in a `static` `LazyLock`. - - But what if the system updates ADB while the app is running? - the numbers will be out of sync! - - However, the server will still be the "old" version - until it's killed - */ - let adb_version_text = text(match adb::ACommand::new().version() { - Ok(s) => s - .lines() - .nth(0) - .unwrap_or_else(|| unreachable!()) - // This allocation is good. - // If it was a ref, the app would hold the entire string - // instead of the relevant slice. - .to_string(), - Err(e) => { - error!("{e}"); - "Couldn't fetch ADB version. Is it installed?".into() - // satisfy `match` by inferring the type of the `Ok` arm - } - }) - .width(250); - let adb_version_row = row![adb_version_text].align_y(Alignment::Center).width(550); - #[cfg(feature = "self-update")] - let update_column = column![uad_list_row, self_update_row, adb_version_row]; + let update_column = column![uad_list_row, self_update_row]; #[cfg(not(feature = "self-update"))] - let update_column = column![uad_list_row, adb_version_row]; + let update_column = column![uad_list_row]; let update_column = update_column.align_x(Alignment::Center).spacing(10); diff --git a/crates/uad-gui/src/views/list.rs b/crates/uad-gui/src/views/list.rs index a9792dd..8701e92 100644 --- a/crates/uad-gui/src/views/list.rs +++ b/crates/uad-gui/src/views/list.rs @@ -5,7 +5,7 @@ use crate::widgets::navigation_menu::ICONS; use log::{error, info, warn}; use std::path::PathBuf; use uad_core::config::DeviceSettings; -use uad_core::sync::{AdbError, Phone, User, apply_pkg_state_commands}; +use uad_core::sync::{AdbError, CorePackage, Phone, User, apply_pkg_state_commands}; use uad_core::uad_lists::{ Opposite, PackageHashMap, PackageState, Removal, UadList, UadListState, load_debloat_lists, }; @@ -130,6 +130,33 @@ impl From for SummaryEntry { } impl List { + #[inline] + fn current_user_index(&self) -> usize { + self.selected_user.unwrap_or_default().index + } + + #[inline] + fn refilter(&mut self) -> Task { + Self::filter_package_lists(self); + Task::none() + } + + #[inline] + fn map_core_to_row(uad_list: &PackageHashMap, core: &CorePackage) -> PackageRow { + let list = uad_list + .get(&core.name) + .map_or(UadList::Unlisted, |p| p.list); + PackageRow::new( + &core.name, + &core.description, + core.removal, + core.state, + list, + false, + false, + ) + } + pub fn update( &mut self, settings: &mut Settings, @@ -216,7 +243,7 @@ impl List { waiting_view("Finding connected devices...", None, style::Text::Default) } else { waiting_view( - "ADB is not installed on your system, install ADB and relaunch application.", + "No device connection detected. Connect a device with USB debugging enabled and authorized, then relaunch.", Some(button("Read on how to get started.") .on_press(Message::GoToUrl(PathBuf::from( "https://github.com/Universal-Debloater-Alliance/universal-android-debloater-next-generation/wiki/Getting-started", @@ -329,12 +356,13 @@ impl List { settings: &Settings, selected_device: &Phone, ) -> Element<'_, Message, Theme, Renderer> { + let i_user = self.current_user_index(); let packages = self .filtered_packages .iter() .fold(column![].spacing(6), |col, &i| { col.push( - self.phone_packages[self.selected_user.unwrap_or_default().index][i] + self.phone_packages[i_user][i] .view(settings, selected_device) .map(move |msg| Message::List(i, msg)), ) @@ -748,8 +776,7 @@ impl List { .selected_removal .expect("removal recommendation must be selected"); - self.filtered_packages = self.phone_packages - [self.selected_user.expect("User must be selected").index] + self.filtered_packages = self.phone_packages[self.current_user_index()] .iter() // we must filter the indices associated with pack-rows, // that's why `enumerate` is before `filter`. @@ -776,7 +803,7 @@ impl List { vec![ fetch_packages(&uad_list, serial, None) .into_iter() - .map(PackageRow::from) + .map(|core| Self::map_core_to_row(&uad_list, &core)) .collect(), ] } else { @@ -785,7 +812,7 @@ impl List { .map(|user| { fetch_packages(&uad_list, serial, Some(user.id)) .into_iter() - .map(PackageRow::from) + .map(|core| Self::map_core_to_row(&uad_list, &core)) .collect() }) .collect() @@ -841,7 +868,7 @@ impl List { } fn on_restoring_device(&mut self, output: Result) -> Task { - let i_user = self.selected_user.unwrap_or_default().index; + let i_user = self.current_user_index(); if let Ok(p) = output { self.loading_state = LoadingState::RestoringDevice(self.phone_packages[i_user][p.index].name.clone()); @@ -886,7 +913,7 @@ impl List { } fn on_apply_filters(&mut self, packages: Vec>) -> Task { - let i_user = self.selected_user.unwrap_or_default().index; + let i_user = self.current_user_index(); self.phone_packages = packages; self.filtered_packages = (0..self.phone_packages[i_user].len()).collect(); self.selected_removal = Some(Removal::Recommended); @@ -906,7 +933,7 @@ impl List { selected_device: &mut Phone, list_update_state: &mut UadListState, ) -> Task { - let i_user = self.selected_user.unwrap_or_default().index; + let i_user = self.current_user_index(); for i in self.filtered_packages.clone() { if self.phone_packages[i_user][i].selected != selected { #[expect(unused_must_use, reason = "side-effect")] @@ -924,26 +951,22 @@ impl List { fn on_search_input_changed(&mut self, letter: String) -> Task { self.input_value = letter; - Self::filter_package_lists(self); - Task::none() + self.refilter() } fn on_list_selected(&mut self, list: UadList) -> Task { self.selected_list = Some(list); - Self::filter_package_lists(self); - Task::none() + self.refilter() } fn on_package_state_selected(&mut self, package_state: PackageState) -> Task { self.selected_package_state = Some(package_state); - Self::filter_package_lists(self); - Task::none() + self.refilter() } fn on_removal_selected(&mut self, removal: Removal) -> Task { self.selected_removal = Some(removal); - Self::filter_package_lists(self); - Task::none() + self.refilter() } fn on_list_row( @@ -953,7 +976,7 @@ impl List { settings: &Settings, selected_device: &mut Phone, ) -> Task { - let i_user = self.selected_user.unwrap_or_default().index; + let i_user = self.current_user_index(); #[expect(unused_must_use, reason = "side-effect")] { self.phone_packages[i_user][i_package] @@ -1031,8 +1054,7 @@ impl List { self.selected_user = Some(user); self.fallback_notifications.clear(); self.filtered_packages = (0..self.phone_packages[user.index].len()).collect(); - Self::filter_package_lists(self); - Task::none() + self.refilter() } #[allow( diff --git a/crates/uad-gui/src/views/settings.rs b/crates/uad-gui/src/views/settings.rs index 3a09b80..ffaa435 100644 --- a/crates/uad-gui/src/views/settings.rs +++ b/crates/uad-gui/src/views/settings.rs @@ -15,6 +15,7 @@ use iced::{Alignment, Element, Length, Renderer, Task, alignment}; use log::{debug, error, info}; use std::path::PathBuf; use uad_core::{ + adb::{ACommand, AdbBackend}, config::{BackupSettings, Config, DeviceSettings, GeneralSettings}, save::{backup_phone, list_available_backup_user, list_available_backups, restore_backup}, sync::{ @@ -35,15 +36,23 @@ pub struct Settings { pub device: DeviceSettings, is_loading: bool, modal: Option, + /// Cached ADB version string for display + adb_version: String, } impl Default for Settings { fn default() -> Self { + let general = Config::load_configuration_file().general; + // Fetch initial ADB version + let adb_version = ACommand::with_backend(general.adb_backend) + .version() + .unwrap_or_else(|e| format!("Error: {e}")); Self { - general: Config::load_configuration_file().general, + general, device: DeviceSettings::default(), is_loading: false, modal: None, + adb_version, } } } @@ -55,6 +64,8 @@ pub enum Message { DisableMode(bool), MultiUserMode(bool), ApplyTheme(Theme), + ApplyAdbBackend(AdbBackend), + AdbVersionFetched(Result), UrlPressed(PathBuf), BackupSelected(DisplayablePath), BackupDevice, @@ -83,6 +94,8 @@ impl Settings { Message::DisableMode(toggled) => self.handle_disable_mode(phone, toggled), Message::MultiUserMode(toggled) => self.handle_multi_user_mode(phone, toggled), Message::ApplyTheme(theme) => self.handle_apply_theme(phone, theme), + Message::ApplyAdbBackend(backend) => self.handle_apply_adb_backend(backend), + Message::AdbVersionFetched(result) => self.handle_adb_version_fetched(result), Message::UrlPressed(url) => Self::handle_url_pressed(url), Message::LoadDeviceSettings => self.handle_load_device_settings(phone), Message::BackupSelected(d_path) => self.handle_backup_selected(d_path), @@ -138,6 +151,28 @@ impl Settings { Task::none() } + fn handle_apply_adb_backend(&mut self, backend: AdbBackend) -> Task { + self.general.adb_backend = backend; + self.adb_version = "Fetching...".to_string(); + debug!("Config change: {self:?}"); + let mut config = Config::load_configuration_file(); + config.save_device_settings(self.device.clone(), self.general.clone()); + info!("ADB backend changed to: {backend}"); + // Fetch version asynchronously + Task::perform( + async move { ACommand::with_backend(backend).version() }, + Message::AdbVersionFetched, + ) + } + + fn handle_adb_version_fetched(&mut self, result: Result) -> Task { + self.adb_version = match result { + Ok(version) => version, + Err(e) => format!("Error: {e}"), + }; + Task::none() + } + fn handle_url_pressed(url: PathBuf) -> Task { open_url(url); Task::none() @@ -361,6 +396,8 @@ impl Settings { self.theme_container(), text("General").size(26), self.general_container(), + text("ADB").size(26), + self.adb_container(), text("Current device").size(26), Self::no_device_container(), text("Backup / Restore").size(26), @@ -381,6 +418,8 @@ impl Settings { self.theme_container(), text("General").size(26), self.general_container(), + text("ADB").size(26), + self.adb_container(), text("Current device").size(26), Self::warning_container(phone), self.device_specific_container(phone), @@ -460,6 +499,49 @@ impl Settings { .into() } + fn adb_container(&self) -> Element<'_, Message, Theme, Renderer> { + let backend_label = text("Backend:").size(16); + let radio_btn_backend = + AdbBackend::ALL + .iter() + .fold(row![].spacing(10), |row_content, option| { + row_content.push( + radio( + format!("{option}"), + *option, + Some(self.general.adb_backend), + Message::ApplyAdbBackend, + ) + .size(20), + ) + }); + + let backend_row = row![backend_label, radio_btn_backend] + .spacing(10) + .align_y(Alignment::Center); + + let backend_descr = text( + "Builtin: Uses embedded ADB (no external dependencies). System: Uses your installed adb binary.", + ) + .style(style::Text::Commentary); + + let version_row = row![ + text("Version: ").size(14), + text(&self.adb_version) + .size(14) + .style(style::Text::Commentary), + ] + .spacing(5) + .align_y(Alignment::Center); + + container(column![backend_row, backend_descr, version_row,].spacing(10)) + .padding(10) + .width(Length::Fill) + .height(Length::Shrink) + .style(style::Container::Frame) + .into() + } + fn warning_container(phone: &Phone) -> Element<'static, Message, Theme, Renderer> { container( row![ diff --git a/flake.lock b/flake.lock index b3b5542..d212ee9 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1776949667, - "narHash": "sha256-GMSVw35Q+294GlrTUKlx087E31z7KurReQ1YHSKp5iw=", + "lastModified": 1781607440, + "narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "01fbdeef22b76df85ea168fbfe1bfd9e63681b30", + "rev": "3e41b24abd260e8f71dbe2f5737d24122f972158", "type": "github" }, "original": { From 08578514dbfed53b891c22e05dce42f09d8e20bb Mon Sep 17 00:00:00 2001 From: Angel J <78835633+iamanaws@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:57:11 -0700 Subject: [PATCH 2/3] feat(adb): gate built-in backend behind default feature --- crates/uad-cli/Cargo.toml | 6 +++++- crates/uad-cli/src/main.rs | 4 +++- crates/uad-core/Cargo.toml | 5 +++-- crates/uad-core/src/adb.rs | 18 +++++++++++++++++- crates/uad-gui/Cargo.toml | 9 +++++---- crates/uad-gui/src/views/settings.rs | 9 +++++---- 6 files changed, 38 insertions(+), 13 deletions(-) diff --git a/crates/uad-cli/Cargo.toml b/crates/uad-cli/Cargo.toml index 6045f95..9759dcf 100644 --- a/crates/uad-cli/Cargo.toml +++ b/crates/uad-cli/Cargo.toml @@ -14,8 +14,12 @@ edition.workspace = true name = "uad" path = "src/main.rs" +[features] +default = ["builtin-adb"] +builtin-adb = ["uad-core/builtin-adb"] + [dependencies] -uad-core = { path = "../uad-core" } +uad-core = { path = "../uad-core", default-features = false } clap.workspace = true clap_complete = "4.4" tokio.workspace = true diff --git a/crates/uad-cli/src/main.rs b/crates/uad-cli/src/main.rs index 83b3143..560baa2 100644 --- a/crates/uad-cli/src/main.rs +++ b/crates/uad-cli/src/main.rs @@ -26,6 +26,7 @@ use filters::{ListFilter, RemovalFilter, StateFilter}; #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum AdbBackendArg { /// Built-in ADB implementation (no external dependencies) + #[cfg(feature = "builtin-adb")] Builtin, /// Use system-installed adb binary System, @@ -34,6 +35,7 @@ pub enum AdbBackendArg { impl From for AdbBackend { fn from(arg: AdbBackendArg) -> Self { match arg { + #[cfg(feature = "builtin-adb")] AdbBackendArg::Builtin => AdbBackend::Builtin, AdbBackendArg::System => AdbBackend::System, } @@ -46,7 +48,7 @@ impl From for AdbBackend { #[command(version)] #[command(propagate_version = true)] pub struct Cli { - /// ADB backend to use: system (default, uses adb binary) or builtin (no dependencies) + /// ADB backend to use: system (default, uses adb binary) or builtin if enabled #[arg( short = 'B', long = "backend", diff --git a/crates/uad-core/Cargo.toml b/crates/uad-core/Cargo.toml index 16e1dbd..4bd07fd 100644 --- a/crates/uad-core/Cargo.toml +++ b/crates/uad-core/Cargo.toml @@ -11,11 +11,12 @@ categories = ["command-line-utilities"] edition.workspace = true [features] -default = [] +default = ["builtin-adb"] +builtin-adb = ["dep:adb_client"] self-update = ["dep:flate2", "dep:tar"] [dependencies] -adb_client.workspace = true +adb_client = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true log.workspace = true diff --git a/crates/uad-core/src/adb.rs b/crates/uad-core/src/adb.rs index 777e56a..68a0743 100644 --- a/crates/uad-core/src/adb.rs +++ b/crates/uad-core/src/adb.rs @@ -4,7 +4,7 @@ //! //! Following the design philosophy of most of Rust `std`, //! `*Command` are intended to be "thin wrappers" (low-overhead abstractions) -//! around `adb_client` or the system ADB CLI, +//! around the optional `adb_client` backend or the system ADB CLI, //! which implies: //! - no "magic" //! - no custom commands @@ -46,9 +46,11 @@ //! For comprehensive info about ADB, //! [see this](https://android.googlesource.com/platform/packages/modules/adb/+/refs/heads/master/docs/) +#[cfg(feature = "builtin-adb")] use adb_client::{ADBDeviceExt, server::ADBServer}; use serde::{Deserialize, Serialize}; use std::fmt::Write as _; +#[cfg(feature = "builtin-adb")] use std::io::Cursor; use std::rc::Rc; @@ -73,6 +75,7 @@ pub fn to_trimmed_utf8(v: &[u8]) -> String { pub enum AdbBackend { /// Built-in ADB implementation via `adb_client` crate. /// The application can communicate with devices without needing `adb` installed. + #[cfg(feature = "builtin-adb")] Builtin, /// Uses the system-installed `adb` binary. /// This is the default to preserve existing behavior. @@ -84,12 +87,18 @@ pub enum AdbBackend { impl AdbBackend { /// Returns all available backend variants for UI enumeration + #[cfg(feature = "builtin-adb")] pub const ALL: [Self; 2] = [Self::Builtin, Self::System]; + + /// Returns all available backend variants for UI enumeration + #[cfg(not(feature = "builtin-adb"))] + pub const ALL: [Self; 1] = [Self::System]; } impl std::fmt::Display for AdbBackend { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + #[cfg(feature = "builtin-adb")] Self::Builtin => write!(f, "Builtin"), Self::System => write!(f, "System (adb)"), } @@ -189,6 +198,7 @@ impl ACommand { /// - "device" pub fn devices(self) -> Result, String> { match self.0.backend { + #[cfg(feature = "builtin-adb")] AdbBackend::Builtin => Self::devices_builtin(), AdbBackend::System => Self::devices_system(), } @@ -212,6 +222,7 @@ impl ACommand { /// ``` pub fn version(self) -> Result { match self.0.backend { + #[cfg(feature = "builtin-adb")] AdbBackend::Builtin => Self::version_builtin(), AdbBackend::System => Self::version_system(), } @@ -220,6 +231,7 @@ impl ACommand { // ========== Builtin backend implementation (adb_client) ========== /// Get ADB server version using the builtin `adb_client` + #[cfg(feature = "builtin-adb")] fn version_builtin() -> Result { let mut server = ADBServer::default(); match server.version() { @@ -232,6 +244,7 @@ impl ACommand { } /// List devices using the builtin `adb_client` + #[cfg(feature = "builtin-adb")] fn devices_builtin() -> Result, String> { let mut server = ADBServer::default(); server @@ -249,6 +262,7 @@ impl ACommand { } /// Execute a shell command via `adb_client` (builtin backend) + #[cfg(feature = "builtin-adb")] fn run_shell_command_builtin(&self, shell_command: &str) -> Result { let mut server = ADBServer::default(); @@ -368,6 +382,7 @@ impl ACommand { /// Execute a shell command using the configured backend fn run_shell_command(&self, shell_command: &str) -> Result { match self.0.backend { + #[cfg(feature = "builtin-adb")] AdbBackend::Builtin => self.run_shell_command_builtin(shell_command), AdbBackend::System => self.run_shell_command_system(shell_command), } @@ -614,6 +629,7 @@ mod tests { #[test] fn backend_display() { + #[cfg(feature = "builtin-adb")] assert_eq!(AdbBackend::Builtin.to_string(), "Builtin"); assert_eq!(AdbBackend::System.to_string(), "System (adb)"); } diff --git a/crates/uad-gui/Cargo.toml b/crates/uad-gui/Cargo.toml index 4a50636..0ef7f42 100644 --- a/crates/uad-gui/Cargo.toml +++ b/crates/uad-gui/Cargo.toml @@ -15,14 +15,15 @@ name = "uad-ng" path = "src/main.rs" [features] -default = ["wgpu", "self-update", "img"] -wgpu = [] # Iced/wgpu is default -self-update = ["flate2", "tar"] +default = ["wgpu", "self-update", "img", "builtin-adb"] +builtin-adb = ["uad-core/builtin-adb"] +self-update = ["flate2", "tar", "uad-core/self-update"] no-self-update = [] +wgpu = [] # Iced/wgpu is default img = ["image", "iced/image"] [dependencies] -uad-core = { path = "../uad-core", features = ["self-update"] } +uad-core = { path = "../uad-core", default-features = false } iced.workspace = true image = { workspace = true, optional = true } rfd.workspace = true diff --git a/crates/uad-gui/src/views/settings.rs b/crates/uad-gui/src/views/settings.rs index ffaa435..ba048d1 100644 --- a/crates/uad-gui/src/views/settings.rs +++ b/crates/uad-gui/src/views/settings.rs @@ -520,10 +520,11 @@ impl Settings { .spacing(10) .align_y(Alignment::Center); - let backend_descr = text( - "Builtin: Uses embedded ADB (no external dependencies). System: Uses your installed adb binary.", - ) - .style(style::Text::Commentary); + #[cfg(feature = "builtin-adb")] + let backend_description = "System: Uses your installed adb binary. Builtin: Uses embedded ADB (no external dependencies)."; + #[cfg(not(feature = "builtin-adb"))] + let backend_description = "System: Uses your installed adb binary."; + let backend_descr = text(backend_description).style(style::Text::Commentary); let version_row = row![ text("Version: ").size(14), From 16a0217b7f34c06480171c1ab1fc5918e0ab571b Mon Sep 17 00:00:00 2001 From: Angel J <78835633+iamanaws@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:48:27 -0700 Subject: [PATCH 3/3] feat(adb): use direct USB for built-in backend Replace the server-based backend with embedded USB communication and share backend selection across the CLI and GUI. Serialize USB operations, cache connections, handle device replugging, and report authorization, busy, and permission errors. Create keys atomically and validate existing keys. Convert PKCS#1 keys to temporary PKCS#8 files without modifying the originals. Propagate remote shell failures and add coverage for discovery, locking, and recovery. --- .github/workflows/build_artifacts.yml | 2 +- Cargo.lock | 49 +- Cargo.toml | 5 +- crates/uad-cli/src/commands.rs | 70 +- crates/uad-cli/src/device.rs | 16 +- crates/uad-cli/src/main.rs | 146 ++- crates/uad-cli/src/repl.rs | 49 +- crates/uad-core/Cargo.toml | 11 +- crates/uad-core/src/adb.rs | 955 ++++++++++++++++-- crates/uad-core/src/config.rs | 46 +- crates/uad-core/src/sync.rs | 228 ++++- crates/uad-gui/src/gui.rs | 61 +- crates/uad-gui/src/views/list.rs | 39 +- crates/uad-gui/src/views/settings.rs | 202 +++- crates/uad-gui/src/widgets/navigation_menu.rs | 5 +- 15 files changed, 1634 insertions(+), 250 deletions(-) diff --git a/.github/workflows/build_artifacts.yml b/.github/workflows/build_artifacts.yml index 63e6542..132a17c 100644 --- a/.github/workflows/build_artifacts.yml +++ b/.github/workflows/build_artifacts.yml @@ -42,7 +42,7 @@ jobs: restore-keys: ${{ runner.OS }}-release- if: matrix.os == 'ubuntu-22.04' - name: Building - run: cargo build --release -p uad-gui --no-default-features --features wgpu,${{ matrix.update_feature }},img + run: cargo build --release -p uad-gui --no-default-features --features wgpu,${{ matrix.update_feature }},img,builtin-adb - name: Renaming binaries [Windows] if: matrix.os == 'windows-2022' run: mv target/release/uad-ng.exe uad-ng-${{ matrix.build_target }}.exe diff --git a/Cargo.lock b/Cargo.lock index 5c53a1e..acef1fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,14 +20,13 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "adb_client" -version = "3.2.2" +version = "3.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4974ac06396fce4424d9d2193f80ea5a177d6e2ddde6214ff7c14aaedd2a3104" +checksum = "773b25a440e72829bbb1b96435ee0e65ef0f14a3da545590930ca881a68e59d7" dependencies = [ "base64", "byteorder", "chrono", - "image", "log", "num-bigint-dig", "num-traits", @@ -37,6 +36,7 @@ dependencies = [ "rcgen", "regex", "rsa", + "rusb", "rustls", "rustls-pki-types", "sha1", @@ -2611,6 +2611,18 @@ dependencies = [ "redox_syscall 0.7.1", ] +[[package]] +name = "libusb1-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "lilt" version = "0.8.1" @@ -4015,9 +4027,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -4027,9 +4039,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -4038,9 +4050,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "renderdoc-sys" @@ -4130,6 +4142,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rusb" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab9f9ff05b63a786553a4c02943b74b34a988448671001e9a27e2f0565cc05a4" +dependencies = [ + "libc", + "libusb1-sys", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -5001,9 +5023,12 @@ dependencies = [ "flate2", "log", "retry", + "rsa", + "rusb", "serde", "serde_json", "tar", + "tempfile", "toml", "ureq", "win32console", @@ -5184,6 +5209,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 9104739..70eb203 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,10 @@ edition = "2024" [workspace.dependencies] # Core dependencies -adb_client = "3.2.2" +adb_client = { version = "3.2.3", default-features = false } +rusb = { version = "0.9.4", features = ["vendored"] } +rsa = { version = "0.9.10", features = ["pem"] } +tempfile = "3.25.0" serde = { version = "^1.0", features = ["derive"] } serde_json = "^1.0" log = "^0.4" diff --git a/crates/uad-cli/src/commands.rs b/crates/uad-cli/src/commands.rs index 1944160..6217134 100644 --- a/crates/uad-cli/src/commands.rs +++ b/crates/uad-cli/src/commands.rs @@ -2,10 +2,10 @@ use clap::CommandFactory; use clap_complete::{Shell, generate}; use std::collections::{HashMap, HashSet}; use std::io::Write; -use uad_core::adb::{ACommand, AdbBackend, PmListPacksFlag}; +use uad_core::adb::{ACommand, PmListPacksFlag}; use uad_core::sync::{ - CorePackage, Phone, User, apply_pkg_state_commands, get_devices_list, get_package_state, - run_adb_shell_action_with_backend, + CorePackage, Phone, User, apply_pkg_state_commands, discover_devices, get_package_state, + run_adb_shell_action, }; use uad_core::uad_lists::{Package, PackageState, Removal, UadList, load_debloat_lists}; use uad_core::utils::{matches_search, truncate_description}; @@ -15,15 +15,16 @@ use crate::filters::{ListFilter, RemovalFilter, StateFilter}; use crate::{Cli, print_or_exit, println_or_exit}; /// List all connected Android devices -pub fn list_devices(backend: AdbBackend) -> Result<(), Box> { - println_or_exit!( - "Scanning for connected devices (using {} backend)...", - backend - ); - let devices = get_devices_list(backend); +pub fn list_devices() -> Result<(), Box> { + println_or_exit!("Scanning for connected devices..."); + let discovery = discover_devices(); + let devices = discovery.devices; if devices.is_empty() { - return Err(NO_DEVICES_FOUND.into()); + return Err(discovery + .issue + .map_or_else(|| NO_DEVICES_FOUND.to_string(), |issue| issue.to_string()) + .into()); } println_or_exit!("\nFound {} device(s):\n", devices.len()); @@ -118,7 +119,6 @@ pub(crate) fn resolve_pm_flag(state_filter: Option) -> Option, state_filter: Option, removal_filter: Option, @@ -126,7 +126,7 @@ pub fn list_packages( search: Option, user_id: Option, ) -> Result<(), Box> { - let target_device = get_target_device(backend, device)?; + let target_device = get_target_device(device)?; let uad_lists = load_debloat_lists(false).unwrap_or_else(|lists| lists); println_or_exit!( @@ -143,7 +143,7 @@ pub fn list_packages( }; let pm_flag = resolve_pm_flag(state_filter); - let system_packages = ACommand::with_backend(backend) + let system_packages = ACommand::new() .shell(&target_device.adb_id) .pm() .list_packages_sys(pm_flag, user_id)?; @@ -154,7 +154,6 @@ pub fn list_packages( &target_device.adb_id, user_id, &context, - backend, )?; if displayed_count == 0 { @@ -173,19 +172,18 @@ pub fn display_package_list( device_serial: &str, user_id: Option, context: &PackageListContext, - backend: AdbBackend, ) -> Result> { let config = context.display_config(); let mut displayed_count = 0; - let enabled_packages: HashSet = ACommand::with_backend(backend) + let enabled_packages: HashSet = ACommand::new() .shell(device_serial) .pm() .list_packages_sys(Some(PmListPacksFlag::OnlyEnabled), user_id) .unwrap_or_default() .into_iter() .collect(); - let disabled_packages: HashSet = ACommand::with_backend(backend) + let disabled_packages: HashSet = ACommand::new() .shell(device_serial) .pm() .list_packages_sys(Some(PmListPacksFlag::OnlyDisabled), user_id) @@ -256,7 +254,6 @@ pub fn display_package_entry( /// Change the state of one or more packages pub fn change_package_state( - backend: AdbBackend, packages: &[String], device: Option, user_id: Option, @@ -268,7 +265,7 @@ pub fn change_package_state( return Err("No packages specified".into()); } - let target_device = get_target_device(backend, device)?; + let target_device = get_target_device(device)?; let user = get_user(&target_device, user_id)?; let uad_lists = load_debloat_lists(false).unwrap_or_else(|lists| lists); @@ -291,7 +288,6 @@ pub fn change_package_state( user, target_state, dry_run, - backend, &uad_lists, )?; println!(); @@ -313,7 +309,6 @@ fn process_package_state_change( user: User, target_state: PackageState, dry_run: bool, - backend: AdbBackend, uad_lists: &HashMap, ) -> Result<(), Box> { let current_state = get_package_state(&device.adb_id, pkg_name, Some(user.id)) @@ -355,7 +350,6 @@ fn process_package_state_change( user, device, &commands, - backend, " ", )?; } @@ -364,10 +358,6 @@ fn process_package_state_change( } /// Execute commands and verify package state with fallback -#[allow( - clippy::too_many_arguments, - reason = "Keeps the shared fallback helper simple across CLI and REPL callers" -)] pub fn execute_with_fallback( package: &str, target_state: PackageState, @@ -375,7 +365,6 @@ pub fn execute_with_fallback( user: User, device: &Phone, commands: &[String], - backend: AdbBackend, indent: &str, ) -> Result<(), Box> { // Capture the before-state of packages on other users for cross-user detection @@ -384,7 +373,7 @@ pub fn execute_with_fallback( // Execute commands for cmd in commands { - match run_adb_shell_action_with_backend(&device.adb_id, cmd.as_str(), backend) { + match run_adb_shell_action(&device.adb_id, cmd.as_str()) { Ok(_) => println!("{}✓ {}", indent, cmd), Err(e) => { return Err(format!("{indent}✗ Failed to execute `{cmd}`: {e:?}").into()); @@ -441,7 +430,6 @@ pub fn execute_with_fallback( /// Show detailed information about a package pub fn show_package_info( - backend: AdbBackend, package: &str, device: Option, user_id: Option, @@ -461,7 +449,7 @@ pub fn show_package_info( } if let Some(device_id) = device { - let target_device = get_target_device(backend, Some(device_id))?; + let target_device = get_target_device(Some(device_id))?; let user = get_user(&target_device, user_id)?; println!("Device: {} ({})", target_device.model, target_device.adb_id); println!("User: {}", user.id); @@ -495,10 +483,11 @@ pub fn generate_completions(shell: Shell) { } /// Show ADB backend and version information -pub fn show_adb_info(backend: AdbBackend) -> Result<(), Box> { +pub fn show_adb_info() -> Result<(), Box> { + let backend = uad_core::adb::AdbBackend::current(); println!("ADB Backend: {}\n", backend); - match ACommand::with_backend(backend).version() { + match ACommand::new().version() { Ok(version) => { println!("{version}"); Ok(()) @@ -509,3 +498,20 @@ pub fn show_adb_info(backend: AdbBackend) -> Result<(), Box, -) -> Result> { - let devices = get_devices_list(backend); +pub fn get_target_device(device: Option) -> Result> { + let discovery = discover_devices(); + let devices = discovery.devices; if devices.is_empty() { - return Err(NO_DEVICES_FOUND.into()); + return Err(discovery + .issue + .map_or_else(|| NO_DEVICES_FOUND.to_string(), |issue| issue.to_string()) + .into()); } let target_device = if let Some(device_id) = device { diff --git a/crates/uad-cli/src/main.rs b/crates/uad-cli/src/main.rs index 560baa2..c31c6d1 100644 --- a/crates/uad-cli/src/main.rs +++ b/crates/uad-cli/src/main.rs @@ -11,7 +11,8 @@ use clap::{Parser, Subcommand, ValueEnum}; use clap_complete::Shell; use std::process::ExitCode; -use uad_core::adb::AdbBackend; +use uad_core::adb::{ACommand, AdbBackend, AdbDeviceStatus}; +use uad_core::sync::{DeviceDiscoveryIssue, classify_device_issue}; use uad_core::uad_lists::PackageState; mod commands; @@ -25,7 +26,7 @@ use filters::{ListFilter, RemovalFilter, StateFilter}; /// CLI-compatible ADB backend selection #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum AdbBackendArg { - /// Built-in ADB implementation (no external dependencies) + /// Embedded ADB over USB #[cfg(feature = "builtin-adb")] Builtin, /// Use system-installed adb binary @@ -48,7 +49,7 @@ impl From for AdbBackend { #[command(version)] #[command(propagate_version = true)] pub struct Cli { - /// ADB backend to use: system (default, uses adb binary) or builtin if enabled + /// ADB backend to use #[arg( short = 'B', long = "backend", @@ -58,6 +59,10 @@ pub struct Cli { )] backend: AdbBackendArg, + /// Stop the system ADB server before using the Builtin backend + #[arg(long, global = true)] + kill_adb_server: bool, + #[command(subcommand)] command: Commands, } @@ -204,11 +209,11 @@ async fn main() -> ExitCode { fn run() -> Result<(), Box> { let cli = Cli::parse(); - let backend: AdbBackend = cli.backend.into(); + configure_adb(&cli)?; match cli.command { Commands::Devices => { - commands::list_devices(backend)?; + commands::list_devices()?; } Commands::List { device, @@ -218,7 +223,7 @@ fn run() -> Result<(), Box> { search, user, } => { - commands::list_packages(backend, device, state, removal, list, search, user)?; + commands::list_packages(device, state, removal, list, search, user)?; } Commands::Uninstall { packages, @@ -227,7 +232,6 @@ fn run() -> Result<(), Box> { dry_run, } => { commands::change_package_state( - backend, &packages, device, user, @@ -243,7 +247,6 @@ fn run() -> Result<(), Box> { dry_run, } => { commands::change_package_state( - backend, &packages, device, user, @@ -259,7 +262,6 @@ fn run() -> Result<(), Box> { dry_run, } => { commands::change_package_state( - backend, &packages, device, user, @@ -273,21 +275,141 @@ fn run() -> Result<(), Box> { device, user, } => { - commands::show_package_info(backend, &package, device, user)?; + commands::show_package_info(&package, device, user)?; } Commands::Update => { commands::update_lists()?; } Commands::Adb => { - commands::show_adb_info(backend)?; + commands::show_adb_info()?; } Commands::Completions { shell } => { commands::generate_completions(shell); } Commands::Repl { device, user } => { - repl::repl_mode(backend, device, user)?; + repl::repl_mode(device, user)?; } } Ok(()) } + +fn configure_adb(cli: &Cli) -> Result<(), Box> { + let backend: AdbBackend = cli.backend.into(); + #[cfg(feature = "builtin-adb")] + let using_builtin = backend == AdbBackend::Builtin; + #[cfg(not(feature = "builtin-adb"))] + let using_builtin = false; + + if cli.kill_adb_server && !using_builtin { + return Err("--kill-adb-server requires --backend builtin".into()); + } + if cli.kill_adb_server { + ACommand::kill_system_server() + .map_err(|err| format!("Failed to stop the system ADB server: {err}"))?; + } + + backend.set_current(); + + if using_builtin && !cli.kill_adb_server && command_uses_device(&cli.command) { + let devices = ACommand::new().devices()?; + match builtin_preflight_issue(&devices) { + Some(DeviceDiscoveryIssue::Busy) => { + return Err( + "The system ADB server is using the USB device. Stop it with `adb kill-server` \ + or rerun with `--kill-adb-server`." + .into(), + ); + } + Some(issue @ DeviceDiscoveryIssue::NoPermissions) => { + return Err(issue.to_string().into()); + } + _ => {} + } + } + + Ok(()) +} + +fn builtin_preflight_issue(devices: &[(String, AdbDeviceStatus)]) -> Option { + if devices + .iter() + .any(|(_, status)| status == &AdbDeviceStatus::Device) + { + return None; + } + + classify_device_issue(devices).filter(|issue| { + matches!( + issue, + DeviceDiscoveryIssue::Busy | DeviceDiscoveryIssue::NoPermissions + ) + }) +} + +fn command_uses_device(command: &Commands) -> bool { + match command { + Commands::Devices + | Commands::List { .. } + | Commands::Uninstall { .. } + | Commands::Enable { .. } + | Commands::Disable { .. } + | Commands::Repl { .. } => true, + Commands::Info { device, .. } => device.is_some(), + Commands::Update | Commands::Adb | Commands::Completions { .. } => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(feature = "builtin-adb")] + #[test] + fn builtin_server_shutdown_is_explicitly_opted_in() { + let cli = Cli::try_parse_from([ + "uad", + "--backend", + "builtin", + "--kill-adb-server", + "devices", + ]) + .expect("valid Builtin CLI arguments"); + + assert_eq!(cli.backend, AdbBackendArg::Builtin); + assert!(cli.kill_adb_server); + assert!(command_uses_device(&cli.command)); + } + + #[test] + fn non_device_commands_skip_usb_preflight() { + assert!(!command_uses_device(&Commands::Update)); + assert!(!command_uses_device(&Commands::Adb)); + } + + #[test] + fn builtin_preflight_allows_any_ready_device() { + let devices = vec![ + ("ready".to_string(), AdbDeviceStatus::Device), + ("busy".to_string(), AdbDeviceStatus::Busy), + ]; + + assert_eq!(builtin_preflight_issue(&devices), None); + } + + #[test] + fn builtin_preflight_reports_blocking_issues_without_a_ready_device() { + assert_eq!( + builtin_preflight_issue(&[("busy".to_string(), AdbDeviceStatus::Busy)]), + Some(DeviceDiscoveryIssue::Busy) + ); + assert_eq!( + builtin_preflight_issue(&[("denied".to_string(), AdbDeviceStatus::NoPermissions,)]), + Some(DeviceDiscoveryIssue::NoPermissions) + ); + assert_eq!( + builtin_preflight_issue(&[("pending".to_string(), AdbDeviceStatus::Unauthorized,)]), + None + ); + } +} diff --git a/crates/uad-cli/src/repl.rs b/crates/uad-cli/src/repl.rs index 47f75ee..981ed42 100644 --- a/crates/uad-cli/src/repl.rs +++ b/crates/uad-cli/src/repl.rs @@ -6,7 +6,9 @@ use uad_core::adb::{ACommand, AdbBackend}; use uad_core::sync::{CorePackage, Phone, User, apply_pkg_state_commands, get_package_state}; use uad_core::uad_lists::{Package, PackageState, Removal, UadList, load_debloat_lists}; -use crate::commands::{PackageListContext, display_package_list, execute_with_fallback}; +use crate::commands::{ + PackageListContext, display_package_list, execute_with_fallback, resolve_pm_flag, +}; use crate::device::{get_target_device, get_user}; use crate::filters::StateFilter; use crate::println_or_exit; @@ -18,15 +20,15 @@ enum ReplAction { /// Start interactive REPL mode pub fn repl_mode( - backend: AdbBackend, device: Option, user_id: Option, ) -> Result<(), Box> { + let backend = AdbBackend::current(); println!("Universal Android Debloater - Interactive Mode"); println!("Using {} ADB backend", backend); println!("Type 'help' for available commands, 'exit' or 'quit' to leave\n"); - let target_device = get_target_device(backend, device)?; + let target_device = get_target_device(device)?; let user = get_user(&target_device, user_id)?; println!( @@ -48,15 +50,7 @@ pub fn repl_mode( let readline = rl.readline("uad> "); match readline { Ok(line) => { - match handle_repl_line( - &line, - &mut rl, - &target_device, - user, - user_id, - &uad_lists, - backend, - ) { + match handle_repl_line(&line, &mut rl, &target_device, user, user_id, &uad_lists) { Ok(ReplAction::Continue) => {} Ok(ReplAction::Exit) => break, Err(e) => { @@ -98,7 +92,6 @@ fn handle_repl_line( user: User, user_id: Option, uad_lists: &HashMap, - backend: AdbBackend, ) -> Result> { let line = line.trim(); if line.is_empty() { @@ -119,7 +112,7 @@ fn handle_repl_line( return Ok(ReplAction::Exit); } "list" | "ls" => { - handle_list_command(&parts[1..], device, user_id, uad_lists, backend)?; + handle_list_command(&parts[1..], device, user_id, uad_lists)?; } "info" => { handle_info_command(&parts[1..], device, user.id, uad_lists)?; @@ -132,7 +125,6 @@ fn handle_repl_line( PackageState::Uninstalled, "Uninstalling", uad_lists, - backend, )?; } "enable" | "restore" => { @@ -143,7 +135,6 @@ fn handle_repl_line( PackageState::Enabled, "Enabling", uad_lists, - backend, )?; } "disable" => { @@ -154,13 +145,16 @@ fn handle_repl_line( PackageState::Disabled, "Disabling", uad_lists, - backend, )?; } "device" => { println!( "Device: {} ({}), Android SDK: {}, User: {}, Backend: {}", - device.model, device.adb_id, device.android_sdk, user.id, backend + device.model, + device.adb_id, + device.android_sdk, + user.id, + AdbBackend::current() ); } "clear" => { @@ -230,12 +224,11 @@ fn handle_list_command( device: &Phone, user_id: Option, uad_lists: &HashMap, - backend: AdbBackend, ) -> Result<(), Box> { let parsed = ReplListArgs::parse(args)?; - let pm_flag = parsed.state_filter.and_then(StateFilter::to_pm_flag); - let system_packages = ACommand::with_backend(backend) + let pm_flag = resolve_pm_flag(parsed.state_filter); + let system_packages = ACommand::new() .shell(&device.adb_id) .pm() .list_packages_sys(pm_flag, user_id)?; @@ -253,7 +246,6 @@ fn handle_list_command( &device.adb_id, user_id, &context, - backend, )?; if displayed_count == 0 { @@ -303,7 +295,6 @@ fn handle_state_change_command( target_state: PackageState, action_name: &str, uad_lists: &HashMap, - backend: AdbBackend, ) -> Result<(), Box> { if args.is_empty() { eprintln!( @@ -314,15 +305,7 @@ fn handle_state_change_command( } for pkg_name in args { - process_package_change( - pkg_name, - device, - user, - target_state, - action_name, - uad_lists, - backend, - )?; + process_package_change(pkg_name, device, user, target_state, action_name, uad_lists)?; } Ok(()) @@ -336,7 +319,6 @@ fn process_package_change( target_state: PackageState, action_name: &str, uad_lists: &HashMap, - backend: AdbBackend, ) -> Result<(), Box> { let current_state = get_package_state(&device.adb_id, pkg_name, Some(user.id)) .ok_or("Package not found on device")?; @@ -372,7 +354,6 @@ fn process_package_change( user, device, &commands, - backend, " ", ) } diff --git a/crates/uad-core/Cargo.toml b/crates/uad-core/Cargo.toml index 4bd07fd..0ecc0f9 100644 --- a/crates/uad-core/Cargo.toml +++ b/crates/uad-core/Cargo.toml @@ -12,11 +12,20 @@ edition.workspace = true [features] default = ["builtin-adb"] -builtin-adb = ["dep:adb_client"] +builtin-adb = [ + "dep:adb_client", + "adb_client/usb", + "dep:rusb", + "dep:rsa", + "dep:tempfile", +] self-update = ["dep:flate2", "dep:tar"] [dependencies] adb_client = { workspace = true, optional = true } +rusb = { workspace = true, optional = true } +rsa = { workspace = true, optional = true } +tempfile = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true log.workspace = true diff --git a/crates/uad-core/src/adb.rs b/crates/uad-core/src/adb.rs index 68a0743..2683d6c 100644 --- a/crates/uad-core/src/adb.rs +++ b/crates/uad-core/src/adb.rs @@ -35,11 +35,11 @@ //! ## Backend Selection //! //! This module supports two ADB backends: -//! - **Builtin** (`adb_client`): Pure Rust implementation, no external dependencies +//! - **Builtin** (`adb_client`): Embedded ADB over USB //! - **System**: Uses the system-installed `adb` binary //! -//! Use [`ACommand::with_backend`] to select a specific backend, -//! or [`ACommand::new`] to use the default (System backend). +//! Select the process-wide backend with [`AdbBackend::set_current`], then use +//! [`ACommand::new`]. //! //! Thank you! ❤️ //! @@ -47,12 +47,31 @@ //! [see this](https://android.googlesource.com/platform/packages/modules/adb/+/refs/heads/master/docs/) #[cfg(feature = "builtin-adb")] -use adb_client::{ADBDeviceExt, server::ADBServer}; +use adb_client::{ + ADBDeviceExt, RebootType, + usb::{ADBUSBDevice, USBTransport}, +}; +#[cfg(feature = "builtin-adb")] +use rsa::{ + RsaPrivateKey, + pkcs1::DecodeRsaPrivateKey, + pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding}, + rand_core::OsRng, +}; +#[cfg(feature = "builtin-adb")] +use rusb::{Context, Device, DeviceDescriptor, UsbContext, constants::LIBUSB_CLASS_VENDOR_SPEC}; use serde::{Deserialize, Serialize}; use std::fmt::Write as _; #[cfg(feature = "builtin-adb")] -use std::io::Cursor; +use std::io::Write as _; +#[cfg(feature = "builtin-adb")] +use std::path::PathBuf; use std::rc::Rc; +#[cfg(feature = "builtin-adb")] +use std::sync::Mutex; +use std::sync::atomic::{AtomicU8, Ordering}; +#[cfg(feature = "builtin-adb")] +use tempfile::NamedTempFile; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; @@ -69,12 +88,12 @@ pub fn to_trimmed_utf8(v: &[u8]) -> String { /// ADB backend selection. /// -/// - **Builtin**: Uses the `adb_client` crate (pure Rust, no external dependencies) +/// - **Builtin**: Connects directly to physical USB devices via `adb_client` /// - **System**: Uses the system-installed `adb` binary #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum AdbBackend { - /// Built-in ADB implementation via `adb_client` crate. - /// The application can communicate with devices without needing `adb` installed. + /// Built-in direct-USB ADB implementation via `adb_client`. + /// The application can communicate with USB devices without an ADB server or binary. #[cfg(feature = "builtin-adb")] Builtin, /// Uses the system-installed `adb` binary. @@ -85,6 +104,69 @@ pub enum AdbBackend { System, } +/// Connection state reported for an ADB device. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AdbDeviceStatus { + Device, + Busy, + Unauthorized, + NoPermissions, + Offline, + BackendError(String), + Other(String), +} + +impl AdbDeviceStatus { + fn from_system(status: &str) -> Self { + let status = status.trim(); + if status == "device" { + Self::Device + } else if status == "unauthorized" { + Self::Unauthorized + } else if status == "offline" { + Self::Offline + } else if status == "no-permissions" || status.starts_with("no permissions") { + Self::NoPermissions + } else { + Self::Other(status.to_string()) + } + } +} + +static CURRENT_ADB_BACKEND: AtomicU8 = AtomicU8::new(0); +#[cfg(feature = "builtin-adb")] +static BUILTIN_USB_DEVICES: Mutex> = Mutex::new(Vec::new()); + +#[cfg(feature = "builtin-adb")] +#[derive(Debug)] +struct CachedBuiltinDevice { + identifier: String, + location: UsbDeviceLocation, + device: ADBUSBDevice, +} + +#[cfg(feature = "builtin-adb")] +fn with_builtin_usb_devices( + operation: impl FnOnce(&mut Vec) -> Result, +) -> Result { + let mut devices = BUILTIN_USB_DEVICES.lock().unwrap_or_else(|poisoned| { + warn!("Recovering poisoned Builtin ADB USB lock"); + poisoned.into_inner() + }); + operation(&mut devices) +} + +#[cfg(feature = "builtin-adb")] +fn clear_builtin_usb_devices() { + if let Err(poisoned) = BUILTIN_USB_DEVICES + .lock() + .map(|mut devices| devices.clear()) + { + warn!("Recovering poisoned Builtin ADB USB lock while clearing cached devices"); + poisoned.into_inner().clear(); + } +} + impl AdbBackend { /// Returns all available backend variants for UI enumeration #[cfg(feature = "builtin-adb")] @@ -93,13 +175,41 @@ impl AdbBackend { /// Returns all available backend variants for UI enumeration #[cfg(not(feature = "builtin-adb"))] pub const ALL: [Self; 1] = [Self::System]; + + /// Select the process-wide ADB backend. + pub fn set_current(self) { + let value = match self { + Self::System => 0, + #[cfg(feature = "builtin-adb")] + Self::Builtin => 1, + }; + #[cfg(feature = "builtin-adb")] + { + let previous = CURRENT_ADB_BACKEND.swap(value, Ordering::Relaxed); + if previous == 1 && value != 1 { + clear_builtin_usb_devices(); + } + } + #[cfg(not(feature = "builtin-adb"))] + CURRENT_ADB_BACKEND.store(value, Ordering::Relaxed); + } + + /// Return the process-wide ADB backend. + #[must_use] + pub fn current() -> Self { + match CURRENT_ADB_BACKEND.load(Ordering::Relaxed) { + #[cfg(feature = "builtin-adb")] + 1 => Self::Builtin, + _ => Self::System, + } + } } impl std::fmt::Display for AdbBackend { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { #[cfg(feature = "builtin-adb")] - Self::Builtin => write!(f, "Builtin"), + Self::Builtin => write!(f, "Builtin (direct USB)"), Self::System => write!(f, "System (adb)"), } } @@ -150,6 +260,282 @@ struct ACommandState { backend: AdbBackend, } +#[cfg(feature = "builtin-adb")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct UsbDeviceLocation { + bus: u8, + address: u8, +} + +#[cfg(feature = "builtin-adb")] +#[derive(Debug)] +struct UsbDeviceCandidate { + identifier: String, + location: UsbDeviceLocation, + device: Device, +} + +#[cfg(feature = "builtin-adb")] +fn is_same_usb_device( + cached_identifier: &str, + cached_location: UsbDeviceLocation, + candidate_identifier: &str, + candidate_location: UsbDeviceLocation, +) -> bool { + cached_identifier == candidate_identifier && cached_location == candidate_location +} + +#[cfg(feature = "builtin-adb")] +fn retain_cached_device_for_candidate( + cached_identifier: &str, + cached_location: UsbDeviceLocation, + candidate_identifier: &str, + candidate_location: UsbDeviceLocation, +) -> bool { + cached_identifier != candidate_identifier + || is_same_usb_device( + cached_identifier, + cached_location, + candidate_identifier, + candidate_location, + ) +} + +#[cfg(feature = "builtin-adb")] +const fn is_supported_adb_interface(class: u8, subclass: u8, protocol: u8) -> bool { + const ADB_SUBCLASS: u8 = 0x42; + const ADB_PROTOCOL: u8 = 0x01; + + class == LIBUSB_CLASS_VENDOR_SPEC && subclass == ADB_SUBCLASS && protocol == ADB_PROTOCOL +} + +#[cfg(feature = "builtin-adb")] +fn is_adb_usb_device(device: &Device, descriptor: &DeviceDescriptor) -> bool { + (0..descriptor.num_configurations()).any(|index| { + device.config_descriptor(index).is_ok_and(|configuration| { + configuration.interfaces().any(|interface| { + interface.descriptors().any(|interface_descriptor| { + is_supported_adb_interface( + interface_descriptor.class_code(), + interface_descriptor.sub_class_code(), + interface_descriptor.protocol_code(), + ) + }) + }) + }) + }) +} + +#[cfg(feature = "builtin-adb")] +fn enumerate_usb_devices() -> Result, String> { + let context = Context::new().map_err(|e| format!("Cannot initialize USB: {e}"))?; + let devices = context + .devices() + .map_err(|e| format!("Cannot enumerate USB devices: {e}"))?; + let mut candidates = Vec::new(); + + for device in devices.iter() { + let Ok(descriptor) = device.device_descriptor() else { + continue; + }; + if !is_adb_usb_device(&device, &descriptor) { + continue; + } + + let location = UsbDeviceLocation { + bus: device.bus_number(), + address: device.address(), + }; + let identifier = device + .open() + .ok() + .and_then(|handle| { + handle + .read_serial_number_string_ascii(&descriptor) + .ok() + .filter(|serial| !serial.is_empty()) + }) + .unwrap_or_else(|| format!("usb:{:03}:{:03}", location.bus, location.address)); + + candidates.push(UsbDeviceCandidate { + identifier, + location, + device, + }); + } + + Ok(candidates) +} + +#[cfg(feature = "builtin-adb")] +fn resolve_adb_key_path( + android_user_home: Option, + home_directory: Option, +) -> Option { + android_user_home + .map(|path| path.join("adbkey")) + .or_else(|| home_directory.map(|home| home.join(".android").join("adbkey"))) +} + +#[cfg(feature = "builtin-adb")] +fn write_adb_private_key(key_path: &std::path::Path, pem: &str) -> Result<(), String> { + let key_directory = key_path + .parent() + .ok_or_else(|| format!("Invalid ADB key path: {}", key_path.display()))?; + let mut temporary_key = NamedTempFile::new_in(key_directory).map_err(|e| { + format!( + "Cannot create temporary ADB key in {}: {e}", + key_directory.display() + ) + })?; + temporary_key + .write_all(pem.as_bytes()) + .and_then(|()| temporary_key.as_file().sync_all()) + .map_err(|e| format!("Cannot save {}: {e}", key_path.display()))?; + + match temporary_key.into_temp_path().persist_noclobber(key_path) { + Ok(()) => Ok(()), + Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(error) => Err(format!( + "Cannot create {}: {}", + key_path.display(), + error.error + )), + } +} + +#[cfg(feature = "builtin-adb")] +struct PreparedAdbPrivateKey { + path: PathBuf, + _temporary_file: Option, +} + +#[cfg(feature = "builtin-adb")] +impl PreparedAdbPrivateKey { + fn path(&self) -> &std::path::Path { + &self.path + } +} + +#[cfg(feature = "builtin-adb")] +fn prepare_existing_adb_private_key( + key_path: &std::path::Path, +) -> Result { + let key_contents = std::fs::read_to_string(key_path) + .map_err(|e| format!("Cannot read ADB key {}: {e}", key_path.display()))?; + + if RsaPrivateKey::from_pkcs8_pem(&key_contents).is_ok() { + return Ok(PreparedAdbPrivateKey { + path: key_path.to_path_buf(), + _temporary_file: None, + }); + } + + let key = RsaPrivateKey::from_pkcs1_pem(&key_contents).map_err(|_| { + format!( + "ADB key {} is not a valid unencrypted PKCS#8 or PKCS#1 RSA private key", + key_path.display() + ) + })?; + let pem = key + .to_pkcs8_pem(LineEnding::LF) + .map_err(|e| format!("Cannot convert {} to PKCS#8: {e}", key_path.display()))?; + let mut temporary_file = NamedTempFile::new() + .map_err(|e| format!("Cannot create temporary converted ADB key: {e}"))?; + temporary_file + .write_all(pem.as_bytes()) + .and_then(|()| temporary_file.as_file().sync_all()) + .map_err(|e| format!("Cannot prepare converted ADB key: {e}"))?; + + Ok(PreparedAdbPrivateKey { + path: temporary_file.path().to_path_buf(), + _temporary_file: Some(temporary_file), + }) +} + +#[cfg(feature = "builtin-adb")] +fn ensure_adb_private_key() -> Result { + let key_path = resolve_adb_key_path( + std::env::var_os("ANDROID_USER_HOME").map(PathBuf::from), + dirs::home_dir(), + ) + .ok_or_else(|| "Cannot determine home directory for the ADB key".to_string())?; + if key_path.is_file() { + return prepare_existing_adb_private_key(&key_path); + } + + let key_directory = key_path + .parent() + .ok_or_else(|| format!("Invalid ADB key path: {}", key_path.display()))?; + std::fs::create_dir_all(key_directory) + .map_err(|e| format!("Cannot create {}: {e}", key_directory.display()))?; + let key = RsaPrivateKey::new(&mut OsRng, 2048) + .map_err(|e| format!("Cannot generate ADB authentication key: {e}"))?; + let pem = key + .to_pkcs8_pem(LineEnding::LF) + .map_err(|e| format!("Cannot encode ADB authentication key: {e}"))?; + write_adb_private_key(&key_path, pem.as_ref())?; + + // Validate the file that won a possible concurrent persist_noclobber race. + prepare_existing_adb_private_key(&key_path) +} + +#[cfg(feature = "builtin-adb")] +fn select_usb_device_index(available: &[String], requested: Option<&str>) -> Result { + if let Some(identifier) = requested { + return available + .iter() + .position(|candidate| candidate == identifier) + .ok_or_else(|| format!("USB device '{identifier}' not found")); + } + + match available { + [] => Err("No ADB-capable USB devices found".to_string()), + [_] => Ok(0), + _ => Err(format!( + "Multiple USB devices found; select one explicitly: {}", + available.join(", ") + )), + } +} + +#[cfg(feature = "builtin-adb")] +const BUILTIN_EXIT_MARKER: &str = "__UAD_EXIT_STATUS__:"; + +#[cfg(feature = "builtin-adb")] +fn wrap_builtin_shell_command(command: &str) -> String { + format!("{command}\n__uad_exit=$?\nprintf '\\n{BUILTIN_EXIT_MARKER}%s\\n' \"$__uad_exit\"") +} + +#[cfg(feature = "builtin-adb")] +fn finish_builtin_shell_command(stdout: &[u8], stderr: &[u8]) -> Result { + let stdout = to_trimmed_utf8(stdout); + let stderr = to_trimmed_utf8(stderr); + + let marker_index = stdout.rfind(BUILTIN_EXIT_MARKER).ok_or_else(|| { + "Direct USB shell command ended without reporting its exit status".to_string() + })?; + let status_text = stdout[marker_index + BUILTIN_EXIT_MARKER.len()..].trim(); + let exit_code = status_text.parse::().map_err(|_| { + format!("Direct USB shell command returned an invalid exit status: {status_text:?}") + })?; + let command_output = stdout[..marker_index].trim_end(); + let detail = match (command_output.is_empty(), stderr.is_empty()) { + (false, false) => format!("{command_output}\n{stderr}"), + (false, true) => command_output.to_string(), + (true, false) => stderr, + (true, true) => String::new(), + }; + + if exit_code != 0 { + return Err(format!( + "Direct USB shell command exited with status {exit_code}: {detail}" + )); + } + + Ok(detail) +} + /// Builder object for an Android Debug Bridge command, /// using the type-state and new-type patterns. /// @@ -161,18 +547,12 @@ struct ACommandState { pub struct ACommand(ACommandState); impl ACommand { - /// `adb` command builder with the default backend. + /// `adb` command builder using the process-wide selected backend. #[must_use] pub fn new() -> Self { - Self::with_backend(AdbBackend::default()) - } - - /// `adb` command builder with a specific backend - #[must_use] - pub fn with_backend(backend: AdbBackend) -> Self { Self(ACommandState { device_serial: None, - backend, + backend: AdbBackend::current(), }) } @@ -196,7 +576,7 @@ impl ACommand { /// Status can be (but not limited to): /// - "unauthorized" /// - "device" - pub fn devices(self) -> Result, String> { + pub fn devices(self) -> Result, String> { match self.0.backend { #[cfg(feature = "builtin-adb")] AdbBackend::Builtin => Self::devices_builtin(), @@ -207,9 +587,9 @@ impl ACommand { /// Returns version information from the ADB server/binary. /// /// ## Builtin backend - /// Returns the ADB server protocol version: + /// Returns the embedded transport implementation: /// ```txt - /// ADB Server Version: 1.0.41 + /// adb_client (direct USB) /// ``` /// /// ## System backend @@ -223,86 +603,208 @@ impl ACommand { pub fn version(self) -> Result { match self.0.backend { #[cfg(feature = "builtin-adb")] - AdbBackend::Builtin => Self::version_builtin(), + AdbBackend::Builtin => Ok(Self::version_builtin()), AdbBackend::System => Self::version_system(), } } + /// Stop the system ADB server so a direct-USB backend can claim the device. + pub fn kill_system_server() -> Result { + let mut cmd = std::process::Command::new("adb"); + cmd.arg("kill-server"); + #[cfg(target_os = "windows")] + cmd.creation_flags(0x0800_0000); // do not open a cmd window + + let output = match cmd.output() { + Ok(output) => output, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok("System ADB is not installed; no server needed stopping".to_string()); + } + Err(err) => return Err(format!("Cannot run `adb kill-server`: {err}")), + }; + let stdout = to_trimmed_utf8(&output.stdout); + let stderr = to_trimmed_utf8(&output.stderr); + if output.status.success() { + Ok(stdout) + } else if stderr.is_empty() { + Err(stdout) + } else { + Err(stderr) + } + } + // ========== Builtin backend implementation (adb_client) ========== - /// Get ADB server version using the builtin `adb_client` + /// Describe the embedded direct-USB backend. #[cfg(feature = "builtin-adb")] - fn version_builtin() -> Result { - let mut server = ADBServer::default(); - match server.version() { - Ok(version) => Ok(format!("ADB Server Version: {version}")), - Err(e) => { - error!("Failed to get ADB server version: {e}"); - Err(format!("Cannot get ADB server version: {e}")) - } + fn version_builtin() -> String { + "adb_client (direct USB)".to_string() + } + + /// List direct USB devices without contacting an ADB server. + #[cfg(feature = "builtin-adb")] + fn devices_builtin() -> Result, String> { + with_builtin_usb_devices(|cached_devices| { + let candidates = enumerate_usb_devices()?; + cached_devices.retain(|cached| { + candidates.iter().any(|candidate| { + is_same_usb_device( + &cached.identifier, + cached.location, + &candidate.identifier, + candidate.location, + ) + }) + }); + + Ok(candidates + .into_iter() + .map(|candidate| { + let identifier = candidate.identifier.clone(); + let location = candidate.location; + let status = if cached_devices.iter().any(|cached| { + is_same_usb_device( + &cached.identifier, + cached.location, + &candidate.identifier, + candidate.location, + ) + }) { + AdbDeviceStatus::Device + } else { + match Self::open_builtin_candidate(candidate) { + Ok(device) => { + cached_devices.push(CachedBuiltinDevice { + identifier: identifier.clone(), + location, + device, + }); + AdbDeviceStatus::Device + } + Err(err) => { + warn!("Direct USB device {identifier} is not ready: {err}"); + Self::builtin_connection_status(&err) + } + } + }; + (identifier, status) + }) + .collect()) + }) + } + + #[cfg(feature = "builtin-adb")] + fn builtin_connection_status(error: &str) -> AdbDeviceStatus { + let normalized = error.to_ascii_lowercase(); + if normalized.contains("busy") { + AdbDeviceStatus::Busy + } else if normalized.contains("timeout") || normalized.contains("timed out") { + AdbDeviceStatus::Unauthorized + } else if normalized.contains("permission") || normalized.contains("access") { + AdbDeviceStatus::NoPermissions + } else if normalized.contains("disconnect") + || normalized.contains("not connected") + || normalized.contains("no device") + { + AdbDeviceStatus::Offline + } else { + AdbDeviceStatus::BackendError(error.to_string()) } } - /// List devices using the builtin `adb_client` #[cfg(feature = "builtin-adb")] - fn devices_builtin() -> Result, String> { - let mut server = ADBServer::default(); - server - .devices() - .map(|device_list| { - device_list - .into_iter() - .map(|dev| (dev.identifier, dev.state.to_string())) - .collect() - }) - .map_err(|e| { - error!("ADB: {e}"); - format!("Cannot connect to ADB server: {e}") - }) + fn open_builtin_candidate(candidate: UsbDeviceCandidate) -> Result { + let private_key = ensure_adb_private_key()?; + ADBUSBDevice::new_from_transport( + USBTransport::new_from_device(candidate.device), + Some(private_key.path().to_path_buf()), + ) + .map_err(|e| format!("Cannot connect directly to USB device: {e}")) } - /// Execute a shell command via `adb_client` (builtin backend) + #[cfg(feature = "builtin-adb")] + fn ensure_builtin_device( + &self, + cached_devices: &mut Vec, + ) -> Result { + let candidates = enumerate_usb_devices()?; + let identifiers = candidates + .iter() + .map(|candidate| candidate.identifier.clone()) + .collect::>(); + let selected = select_usb_device_index(&identifiers, self.0.device_serial.as_deref())?; + let candidate = candidates + .into_iter() + .nth(selected) + .ok_or_else(|| "Selected USB device disappeared".to_string())?; + let identifier = candidate.identifier.clone(); + let location = candidate.location; + + cached_devices.retain(|cached| { + retain_cached_device_for_candidate( + &cached.identifier, + cached.location, + &identifier, + location, + ) + }); + if let Some(index) = cached_devices.iter().position(|cached| { + is_same_usb_device(&cached.identifier, cached.location, &identifier, location) + }) { + return Ok(index); + } + + let device = Self::open_builtin_candidate(candidate)?; + cached_devices.push(CachedBuiltinDevice { + identifier, + location, + device, + }); + Ok(cached_devices.len() - 1) + } + + /// Execute a shell command directly over USB. #[cfg(feature = "builtin-adb")] fn run_shell_command_builtin(&self, shell_command: &str) -> Result { - let mut server = ADBServer::default(); - - // Validate device availability and serial - if let Some(ref serial) = self.0.device_serial { - let device_list = server - .devices() - .map_err(|e| format!("Cannot get device list: {e}"))?; - - if !device_list.iter().any(|d| d.identifier == *serial) { - let available = device_list - .iter() - .map(|d| d.identifier.as_str()) - .collect::>() - .join(", "); - return Err(format!( - "Device '{serial}' not found. Available: {available}" - )); - } - } - - let mut device = server - .get_device() - .map_err(|e| format!("Cannot connect to device: {e}"))?; - if shell_command.trim().is_empty() { return Err("Empty shell command".into()); } - info!("Ran command: adb shell {shell_command}"); + with_builtin_usb_devices(|cached_devices| { + let device_index = self.ensure_builtin_device(cached_devices)?; + info!("Ran direct USB shell command: {shell_command}"); - let mut buffer = Vec::new(); - let _exit_code = device - .shell_command(&shell_command, Some(&mut Cursor::new(&mut buffer)), None) - .map_err(|e| { - error!("ADB shell command failed: {e}"); - format!("Shell command failed: {e}") - })?; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let wrapped_command = wrap_builtin_shell_command(shell_command); + let transport_result = cached_devices[device_index] + .device + .shell_command(&wrapped_command, Some(&mut stdout), Some(&mut stderr)) + .map_err(|e| { + error!("Direct USB shell command failed: {e}"); + format!("Direct USB shell command failed: {e}") + }); + if let Err(err) = transport_result { + cached_devices.remove(device_index); + return Err(err); + } - Ok(String::from_utf8_lossy(&buffer).trim_end().to_string()) + finish_builtin_shell_command(&stdout, &stderr) + }) + } + + #[cfg(feature = "builtin-adb")] + fn reboot_builtin(&self) -> Result { + with_builtin_usb_devices(|cached_devices| { + let device_index = self.ensure_builtin_device(cached_devices)?; + let result = cached_devices[device_index] + .device + .reboot(RebootType::System) + .map(|()| String::new()) + .map_err(|e| format!("Direct USB reboot failed: {e}")); + cached_devices.remove(device_index); + result + }) } // ========== System backend implementation (adb binary) ========== @@ -320,7 +822,7 @@ impl ACommand { } /// List devices using the system `adb` binary - fn devices_system() -> Result, String> { + fn devices_system() -> Result, String> { let mut cmd = std::process::Command::new("adb"); cmd.arg("devices"); Ok(Self::run_system_command(cmd)? @@ -328,7 +830,7 @@ impl ACommand { .skip(1) // header .filter_map(|line| { let (serial, status) = line.split_once('\t')?; - Some((serial.to_string(), status.to_string())) + Some((serial.to_string(), AdbDeviceStatus::from_system(status))) }) .collect()) } @@ -348,6 +850,15 @@ impl ACommand { Self::run_system_command(cmd) } + fn reboot_system(&self) -> Result { + let mut cmd = std::process::Command::new("adb"); + if let Some(ref serial) = self.0.device_serial { + cmd.args(["-s", serial]); + } + cmd.arg("reboot"); + Self::run_system_command(cmd) + } + /// General system command executor for adb binary fn run_system_command(mut cmd: std::process::Command) -> Result { #[cfg(target_os = "windows")] @@ -387,6 +898,14 @@ impl ACommand { AdbBackend::System => self.run_shell_command_system(shell_command), } } + + fn reboot(&self) -> Result { + match self.0.backend { + #[cfg(feature = "builtin-adb")] + AdbBackend::Builtin => self.reboot_builtin(), + AdbBackend::System => self.reboot_system(), + } + } } impl Default for ACommand { @@ -422,7 +941,7 @@ impl ShellCommand { /// Reboots device pub fn reboot(self) -> Result { - self.0.run_shell_command("reboot") + self.0.reboot() } /// Execute an arbitrary shell action string on the device's default shell. @@ -630,7 +1149,7 @@ mod tests { #[test] fn backend_display() { #[cfg(feature = "builtin-adb")] - assert_eq!(AdbBackend::Builtin.to_string(), "Builtin"); + assert_eq!(AdbBackend::Builtin.to_string(), "Builtin (direct USB)"); assert_eq!(AdbBackend::System.to_string(), "System (adb)"); } @@ -638,4 +1157,274 @@ mod tests { fn backend_default_is_system() { assert_eq!(AdbBackend::default(), AdbBackend::System); } + + #[cfg(feature = "builtin-adb")] + #[test] + fn builtin_version_does_not_embed_a_dependency_version() { + assert_eq!(ACommand::version_builtin(), "adb_client (direct USB)"); + } + + #[cfg(feature = "builtin-adb")] + #[test] + fn builtin_device_selection_requires_an_exact_choice() { + let available = vec!["phone-a".to_string(), "phone-b".to_string()]; + assert_eq!(select_usb_device_index(&available, Some("phone-b")), Ok(1)); + assert!(select_usb_device_index(&available, Some("missing")).is_err()); + assert!(select_usb_device_index(&available, None).is_err()); + assert_eq!(select_usb_device_index(&["only".to_string()], None), Ok(0)); + } + + #[cfg(feature = "builtin-adb")] + #[test] + fn cached_device_is_dropped_when_its_usb_location_changes() { + let original = UsbDeviceLocation { bus: 1, address: 2 }; + let replugged = UsbDeviceLocation { bus: 1, address: 3 }; + + assert!(!retain_cached_device_for_candidate( + "phone", original, "phone", replugged + )); + assert!(retain_cached_device_for_candidate( + "other-phone", + original, + "phone", + replugged + )); + assert!(retain_cached_device_for_candidate( + "phone", original, "phone", original + )); + } + + #[cfg(feature = "builtin-adb")] + #[test] + fn builtin_shell_nonzero_exit_is_an_error() { + let result = finish_builtin_shell_command( + b"partial output\n__UAD_EXIT_STATUS__:1\n", + b"permission denied", + ); + let error = result.expect_err("non-zero exit status must fail"); + assert!(error.contains("status 1")); + assert!(error.contains("permission denied")); + } + + #[cfg(feature = "builtin-adb")] + #[test] + fn builtin_shell_success_preserves_output() { + assert_eq!( + finish_builtin_shell_command(b"success\n__UAD_EXIT_STATUS__:0\n", &[]), + Ok("success".to_string()) + ); + } + + #[cfg(feature = "builtin-adb")] + #[test] + fn builtin_shell_requires_a_valid_status_marker() { + assert!(finish_builtin_shell_command(b"output without status", &[]).is_err()); + assert!( + finish_builtin_shell_command(b"output\n__UAD_EXIT_STATUS__:not-a-number", &[]).is_err() + ); + } + + #[cfg(feature = "builtin-adb")] + #[test] + fn builtin_shell_wrapper_reports_the_remote_status() { + let wrapped = wrap_builtin_shell_command("pm list packages"); + assert!(wrapped.starts_with("pm list packages\n")); + assert!(wrapped.contains(BUILTIN_EXIT_MARKER)); + assert!(wrapped.contains("$?")); + } + + #[cfg(feature = "builtin-adb")] + #[test] + fn adb_key_path_honors_android_user_home_directly() { + assert_eq!( + resolve_adb_key_path( + Some(PathBuf::from("/custom/android-home")), + Some(PathBuf::from("/home/user")) + ), + Some(PathBuf::from("/custom/android-home/adbkey")) + ); + assert_eq!( + resolve_adb_key_path(None, Some(PathBuf::from("/home/user"))), + Some(PathBuf::from("/home/user/.android/adbkey")) + ); + } + + #[cfg(feature = "builtin-adb")] + #[test] + fn adb_key_write_is_atomic_and_does_not_replace_an_existing_key() { + let directory = tempfile::tempdir().expect("temporary key directory"); + let key_path = directory.path().join("adbkey"); + + write_adb_private_key(&key_path, "first key").expect("write initial key"); + assert_eq!( + std::fs::read_to_string(&key_path).expect("read initial key"), + "first key" + ); + + write_adb_private_key(&key_path, "replacement key") + .expect("existing key wins concurrent creation"); + assert_eq!( + std::fs::read_to_string(&key_path).expect("read preserved key"), + "first key" + ); + assert_eq!( + std::fs::read_dir(directory.path()) + .expect("list temporary key directory") + .count(), + 1, + "temporary files must be cleaned up" + ); + } + + #[cfg(feature = "builtin-adb")] + #[test] + fn existing_pkcs1_adb_key_is_converted_without_replacing_it() { + use rsa::pkcs1::EncodeRsaPrivateKey; + + let directory = tempfile::tempdir().expect("temporary key directory"); + let key_path = directory.path().join("adbkey"); + let key = RsaPrivateKey::new(&mut OsRng, 1024).expect("generate test key"); + let pkcs1 = key + .to_pkcs1_pem(LineEnding::LF) + .expect("encode PKCS#1 test key"); + std::fs::write(&key_path, pkcs1.as_bytes()).expect("write PKCS#1 test key"); + + let prepared = + prepare_existing_adb_private_key(&key_path).expect("prepare existing PKCS#1 key"); + + assert_ne!(prepared.path(), key_path); + assert!( + RsaPrivateKey::from_pkcs8_pem( + &std::fs::read_to_string(prepared.path()).expect("read converted key") + ) + .is_ok() + ); + assert_eq!( + std::fs::read_to_string(&key_path).expect("read original key"), + pkcs1.as_str() + ); + } + + #[cfg(feature = "builtin-adb")] + #[test] + fn invalid_existing_adb_key_has_an_actionable_error() { + let directory = tempfile::tempdir().expect("temporary key directory"); + let key_path = directory.path().join("adbkey"); + std::fs::write(&key_path, "not a private key").expect("write invalid key"); + + let error = prepare_existing_adb_private_key(&key_path) + .err() + .expect("invalid key must fail"); + + assert!(error.contains("not a valid unencrypted PKCS#8 or PKCS#1")); + assert!(error.contains(&key_path.display().to_string())); + } + + #[cfg(feature = "builtin-adb")] + #[test] + fn usb_matching_only_accepts_interfaces_supported_by_transport() { + assert!(is_supported_adb_interface(0xff, 0x42, 0x01)); + assert!(!is_supported_adb_interface(0xdc, 0x02, 0x01)); + assert!(!is_supported_adb_interface(0xff, 0x42, 0x00)); + } + + #[cfg(feature = "builtin-adb")] + #[test] + fn builtin_connection_errors_have_non_ready_statuses() { + assert_eq!( + ACommand::builtin_connection_status("DeviceBusy"), + AdbDeviceStatus::Busy + ); + assert_eq!( + ACommand::builtin_connection_status("operation timed out"), + AdbDeviceStatus::Unauthorized + ); + assert_eq!( + ACommand::builtin_connection_status("permission denied"), + AdbDeviceStatus::NoPermissions + ); + assert_eq!( + ACommand::builtin_connection_status("unexpected disconnect"), + AdbDeviceStatus::Offline + ); + assert_eq!( + ACommand::builtin_connection_status("invalid PKCS#8 key"), + AdbDeviceStatus::BackendError("invalid PKCS#8 key".to_string()) + ); + } + + #[test] + fn system_status_parser_accepts_detailed_permission_errors() { + assert_eq!( + AdbDeviceStatus::from_system( + "no permissions (user in plugdev group; are your udev rules wrong?)" + ), + AdbDeviceStatus::NoPermissions + ); + assert_eq!( + AdbDeviceStatus::from_system("recovery"), + AdbDeviceStatus::Other("recovery".to_string()) + ); + } + + #[cfg(feature = "builtin-adb")] + #[test] + fn usb_cache_identity_includes_physical_location() { + let original = UsbDeviceLocation { bus: 1, address: 2 }; + assert!(is_same_usb_device("phone", original, "phone", original)); + assert!(!is_same_usb_device( + "phone", + original, + "phone", + UsbDeviceLocation { bus: 1, address: 3 } + )); + } + + #[cfg(feature = "builtin-adb")] + #[test] + fn builtin_usb_operations_are_serialized() { + use std::sync::mpsc; + use std::time::Duration; + + let (first_entered_tx, first_entered_rx) = mpsc::channel(); + let (release_first_tx, release_first_rx) = mpsc::channel(); + let first = std::thread::spawn(move || { + with_builtin_usb_devices(|_| { + first_entered_tx.send(()).expect("signal first operation"); + release_first_rx.recv().expect("release first operation"); + Ok(()) + }) + }); + first_entered_rx + .recv_timeout(Duration::from_secs(1)) + .expect("first operation should acquire the lock"); + + let (second_entered_tx, second_entered_rx) = mpsc::channel(); + let second = std::thread::spawn(move || { + with_builtin_usb_devices(|_| { + second_entered_tx.send(()).expect("signal second operation"); + Ok(()) + }) + }); + + assert!( + second_entered_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "second operation must wait for the first" + ); + release_first_tx.send(()).expect("release first operation"); + second_entered_rx + .recv_timeout(Duration::from_secs(1)) + .expect("second operation should run after the first"); + + first + .join() + .expect("first operation thread") + .expect("first operation result"); + second + .join() + .expect("second operation thread") + .expect("second operation result"); + } } diff --git a/crates/uad-core/src/config.rs b/crates/uad-core/src/config.rs index 515cd63..8dfefbc 100644 --- a/crates/uad-core/src/config.rs +++ b/crates/uad-core/src/config.rs @@ -4,7 +4,7 @@ use crate::adb::AdbBackend; use crate::sync::User; use crate::utils::DisplayablePath; use log::error; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use std::fs; use std::path::PathBuf; use std::sync::LazyLock; @@ -23,11 +23,30 @@ pub struct GeneralSettings { pub theme: String, pub expert_mode: bool, pub backup_folder: PathBuf, - /// ADB backend to use: Builtin (no external dependencies) or System (uses installed adb) - #[serde(default)] + /// ADB backend to use: Builtin (direct USB) or System (installed adb) + #[serde(default, deserialize_with = "deserialize_adb_backend")] pub adb_backend: AdbBackend, } +#[derive(Deserialize)] +enum PersistedAdbBackend { + Builtin, + System, +} + +fn deserialize_adb_backend<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + match PersistedAdbBackend::deserialize(deserializer)? { + #[cfg(feature = "builtin-adb")] + PersistedAdbBackend::Builtin => Ok(AdbBackend::Builtin), + #[cfg(not(feature = "builtin-adb"))] + PersistedAdbBackend::Builtin => Ok(AdbBackend::System), + PersistedAdbBackend::System => Ok(AdbBackend::System), + } +} + #[derive(Default, Debug, Clone)] pub struct BackupSettings { pub backups: Vec, @@ -126,6 +145,27 @@ mod tests { assert_eq!(config.general.backup_folder, CACHE_DIR.join("backups")); } + #[test] + fn builtin_backend_config_is_preserved_or_safely_downgraded() { + let config: Config = toml::from_str( + r#" + [general] + theme = "Custom" + expert_mode = true + backup_folder = "/tmp/uad-backups" + adb_backend = "Builtin" + "#, + ) + .expect("Builtin backend config should remain readable"); + + assert_eq!(config.general.theme, "Custom"); + assert!(config.general.expert_mode); + #[cfg(feature = "builtin-adb")] + assert_eq!(config.general.adb_backend, AdbBackend::Builtin); + #[cfg(not(feature = "builtin-adb"))] + assert_eq!(config.general.adb_backend, AdbBackend::System); + } + // non-deterministic /* #[test] diff --git a/crates/uad-core/src/sync.rs b/crates/uad-core/src/sync.rs index 64399f8..b7ee84a 100644 --- a/crates/uad-core/src/sync.rs +++ b/crates/uad-core/src/sync.rs @@ -1,5 +1,5 @@ use crate::{ - adb::{ACommand as AdbCommand, AdbBackend, PM_CLEAR_PACK, PackageId}, + adb::{ACommand as AdbCommand, AdbBackend, AdbDeviceStatus, PM_CLEAR_PACK, PackageId}, uad_lists::PackageState, }; use log::{error, info}; @@ -37,6 +37,96 @@ impl std::fmt::Display for Phone { } } +/// Why device discovery found no ready devices. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeviceDiscoveryIssue { + Busy, + Unauthorized, + NoPermissions, + Offline, + Backend(String), +} + +impl DeviceDiscoveryIssue { + #[must_use] + pub const fn is_terminal_for(&self, backend: AdbBackend) -> bool { + if matches!(self, Self::Busy | Self::NoPermissions) { + return true; + } + #[cfg(feature = "builtin-adb")] + return matches!((self, backend), (Self::Backend(_), AdbBackend::Builtin)); + #[cfg(not(feature = "builtin-adb"))] + { + let _ = backend; + false + } + } +} + +impl std::fmt::Display for DeviceDiscoveryIssue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Busy => write!( + f, + "The system ADB server is using the USB device. Stop it and retry." + ), + Self::Unauthorized => write!( + f, + "USB debugging authorization is pending. Accept the prompt on the device." + ), + Self::NoPermissions => write!( + f, + "USB access was denied. Check the host's USB permissions or udev rules." + ), + Self::Offline => write!(f, "The device is offline. Reconnect it and retry."), + Self::Backend(err) => write!(f, "ADB device discovery failed: {err}"), + } + } +} + +/// Ready devices plus the reason no device was ready. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DeviceDiscovery { + pub devices: Vec, + pub issue: Option, +} + +/// Classify statuses returned by `adb devices`. +#[must_use] +pub fn classify_device_issue( + devices: &[(String, AdbDeviceStatus)], +) -> Option { + let mut statuses = devices.iter().map(|(_, status)| status); + if statuses + .clone() + .any(|status| matches!(status, AdbDeviceStatus::Busy)) + { + Some(DeviceDiscoveryIssue::Busy) + } else if statuses + .clone() + .any(|status| matches!(status, AdbDeviceStatus::Unauthorized)) + { + Some(DeviceDiscoveryIssue::Unauthorized) + } else if statuses + .clone() + .any(|status| matches!(status, AdbDeviceStatus::NoPermissions)) + { + Some(DeviceDiscoveryIssue::NoPermissions) + } else if statuses + .clone() + .any(|status| matches!(status, AdbDeviceStatus::Offline)) + { + Some(DeviceDiscoveryIssue::Offline) + } else { + statuses + .find_map(|status| match status { + AdbDeviceStatus::BackendError(error) => Some(error.clone()), + _ => None, + }) + .map(DeviceDiscoveryIssue::Backend) + } +} + /// `UserInfo` but relevant to UAD #[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] pub struct User { @@ -61,25 +151,14 @@ pub enum AdbError { /// This replaces the deprecated `adb_shell_command`. /// /// If `serial` is empty, it lets ADB choose the default device. -/// Uses the default ADB backend. +/// Uses the process-wide selected ADB backend. pub fn run_adb_shell_action>( device_serial: S, action: &str, -) -> Result { - run_adb_shell_action_with_backend(device_serial, action, AdbBackend::default()) -} - -/// Run an arbitrary shell action via the typed ADB wrapper with a specific backend. -/// -/// If `serial` is empty, it lets ADB choose the default device. -pub fn run_adb_shell_action_with_backend>( - device_serial: S, - action: &str, - backend: AdbBackend, ) -> Result { let serial = device_serial.as_ref(); - match AdbCommand::with_backend(backend).shell(serial).raw(action) { + match AdbCommand::new().shell(serial).raw(action) { Ok(o) => { if ["Error", "Failure"].iter().any(|&e| o.contains(e)) { let friendly_msg = make_friendly_error_message(&o, action); @@ -500,19 +579,18 @@ pub fn list_users_idx_prot(device_serial: &str) -> Vec { .unwrap_or_default() } -/// Get list of connected devices using a specific ADB backend. +/// Discover connected devices using the selected ADB backend. #[must_use] -pub fn get_devices_list(backend: AdbBackend) -> Vec { +pub fn discover_devices() -> DeviceDiscovery { + let backend = AdbBackend::current(); retry( Fixed::from_millis(500).take(if cfg!(debug_assertions) { 3 } else { 10 }), - || match AdbCommand::with_backend(backend).devices() { + || match AdbCommand::new().devices() { Ok(devices) => { + let issue = classify_device_issue(&devices); let mut device_list: Vec = vec![]; - if devices.iter().all(|(_, stat)| stat != "device") { - return OperationResult::Retry(vec![]); - } for (serial, status) in devices { - if status != "device" { + if status != AdbDeviceStatus::Device { continue; } device_list.push(Phone { @@ -526,26 +604,56 @@ pub fn get_devices_list(backend: AdbBackend) -> Vec { adb_id: serial, }); } - if device_list.is_empty() { - return OperationResult::Retry(vec![]); + + if !device_list.is_empty() { + return OperationResult::Ok(DeviceDiscovery { + devices: device_list, + issue: None, + }); + } + + let discovery = DeviceDiscovery { + devices: vec![], + issue, + }; + if discovery + .issue + .as_ref() + .is_some_and(|current_issue| current_issue.is_terminal_for(backend)) + { + OperationResult::Ok(discovery) + } else { + OperationResult::Retry(discovery) } - OperationResult::Ok(device_list) } Err(err) => { - error!("get_devices_list(backend) -> {err}"); - let test: Vec = vec![]; - OperationResult::Retry(test) + error!("get_devices_list() -> {err}"); + OperationResult::Retry(DeviceDiscovery { + devices: vec![], + issue: Some(DeviceDiscoveryIssue::Backend(err)), + }) } }, ) - .unwrap_or_default() + .unwrap_or_else(|last_attempt| last_attempt.error) +} + +/// Get ready connected devices using the selected ADB backend. +#[must_use] +pub fn get_devices_list() -> Vec { + discover_devices().devices } #[must_use] -pub fn initial_load(backend: AdbBackend) -> bool { - match AdbCommand::with_backend(backend).devices() { - Ok(_devices) => true, - Err(_err) => false, +pub fn initial_load() -> bool { + match AdbCommand::new().devices() { + // A successful query means the selected backend is available. Device + // presence and authorization are handled by get_devices_list(). + Ok(_) => true, + Err(err) => { + error!("initial_load() -> {err}"); + false + } } } @@ -712,3 +820,59 @@ pub fn attempt_fallback( )), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn statuses(values: &[AdbDeviceStatus]) -> Vec<(String, AdbDeviceStatus)> { + values + .iter() + .enumerate() + .map(|(index, status)| (format!("device-{index}"), status.clone())) + .collect() + } + + #[test] + fn discovery_issue_classification_is_specific_and_prioritized() { + assert_eq!( + classify_device_issue(&statuses(&[ + AdbDeviceStatus::Offline, + AdbDeviceStatus::Busy, + AdbDeviceStatus::Unauthorized, + ])), + Some(DeviceDiscoveryIssue::Busy) + ); + assert_eq!( + classify_device_issue(&statuses(&[AdbDeviceStatus::NoPermissions])), + Some(DeviceDiscoveryIssue::NoPermissions) + ); + assert_eq!( + classify_device_issue(&statuses(&[AdbDeviceStatus::BackendError( + "invalid key".to_string(), + )])), + Some(DeviceDiscoveryIssue::Backend("invalid key".to_string())) + ); + assert_eq!( + classify_device_issue(&statuses(&[AdbDeviceStatus::Device])), + None + ); + } + + #[test] + fn terminal_discovery_issues_are_backend_aware() { + assert!(DeviceDiscoveryIssue::Busy.is_terminal_for(AdbBackend::System)); + assert!(DeviceDiscoveryIssue::NoPermissions.is_terminal_for(AdbBackend::System)); + assert!(!DeviceDiscoveryIssue::Unauthorized.is_terminal_for(AdbBackend::System)); + assert!(!DeviceDiscoveryIssue::Offline.is_terminal_for(AdbBackend::System)); + #[cfg(feature = "builtin-adb")] + { + assert!(!DeviceDiscoveryIssue::Unauthorized.is_terminal_for(AdbBackend::Builtin)); + assert!(!DeviceDiscoveryIssue::Offline.is_terminal_for(AdbBackend::Builtin)); + assert!( + DeviceDiscoveryIssue::Backend("invalid key".to_string()) + .is_terminal_for(AdbBackend::Builtin) + ); + } + } +} diff --git a/crates/uad-gui/src/gui.rs b/crates/uad-gui/src/gui.rs index 75994f1..d51dd6a 100644 --- a/crates/uad-gui/src/gui.rs +++ b/crates/uad-gui/src/gui.rs @@ -16,11 +16,13 @@ use iced::{Alignment, Element, Length, Settings, Task, window::Settings as Windo use iced::{Subscription, event, keyboard}; #[cfg(feature = "img")] use image::ImageFormat; -use log::{debug, error, info}; +#[cfg(feature = "self-update")] +use log::debug; +use log::{error, info}; #[cfg(feature = "self-update")] use std::path::PathBuf; use uad_core::adb; -use uad_core::sync::{Phone, get_devices_list, initial_load}; +use uad_core::sync::{DeviceDiscovery, Phone, discover_devices, initial_load}; use uad_core::uad_lists::UadListState; use uad_core::update::{Release, SelfUpdateState, SelfUpdateStatus, get_latest_release}; use uad_core::utils::{FULL_NAME, NAME}; @@ -68,7 +70,7 @@ pub enum Message { SettingsAction(SettingsMessage), RefreshButtonPressed, RebootButtonPressed, - LoadDevices(Vec), + LoadDevices(DeviceDiscovery), #[cfg(feature = "self-update")] _NewReleaseDownloaded(Result<(PathBuf, PathBuf), ()>), GetLatestRelease(Result, ()>), @@ -93,17 +95,14 @@ impl UadGui { fn new() -> (Self, Task) { let app = Self::default(); let backend = app.settings_view.general.adb_backend; + backend.set_current(); ( app, Task::batch([ // Used in crate::widgets::navigation_menu::ICONS. Name is `icomoon`. font::load(include_bytes!("../../../resources/assets/icons.ttf").as_slice()) .map(Message::FontLoaded), - Task::perform(async move { initial_load(backend) }, Message::ADBSatisfied), - Task::perform( - async move { get_devices_list(backend) }, - Message::LoadDevices, - ), + Task::perform(async move { initial_load() }, Message::ADBSatisfied), Task::perform( async move { get_latest_release() }, Message::GetLatestRelease, @@ -164,7 +163,12 @@ impl UadGui { )] fn update(&mut self, msg: Message) -> Task { match msg { - Message::LoadDevices(devices_list) => { + Message::LoadDevices(discovery) => { + let DeviceDiscovery { + devices: devices_list, + issue, + } = discovery; + self.apps_view.discovery_issue = issue; self.selected_device = match &self.selected_device { Some(s_device) => { // Try to reload last selected phone @@ -177,6 +181,11 @@ impl UadGui { }; self.devices_list = devices_list; + if self.devices_list.is_empty() { + self.apps_view.loading_state = ListLoadingState::FindingPhones; + return Task::none(); + } + #[expect(unused_must_use, reason = "side-effect")] { self.update(Message::SettingsAction(SettingsMessage::LoadDeviceSettings)); @@ -202,21 +211,16 @@ impl UadGui { } Message::RefreshButtonPressed => { self.apps_view = AppsView::default(); - let backend = self.settings_view.general.adb_backend; #[expect(unused_must_use, reason = "side-effect")] { self.update(Message::AppsAction(AppsMessage::ADBSatisfied( self.adb_satisfied, ))); } - Task::perform( - async move { get_devices_list(backend) }, - Message::LoadDevices, - ) + Task::perform(async move { discover_devices() }, Message::LoadDevices) } Message::RebootButtonPressed => { self.apps_view = AppsView::default(); - let backend = self.settings_view.general.adb_backend; let serial = match &self.selected_device { Some(d) => d.adb_id.clone(), _ => String::default(), @@ -224,10 +228,14 @@ impl UadGui { self.selected_device = None; self.devices_list = vec![]; Task::perform( - async move { adb::ACommand::with_backend(backend).shell(serial).reboot() }, + async move { adb::ACommand::new().shell(serial).reboot() }, |_| Message::Nothing, ) } + Message::AppsAction(AppsMessage::OpenAdbSettings) => { + self.view = View::Settings; + Task::none() + } Message::AppsAction(msg) => self .apps_view .update( @@ -272,6 +280,15 @@ impl UadGui { } } } + SettingsMessage::AdbBackendApplied(backend) => { + backend.set_current(); + self.apps_view = AppsView::default(); + self.selected_device = None; + self.devices_list.clear(); + self.adb_satisfied = false; + + return Task::perform(async move { initial_load() }, Message::ADBSatisfied); + } _ => (), } self.settings_view @@ -398,9 +415,17 @@ impl UadGui { } Message::ADBSatisfied(result) => { self.adb_satisfied = result; - self.update(Message::AppsAction(AppsMessage::ADBSatisfied( + let update_view = self.update(Message::AppsAction(AppsMessage::ADBSatisfied( self.adb_satisfied, - ))) + ))); + if result { + Task::batch([ + update_view, + Task::perform(async move { discover_devices() }, Message::LoadDevices), + ]) + } else { + update_view + } } Message::Nothing => Task::none(), } diff --git a/crates/uad-gui/src/views/list.rs b/crates/uad-gui/src/views/list.rs index 8701e92..fc61ac0 100644 --- a/crates/uad-gui/src/views/list.rs +++ b/crates/uad-gui/src/views/list.rs @@ -4,8 +4,11 @@ use crate::theme::Theme; use crate::widgets::navigation_menu::ICONS; use log::{error, info, warn}; use std::path::PathBuf; +use uad_core::adb::AdbBackend; use uad_core::config::DeviceSettings; -use uad_core::sync::{AdbError, CorePackage, Phone, User, apply_pkg_state_commands}; +use uad_core::sync::{ + AdbError, CorePackage, DeviceDiscoveryIssue, Phone, User, apply_pkg_state_commands, +}; use uad_core::uad_lists::{ Opposite, PackageHashMap, PackageState, Removal, UadList, UadListState, load_debloat_lists, }; @@ -68,6 +71,7 @@ pub struct List { export_modal: bool, current_package_index: usize, is_adb_satisfied: bool, + pub discovery_issue: Option, copy_confirmation: bool, fallback_notifications: Vec, } @@ -102,6 +106,7 @@ pub enum Message { DescriptionEdit(text_editor::Action), CopyError(String), HideCopyConfirmation, + OpenAdbSettings, } pub struct SummaryEntry { @@ -199,7 +204,7 @@ impl List { Message::GoToUrl(url) => Self::on_go_to_url(url), Message::ExportSelection => self.on_export_selection(), Message::SelectionExported(res) => self.on_selection_exported(res), - Message::Nothing => Task::none(), + Message::Nothing | Message::OpenAdbSettings => Task::none(), Message::DescriptionEdit(action) => self.on_description_edit(action), Message::CopyError(err) => self.on_copy_error(err), Message::HideCopyConfirmation => self.on_hide_copy_confirmation(), @@ -240,10 +245,36 @@ impl List { ), LoadingState::FindingPhones => { if self.is_adb_satisfied { - waiting_view("Finding connected devices...", None, style::Text::Default) + if let Some(issue) = &self.discovery_issue { + let action = if matches!(issue, DeviceDiscoveryIssue::Busy) { + Some(button("Open ADB settings").on_press(Message::OpenAdbSettings)) + } else { + None + }; + return waiting_view(&issue.to_string(), action, style::Text::Danger); + } + let waiting_message = match AdbBackend::current() { + #[cfg(feature = "builtin-adb")] + AdbBackend::Builtin => { + "Waiting for a USB device and USB debugging authorization..." + } + AdbBackend::System => "Finding connected devices...", + }; + waiting_view(waiting_message, None, style::Text::Default) } else { + let connection_help = match AdbBackend::current() { + #[cfg(feature = "builtin-adb")] + AdbBackend::Builtin => { + "Builtin ADB could not initialize USB access. Check USB permissions \ + and reconnect the device." + } + AdbBackend::System => { + "ADB is not installed or could not be started. Install Android \ + platform-tools and relaunch the application." + } + }; waiting_view( - "No device connection detected. Connect a device with USB debugging enabled and authorized, then relaunch.", + connection_help, Some(button("Read on how to get started.") .on_press(Message::GoToUrl(PathBuf::from( "https://github.com/Universal-Debloater-Alliance/universal-android-debloater-next-generation/wiki/Getting-started", diff --git a/crates/uad-gui/src/views/settings.rs b/crates/uad-gui/src/views/settings.rs index ba048d1..7ff9440 100644 --- a/crates/uad-gui/src/views/settings.rs +++ b/crates/uad-gui/src/views/settings.rs @@ -28,6 +28,9 @@ use uad_core::{ #[derive(Debug, Clone)] pub enum PopUpModal { ExportUninstalled, + ConfirmAdbServerStop, + StoppingAdbServer, + AdbBackendError, } #[derive(Debug, Clone)] @@ -38,13 +41,16 @@ pub struct Settings { modal: Option, /// Cached ADB version string for display adb_version: String, + pending_adb_backend: Option, + adb_backend_error: Option, } impl Default for Settings { fn default() -> Self { let general = Config::load_configuration_file().general; + general.adb_backend.set_current(); // Fetch initial ADB version - let adb_version = ACommand::with_backend(general.adb_backend) + let adb_version = ACommand::new() .version() .unwrap_or_else(|e| format!("Error: {e}")); Self { @@ -53,6 +59,8 @@ impl Default for Settings { is_loading: false, modal: None, adb_version, + pending_adb_backend: None, + adb_backend_error: None, } } } @@ -65,6 +73,11 @@ pub enum Message { MultiUserMode(bool), ApplyTheme(Theme), ApplyAdbBackend(AdbBackend), + #[cfg(feature = "builtin-adb")] + RetryBuiltinAdb, + ConfirmAdbBackendSwitch, + AdbServerStopped(Result), + AdbBackendApplied(AdbBackend), AdbVersionFetched(Result), UrlPressed(PathBuf), BackupSelected(DisplayablePath), @@ -95,6 +108,10 @@ impl Settings { Message::MultiUserMode(toggled) => self.handle_multi_user_mode(phone, toggled), Message::ApplyTheme(theme) => self.handle_apply_theme(phone, theme), Message::ApplyAdbBackend(backend) => self.handle_apply_adb_backend(backend), + #[cfg(feature = "builtin-adb")] + Message::RetryBuiltinAdb => self.handle_retry_builtin_adb(), + Message::ConfirmAdbBackendSwitch => self.handle_confirm_adb_backend_switch(), + Message::AdbServerStopped(result) => self.handle_adb_server_stopped(result), Message::AdbVersionFetched(result) => self.handle_adb_version_fetched(result), Message::UrlPressed(url) => Self::handle_url_pressed(url), Message::LoadDeviceSettings => self.handle_load_device_settings(phone), @@ -104,7 +121,7 @@ impl Settings { Message::RestoreDevice => { self.handle_restore_device(phone, packages, nb_running_async_adb_commands) } - Message::RestoringDevice(_) => Task::none(), + Message::RestoringDevice(_) | Message::AdbBackendApplied(_) => Task::none(), Message::FolderChosen(result) => self.handle_folder_chosen(phone, result), Message::ChooseBackUpFolder => self.handle_choose_backup_folder(), Message::ExportPackages => Self::handle_export_packages(selected_user, packages), @@ -114,6 +131,8 @@ impl Settings { fn handle_modal_hide(&mut self) -> Task { self.modal = None; + self.pending_adb_backend = None; + self.adb_backend_error = None; Task::none() } @@ -152,17 +171,74 @@ impl Settings { } fn handle_apply_adb_backend(&mut self, backend: AdbBackend) -> Task { + if backend == self.general.adb_backend { + return Task::none(); + } + + #[cfg(feature = "builtin-adb")] + if backend == AdbBackend::Builtin { + self.pending_adb_backend = Some(backend); + self.adb_backend_error = None; + self.modal = Some(PopUpModal::ConfirmAdbServerStop); + return Task::none(); + } + + self.commit_adb_backend(backend) + } + + #[cfg(feature = "builtin-adb")] + fn handle_retry_builtin_adb(&mut self) -> Task { + self.pending_adb_backend = Some(AdbBackend::Builtin); + self.adb_backend_error = None; + self.modal = Some(PopUpModal::ConfirmAdbServerStop); + Task::none() + } + + fn handle_confirm_adb_backend_switch(&mut self) -> Task { + self.modal = Some(PopUpModal::StoppingAdbServer); + Task::perform( + async { ACommand::kill_system_server() }, + Message::AdbServerStopped, + ) + } + + fn handle_adb_server_stopped(&mut self, result: Result) -> Task { + match result { + Ok(_) => { + let Some(backend) = self.pending_adb_backend.take() else { + self.modal = None; + return Task::none(); + }; + self.modal = None; + self.commit_adb_backend(backend) + } + Err(err) => { + self.pending_adb_backend = None; + self.adb_backend_error = Some(format!( + "Could not stop the system ADB server: {err}\n\ + Builtin ADB was not enabled." + )); + self.modal = Some(PopUpModal::AdbBackendError); + Task::none() + } + } + } + + fn commit_adb_backend(&mut self, backend: AdbBackend) -> Task { self.general.adb_backend = backend; + backend.set_current(); self.adb_version = "Fetching...".to_string(); debug!("Config change: {self:?}"); let mut config = Config::load_configuration_file(); config.save_device_settings(self.device.clone(), self.general.clone()); info!("ADB backend changed to: {backend}"); - // Fetch version asynchronously - Task::perform( - async move { ACommand::with_backend(backend).version() }, - Message::AdbVersionFetched, - ) + Task::batch([ + Task::perform( + async move { ACommand::new().version() }, + Message::AdbVersionFetched, + ), + Task::done(Message::AdbBackendApplied(backend)), + ]) } fn handle_adb_version_fetched(&mut self, result: Result) -> Task { @@ -379,8 +455,18 @@ impl Settings { self.build_device_content(phone, apps_view) }; - if let Some(PopUpModal::ExportUninstalled) = self.modal { - return Self::render_export_modal(content); + match self.modal.as_ref() { + Some(PopUpModal::ExportUninstalled) => return Self::render_export_modal(content), + Some(PopUpModal::ConfirmAdbServerStop) => { + return Self::render_adb_server_confirmation_modal(content); + } + Some(PopUpModal::StoppingAdbServer) => { + return Self::render_adb_server_stopping_modal(content); + } + Some(PopUpModal::AdbBackendError) => { + return self.render_adb_backend_error_modal(content); + } + None => {} } container(scrollable(content)) @@ -521,7 +607,8 @@ impl Settings { .align_y(Alignment::Center); #[cfg(feature = "builtin-adb")] - let backend_description = "System: Uses your installed adb binary. Builtin: Uses embedded ADB (no external dependencies)."; + let backend_description = + "System: Uses your installed adb binary. Builtin: Embedded ADB over USB."; #[cfg(not(feature = "builtin-adb"))] let backend_description = "System: Uses your installed adb binary."; let backend_descr = text(backend_description).style(style::Text::Commentary); @@ -535,7 +622,18 @@ impl Settings { .spacing(5) .align_y(Alignment::Center); - container(column![backend_row, backend_descr, version_row,].spacing(10)) + let content = column![backend_row, backend_descr, version_row].spacing(10); + #[cfg(feature = "builtin-adb")] + let content = if self.general.adb_backend == AdbBackend::Builtin { + content.push( + button_primary(text("Stop system ADB and retry")) + .on_press(Message::RetryBuiltinAdb), + ) + } else { + content + }; + + container(content) .padding(10) .width(Length::Fill) .height(Length::Shrink) @@ -774,4 +872,86 @@ impl Settings { .on_blur(Message::ModalHide) .into() } + + fn render_adb_server_confirmation_modal( + content: Element<'_, Message, Theme, Renderer>, + ) -> Element<'_, Message, Theme, Renderer> { + let title = container( + row![text("Stop the system ADB server?").size(24)].align_y(Alignment::Center), + ) + .width(Length::Fill) + .style(style::Container::Frame) + .padding([10, 0]) + .center_x(Length::Shrink); + + let explanation = text( + "Builtin ADB needs exclusive access to the USB device. The system ADB server may \ + currently hold that access. Stop it and retry with Builtin ADB?", + ) + .width(Length::Fill); + + let actions = row![ + button(text("Cancel")).on_press(Message::ModalHide), + Space::new().width(Length::Fill), + button_primary(text("Stop server and retry")) + .on_press(Message::ConfirmAdbBackendSwitch), + ] + .spacing(10); + + let modal = container(column![title, explanation, actions].spacing(20)) + .width(520) + .padding(20) + .style(style::Container::Frame); + + Modal::new(content, modal) + .on_blur(Message::ModalHide) + .into() + } + + fn render_adb_server_stopping_modal( + content: Element<'_, Message, Theme, Renderer>, + ) -> Element<'_, Message, Theme, Renderer> { + let modal = container( + column![ + text("Stopping the system ADB server").size(24), + text("Please wait before reconnecting with Builtin ADB."), + ] + .spacing(20), + ) + .width(520) + .padding(20) + .style(style::Container::Frame); + + Modal::new(content, modal).into() + } + + fn render_adb_backend_error_modal<'a>( + &'a self, + content: Element<'a, Message, Theme, Renderer>, + ) -> Element<'a, Message, Theme, Renderer> { + let message = self + .adb_backend_error + .as_deref() + .unwrap_or("Could not switch ADB backends."); + let actions = row![ + Space::new().width(Length::Fill), + button(text("Close")).on_press(Message::ModalHide), + Space::new().width(Length::Fill), + ]; + let modal = container( + column![ + text("ADB backend switch failed").size(24), + text(message), + actions, + ] + .spacing(20), + ) + .width(520) + .padding(20) + .style(style::Container::Frame); + + Modal::new(content, modal) + .on_blur(Message::ModalHide) + .into() + } } diff --git a/crates/uad-gui/src/widgets/navigation_menu.rs b/crates/uad-gui/src/widgets/navigation_menu.rs index d26f1b4..acc3354 100644 --- a/crates/uad-gui/src/widgets/navigation_menu.rs +++ b/crates/uad-gui/src/widgets/navigation_menu.rs @@ -98,7 +98,10 @@ pub fn nav_menu<'a>( .gap(4); let device_list_text = match apps_view.loading_state { - ListLoadingState::FindingPhones => text("Finding connected devices..."), + ListLoadingState::FindingPhones => apps_view.discovery_issue.as_ref().map_or_else( + || text("Finding connected devices..."), + |issue| text(issue.to_string()), + ), _ => text("No devices/emulators found"), };