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 e40c813..acef1fa 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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773b25a440e72829bbb1b96435ee0e65ef0f14a3da545590930ca881a68e59d7" +dependencies = [ + "base64", + "byteorder", + "chrono", + "log", + "num-bigint-dig", + "num-traits", + "num_enum", + "quick-protobuf", + "rand 0.10.1", + "rcgen", + "regex", + "rsa", + "rusb", + "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" @@ -2407,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" @@ -2457,9 +2673,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 +2753,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 +2782,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 +2904,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 +2939,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 +2982,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 +3026,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 +3036,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 +3405,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 +3511,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 +3579,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 +3755,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 +3819,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 +3868,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 +3923,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 +3962,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 +4025,35 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -3744,6 +4122,36 @@ 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 = "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" @@ -3765,6 +4173,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 +4210,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 +4225,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 +4386,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 +4413,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 +4582,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 +4597,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 +4760,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 +4986,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,15 +5016,19 @@ dependencies = [ name = "uad-core" version = "1.2.0" dependencies = [ + "adb_client", "chrono", "csv", "dirs", "flate2", "log", "retry", + "rsa", + "rusb", "serde", "serde_json", "tar", + "tempfile", "toml", "ureq", "win32console", @@ -4745,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" @@ -5119,7 +5589,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 +6361,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 +6426,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 +6618,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 +6633,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..70eb203 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,10 @@ edition = "2024" [workspace.dependencies] # Core dependencies +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/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/commands.rs b/crates/uad-cli/src/commands.rs index 62286bd..6217134 100644 --- a/crates/uad-cli/src/commands.rs +++ b/crates/uad-cli/src/commands.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use std::io::Write; use uad_core::adb::{ACommand, PmListPacksFlag}; use uad_core::sync::{ - CorePackage, Phone, User, apply_pkg_state_commands, get_devices_list, get_package_state, + 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}; @@ -17,10 +17,14 @@ 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(); + 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()); @@ -104,6 +108,15 @@ 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( device: Option, @@ -129,7 +142,7 @@ pub fn list_packages( search, }; - let pm_flag = state_filter.and_then(StateFilter::to_pm_flag); + let pm_flag = resolve_pm_flag(state_filter); let system_packages = ACommand::new() .shell(&target_device.adb_id) .pm() @@ -468,3 +481,37 @@ 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() -> Result<(), Box> { + let backend = uad_core::adb::AdbBackend::current(); + println!("ADB Backend: {}\n", backend); + + match ACommand::new().version() { + Ok(version) => { + println!("{version}"); + Ok(()) + } + Err(e) => { + eprintln!("Failed to get ADB version: {e}"); + Err(e.into()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_and_uninstalled_lists_include_removed_packages() { + assert_eq!( + resolve_pm_flag(None), + Some(PmListPacksFlag::IncludeUninstalled) + ); + assert_eq!( + resolve_pm_flag(Some(StateFilter::Uninstalled)), + Some(PmListPacksFlag::IncludeUninstalled) + ); + } +} diff --git a/crates/uad-cli/src/device.rs b/crates/uad-cli/src/device.rs index 54fee8c..2391557 100644 --- a/crates/uad-cli/src/device.rs +++ b/crates/uad-cli/src/device.rs @@ -1,14 +1,18 @@ -use uad_core::sync::{Phone, User, get_devices_list}; +use uad_core::sync::{Phone, User, discover_devices}; 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(); + 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 f473e28..c31c6d1 100644 --- a/crates/uad-cli/src/main.rs +++ b/crates/uad-cli/src/main.rs @@ -4,12 +4,15 @@ 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::{ACommand, AdbBackend, AdbDeviceStatus}; +use uad_core::sync::{DeviceDiscoveryIssue, classify_device_issue}; use uad_core::uad_lists::PackageState; mod commands; @@ -20,12 +23,46 @@ mod repl; use filters::{ListFilter, RemovalFilter, StateFilter}; +/// CLI-compatible ADB backend selection +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum AdbBackendArg { + /// Embedded ADB over USB + #[cfg(feature = "builtin-adb")] + Builtin, + /// Use system-installed adb binary + System, +} + +impl From for AdbBackend { + fn from(arg: AdbBackendArg) -> Self { + match arg { + #[cfg(feature = "builtin-adb")] + 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 + #[arg( + short = 'B', + long = "backend", + value_enum, + global = true, + default_value = "system" + )] + backend: AdbBackendArg, + + /// Stop the system ADB server before using the Builtin backend + #[arg(long, global = true)] + kill_adb_server: bool, + #[command(subcommand)] command: Commands, } @@ -136,6 +173,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,6 +209,7 @@ async fn main() -> ExitCode { fn run() -> Result<(), Box> { let cli = Cli::parse(); + configure_adb(&cli)?; match cli.command { Commands::Devices => { @@ -239,6 +280,9 @@ fn run() -> Result<(), Box> { Commands::Update => { commands::update_lists()?; } + Commands::Adb => { + commands::show_adb_info()?; + } Commands::Completions { shell } => { commands::generate_completions(shell); } @@ -249,3 +293,123 @@ fn run() -> Result<(), Box> { 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/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..981ed42 100644 --- a/crates/uad-cli/src/repl.rs +++ b/crates/uad-cli/src/repl.rs @@ -2,11 +2,13 @@ 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}; -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; @@ -21,7 +23,9 @@ pub fn repl_mode( 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(device)?; @@ -145,8 +149,12 @@ fn handle_repl_line( } "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, + AdbBackend::current() ); } "clear" => { @@ -219,7 +227,7 @@ fn handle_list_command( ) -> Result<(), Box> { let parsed = ReplListArgs::parse(args)?; - let pm_flag = parsed.state_filter.and_then(StateFilter::to_pm_flag); + let pm_flag = resolve_pm_flag(parsed.state_filter); let system_packages = ACommand::new() .shell(&device.adb_id) .pm() diff --git a/crates/uad-core/Cargo.toml b/crates/uad-core/Cargo.toml index f7d3a43..0ecc0f9 100644 --- a/crates/uad-core/Cargo.toml +++ b/crates/uad-core/Cargo.toml @@ -11,10 +11,21 @@ categories = ["command-line-utilities"] edition.workspace = true [features] -default = [] +default = ["builtin-adb"] +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 f2335e0..2683d6c 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 the optional `adb_client` backend or the system ADB CLI, //! which implies: //! - no "magic" //! - no custom commands @@ -33,12 +32,46 @@ //! 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`): Embedded ADB over USB +//! - **System**: Uses the system-installed `adb` binary +//! +//! Select the process-wide backend with [`AdbBackend::set_current`], then use +//! [`ACommand::new`]. +//! //! Thank you! ❤️ //! //! 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, 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::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; @@ -53,25 +86,457 @@ pub fn to_trimmed_utf8(v: &[u8]) -> String { String::from_utf8_lossy(v).trim_end().to_string() } +/// ADB backend selection. +/// +/// - **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 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. + /// 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, +} + +/// 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")] + 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]; + + /// 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 (direct USB)"), + 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, +} + +#[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. /// /// This is not intended to model the entire ADB API. @@ -79,12 +544,16 @@ 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 using the process-wide selected backend. #[must_use] pub fn new() -> Self { - Self(std::process::Command::new("adb")) + Self(ACommandState { + device_serial: None, + backend: AdbBackend::current(), + }) } /// `shell` sub-command builder. @@ -94,9 +563,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 +576,334 @@ 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 { + #[cfg(feature = "builtin-adb")] + 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 embedded transport implementation: + /// ```txt + /// adb_client (direct USB) + /// ``` + /// + /// ## 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 { + #[cfg(feature = "builtin-adb")] + 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) ========== + + /// Describe the embedded direct-USB backend. + #[cfg(feature = "builtin-adb")] + 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()) + } + } + + #[cfg(feature = "builtin-adb")] + 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}")) + } + + #[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 { + if shell_command.trim().is_empty() { + return Err("Empty shell command".into()); + } + + 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 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); + } + + 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) ========== + + /// 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)] - { - 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") - } - } + 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(), AdbDeviceStatus::from_system(status))) + }) + .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) + } + + 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")] 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 { + #[cfg(feature = "builtin-adb")] + AdbBackend::Builtin => self.run_shell_command_builtin(shell_command), + 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(), } } } @@ -227,12 +920,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 +935,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.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 +998,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 +1006,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 +1031,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 +1063,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 +1145,286 @@ mod tests { assert_ne!(PackageId::new(p_id), None); } } + + #[test] + fn backend_display() { + #[cfg(feature = "builtin-adb")] + assert_eq!(AdbBackend::Builtin.to_string(), "Builtin (direct USB)"); + assert_eq!(AdbBackend::System.to_string(), "System (adb)"); + } + + #[test] + 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 5c41d78..8dfefbc 100644 --- a/crates/uad-core/src/config.rs +++ b/crates/uad-core/src/config.rs @@ -1,9 +1,10 @@ use crate::CACHE_DIR; use crate::CONFIG_DIR; +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; @@ -22,6 +23,28 @@ pub struct GeneralSettings { pub theme: String, pub expert_mode: bool, pub backup_folder: PathBuf, + /// 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)] @@ -49,6 +72,7 @@ impl Default for GeneralSettings { theme: DEFAULT_THEME.to_string(), expert_mode: false, backup_folder: CACHE_DIR.join("backups"), + adb_backend: AdbBackend::default(), } } } @@ -121,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 f90b6ee..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, 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,6 +151,7 @@ pub enum AdbError { /// This replaces the deprecated `adb_shell_command`. /// /// If `serial` is empty, it lets ADB choose the default device. +/// Uses the process-wide selected ADB backend. pub fn run_adb_shell_action>( device_serial: S, action: &str, @@ -227,23 +318,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 +370,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 +384,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,44 +579,81 @@ 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. +/// Discover connected devices using the selected ADB backend. #[must_use] -pub fn get_devices_list() -> 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::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 device in devices { - let serial = &device.0; + for (serial, status) in devices { + if status != AdbDeviceStatus::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, }); } - OperationResult::Ok(device_list) + + 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) + } } Err(err) => { error!("get_devices_list() -> {err}"); - let test: Vec = vec![]; - OperationResult::Retry(test) + 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() -> bool { match AdbCommand::new().devices() { - Ok(_devices) => true, - Err(_err) => false, + // 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 + } } } @@ -661,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/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/gui.rs b/crates/uad-gui/src/gui.rs index 925aedf..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, ()>), @@ -83,21 +85,24 @@ 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; + backend.set_current(); ( - 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() }, Message::ADBSatisfied), Task::perform( async move { get_latest_release() }, Message::GetLatestRelease, @@ -158,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 @@ -171,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,7 +217,7 @@ impl UadGui { self.adb_satisfied, ))); } - Task::perform(async { get_devices_list() }, Message::LoadDevices) + Task::perform(async move { discover_devices() }, Message::LoadDevices) } Message::RebootButtonPressed => { self.apps_view = AppsView::default(); @@ -213,10 +228,14 @@ impl UadGui { self.selected_device = None; self.devices_list = vec![]; Task::perform( - async { adb::ACommand::new().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( @@ -261,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 @@ -387,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/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..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, 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 { @@ -130,6 +135,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, @@ -172,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(), @@ -213,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( - "ADB is not installed on your system, install ADB and relaunch application.", + 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", @@ -329,12 +387,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 +807,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 +834,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 +843,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 +899,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 +944,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 +964,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 +982,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 +1007,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 +1085,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..7ff9440 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::{ @@ -27,6 +28,9 @@ use uad_core::{ #[derive(Debug, Clone)] pub enum PopUpModal { ExportUninstalled, + ConfirmAdbServerStop, + StoppingAdbServer, + AdbBackendError, } #[derive(Debug, Clone)] @@ -35,15 +39,28 @@ pub struct Settings { pub device: DeviceSettings, is_loading: bool, 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::new() + .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, + pending_adb_backend: None, + adb_backend_error: None, } } } @@ -55,6 +72,13 @@ pub enum Message { DisableMode(bool), MultiUserMode(bool), ApplyTheme(Theme), + ApplyAdbBackend(AdbBackend), + #[cfg(feature = "builtin-adb")] + RetryBuiltinAdb, + ConfirmAdbBackendSwitch, + AdbServerStopped(Result), + AdbBackendApplied(AdbBackend), + AdbVersionFetched(Result), UrlPressed(PathBuf), BackupSelected(DisplayablePath), BackupDevice, @@ -83,6 +107,12 @@ 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), + #[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), Message::BackupSelected(d_path) => self.handle_backup_selected(d_path), @@ -91,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), @@ -101,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() } @@ -138,6 +170,85 @@ impl Settings { Task::none() } + 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}"); + 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 { + 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() @@ -344,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)) @@ -361,6 +482,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 +504,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 +585,62 @@ 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); + + #[cfg(feature = "builtin-adb")] + 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); + + let version_row = row![ + text("Version: ").size(14), + text(&self.adb_version) + .size(14) + .style(style::Text::Commentary), + ] + .spacing(5) + .align_y(Alignment::Center); + + 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) + .style(style::Container::Frame) + .into() + } + fn warning_container(phone: &Phone) -> Element<'static, Message, Theme, Renderer> { container( row![ @@ -691,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"), }; 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": {