diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c44a29c..d9d3bd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,8 +86,8 @@ jobs: from PyInstaller.utils.hooks import collect_all; from openadb.version import ACBRIDGE_APK_FILENAME, RELEASE_EXE_FILENAME; assert callable(collect_all); - assert ACBRIDGE_APK_FILENAME == 'ACBridge-3.0.1.apk'; - assert RELEASE_EXE_FILENAME == 'OpenADB-3.0.1.exe'" + assert ACBRIDGE_APK_FILENAME == 'ACBridge-3.0.2.apk'; + assert RELEASE_EXE_FILENAME == 'OpenADB-3.0.2.exe'" - name: Check repository privacy guardrails shell: pwsh diff --git a/.github/workflows/device-lab.yml b/.github/workflows/device-lab.yml index 6c1ac75..49bf084 100644 --- a/.github/workflows/device-lab.yml +++ b/.github/workflows/device-lab.yml @@ -132,7 +132,7 @@ jobs: $null -eq $json -or $json.schema -ne "openadb.device-lab.v1" -or $json.product.name -ne "OpenADB" -or - $json.product.version -ne "3.0.1" -or + $json.product.version -ne "3.0.2" -or $json.product.source_commit -ne $expectedSourceCommit -or [string]::IsNullOrWhiteSpace([string]$json.environment.os) -or [string]::IsNullOrWhiteSpace([string]$json.environment.release) -or diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2bf781a..941df7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,15 +1,15 @@ -name: OpenADB 3.0.1 release +name: OpenADB 3.0.2 release on: push: tags: - - v3.0.1 + - v3.0.2 workflow_dispatch: inputs: tag: description: Release tag; dispatch this workflow from the same tag ref required: true - default: v3.0.1 + default: v3.0.2 type: string allow_unsigned_stable: description: Explicitly publish an unsigned stable release (artifact keeps the -unsigned suffix) @@ -31,7 +31,7 @@ jobs: tag: ${{ steps.release-ref.outputs.tag }} allow_unsigned_stable: ${{ steps.release-ref.outputs.allow_unsigned_stable }} steps: - - name: Require the immutable 3.0.1 tag + - name: Require the immutable 3.0.2 tag id: release-ref shell: pwsh env: @@ -47,8 +47,8 @@ jobs: '${{ github.ref_name }}' } - if ($tag -ne 'v3.0.1') { - throw "This release workflow is intentionally restricted to v3.0.1; received '$tag'." + if ($tag -ne 'v3.0.2') { + throw "This release workflow is intentionally restricted to v3.0.2; received '$tag'." } if ($env:CURRENT_REF -ne "refs/tags/$tag") { throw "Run a manual release from the $tag tag ref, not from a branch or a different tag." @@ -212,7 +212,7 @@ jobs: if (@(Compare-Object $requiredTopLevel $actualTopLevel).Count -ne 0) { throw 'BUILD_STATUS.json has missing or unexpected top-level fields.' } - if ($status.version -ne '3.0.1') { + if ($status.version -ne '3.0.2') { throw "Unexpected build version '$($status.version)'." } if ($status.signed -isnot [bool]) { @@ -236,21 +236,21 @@ jobs: $bridgeKeys = @($status.acbridge.PSObject.Properties.Name) if (@(Compare-Object @('package', 'version_name', 'version_code', 'apk_filename') $bridgeKeys).Count -ne 0 -or $status.acbridge.package -ne 'com.communism420.acbridge' -or - $status.acbridge.version_name -ne '3.0.1' -or - [int]$status.acbridge.version_code -ne 30101 -or - $status.acbridge.apk_filename -ne 'ACBridge-3.0.1.apk') { + $status.acbridge.version_name -ne '3.0.2' -or + [int]$status.acbridge.version_code -ne 30201 -or + $status.acbridge.apk_filename -ne 'ACBridge-3.0.2.apk') { throw 'BUILD_STATUS.json contains unexpected ACBridge metadata.' } $expectedName = if ($status.signed) { - 'OpenADB-3.0.1.exe' + 'OpenADB-3.0.2.exe' } else { - 'OpenADB-3.0.1-unsigned.exe' + 'OpenADB-3.0.2-unsigned.exe' } $expectedArtifactName = if ($status.signed) { - 'OpenADB-3.0.1-windows-signed' + 'OpenADB-3.0.2-windows-signed' } else { - 'OpenADB-3.0.1-windows-unsigned' + 'OpenADB-3.0.2-windows-unsigned' } if ($env:BUILD_ARTIFACT_NAME -ne $expectedArtifactName -or $env:BUILD_OUTPUT_FILENAME -ne $expectedName -or @@ -261,7 +261,7 @@ jobs: if ($status.filename -ne $expectedName) { throw "Build filename '$($status.filename)' does not match its signed status." } - $executables = @(Get-ChildItem -LiteralPath $release -File -Filter 'OpenADB-3.0.1*.exe') + $executables = @(Get-ChildItem -LiteralPath $release -File -Filter 'OpenADB-3.0.2*.exe') if ($executables.Count -ne 1 -or $executables[0].Name -ne $expectedName) { throw 'The build artifact must contain exactly one correctly named OpenADB executable.' } @@ -303,7 +303,7 @@ jobs: throw "Unsigned build has an unexpected Authenticode state: $($signature.Status)." } - $apkSource = 'openadb/resources/acbridge/ACBridge-3.0.1.apk' + $apkSource = 'openadb/resources/acbridge/ACBridge-3.0.2.apk' if (-not (Test-Path -LiteralPath $apkSource -PathType Leaf) -or (Get-Item -LiteralPath $apkSource).Length -le 0) { throw 'The versioned ACBridge APK is missing or empty at the release tag.' @@ -312,7 +312,7 @@ jobs: $releaseFiles = @( $exe.FullName, - (Join-Path $release 'ACBridge-3.0.1.apk'), + (Join-Path $release 'ACBridge-3.0.2.apk'), $statusPath ) $newSums = foreach ($file in $releaseFiles) { @@ -344,10 +344,10 @@ jobs: $changelog = Get-Content -LiteralPath 'CHANGELOG_EN.md' -Raw -Encoding utf8 $match = [regex]::Match( $changelog, - '(?ms)^## \[3\.0\.1\].*?(?=^## \[|\z)' + '(?ms)^## \[3\.0\.2\].*?(?=^## \[|\z)' ) if (-not $match.Success) { - throw 'CHANGELOG_EN.md does not contain a 3.0.1 release section.' + throw 'CHANGELOG_EN.md does not contain a 3.0.2 release section.' } $status = Get-Content -LiteralPath 'release/BUILD_STATUS.json' -Raw -Encoding utf8 | ConvertFrom-Json @@ -360,7 +360,7 @@ jobs: } $notes = @" - # OpenADB 3.0.1 + # OpenADB 3.0.2 ## Artifact status @@ -419,11 +419,11 @@ jobs: } $title = if ($env:SIGNED -eq 'true') { - 'OpenADB 3.0.1' + 'OpenADB 3.0.2' } elseif ($env:STABLE -eq 'true') { - 'OpenADB 3.0.1 (explicitly approved unsigned)' + 'OpenADB 3.0.2 (explicitly approved unsigned)' } else { - 'OpenADB 3.0.1 unsigned preview' + 'OpenADB 3.0.2 unsigned preview' } $arguments = @( 'release', 'create', $env:RELEASE_TAG, @@ -436,7 +436,7 @@ jobs: } $arguments += @( (Join-Path 'release' $env:EXE_FILENAME), - 'release/ACBridge-3.0.1.apk', + 'release/ACBridge-3.0.2.apk', 'release/BUILD_STATUS.json', 'release/SHA256SUMS.txt' ) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 7cbdaf8..04e4757 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -25,7 +25,7 @@ on: required: false push: tags: - - "v3.0.1" + - "v3.0.2" permissions: contents: read @@ -39,7 +39,7 @@ defaults: shell: pwsh env: - OPENADB_VERSION: "3.0.1" + OPENADB_VERSION: "3.0.2" PLATFORM_TOOLS_VERSION: "37.0.0" PLATFORM_TOOLS_ARCHIVE: "platform-tools_r37.0.0-win.zip" PLATFORM_TOOLS_ARCHIVE_SHA1: "f29bfb58d0d6f9a57d7dbcba6cc259f9ca6f58f1" diff --git a/CHANGELOG.md b/CHANGELOG.md index 00b4e29..78f0405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,74 @@ All notable OpenADB changes made since the start of the local audit and project redesign are documented in this file. The format is based on Keep a Changelog. The current public project version is -3.0.1. +3.0.2. -## [3.0.1] — Unreleased +## [3.0.2] — Unreleased + +### Fixed + +- Replaced the private-file `run-as` P2P bootstrap with a request-scoped + abstract Android control socket reached through a temporary local-only ADB + forward. P2P startup no longer fails on otherwise supported OEM devices + whose `/data` permissions cause Android's `run-as` safety check to reject + the helper before any file bytes are sent. +- ACBridge now reports storage-permission state and the authenticated `READY` + startup acknowledgement through the same bounded control channel. OpenADB + waits for that acknowledgement before opening the LAN data connection. +- Service startup now uses the Android 6–7-compatible path on API 23–25 and a + foreground service on Android 8 and later. +- The LocalSocket read timeout now applies only to the bounded bootstrap. The + established control monitor remains blocking until request-scoped cleanup + closes it, preventing normal SAF waits from being mistaken for disconnects + on Android releases that surface idle LocalSocket timeouts as `IOException`. + +### Security and lifecycle + +- The bootstrap secret and generated session metadata remain in memory and are + not placed in process arguments or request/status files on the Android + filesystem. ACBridge accepts the forwarded control channel only from the + Android shell or root peer identity. +- Cancellation and cleanup are request-scoped. Success, cancellation, timeout, + and startup failure close the control sockets and remove only the matching + temporary ADB forward, without overriding a more useful primary error. +- OpenADB also recovers the unique matching forward when Platform Tools loses + or returns a malformed creation response, and sends a public-ID cancellation + fallback so closing the tunnel cannot race ahead of Android consuming + `CANCEL`. +- Control messages are size-bounded and deadline-aware; authenticated startup + validation and the existing authenticated P2P data protocol remain in + effect. + +### Version + +- Updated OpenADB, ACBridge, Windows metadata, build workflows, screenshots, + and active release documentation to version 3.0.2. +- ACBridge 3.0.2 uses `versionCode 30201` under the documented version-code + policy and is released as `ACBridge-3.0.2.apk`. + +### Validation + +- Added automated regression coverage for the ADB-forwarded control bootstrap, + authenticated startup acknowledgement, cancellation, cleanup, redaction, + control-frame limits, and Android-version-specific service startup. +- A disposable read-only API 36 Android emulator completed a two-session + nested-folder upload: three files, six entries, 1,048,624 bytes, an empty + directory, and every SHA-256 were verified. Emulator NAT required a + test-only ADB forward for the data sockets, so this validates ACBridge, + control, folder, and integrity behavior but not direct-LAN routing or speed. +- The unsigned one-file EXE passed a clean-profile title, bundled Platform + Tools, normal-close, and crash-log smoke test. Thirty-eight isolated test + modules passed cleanly; all 41 assertions in the remaining adaptive-window + module passed, but its local PySide6 offscreen process then exited with a + native Windows heap-corruption code. The packaged EXE did not reproduce that + teardown failure. Windows CI run `29409867004` subsequently passed the full + clean-process matrix on CPython 3.10–3.14, so the native exit remains a + local-host-only observation rather than a reproduced release failure. +- No physical Android device, OEM Android 17 build, removable storage, or + direct-LAN route was available, so physical hardware transfer success + remains to be verified. + +## [3.0.1] — 2026-07-13 ### Fixed diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 00b4e29..78f0405 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -4,9 +4,74 @@ All notable OpenADB changes made since the start of the local audit and project redesign are documented in this file. The format is based on Keep a Changelog. The current public project version is -3.0.1. +3.0.2. -## [3.0.1] — Unreleased +## [3.0.2] — Unreleased + +### Fixed + +- Replaced the private-file `run-as` P2P bootstrap with a request-scoped + abstract Android control socket reached through a temporary local-only ADB + forward. P2P startup no longer fails on otherwise supported OEM devices + whose `/data` permissions cause Android's `run-as` safety check to reject + the helper before any file bytes are sent. +- ACBridge now reports storage-permission state and the authenticated `READY` + startup acknowledgement through the same bounded control channel. OpenADB + waits for that acknowledgement before opening the LAN data connection. +- Service startup now uses the Android 6–7-compatible path on API 23–25 and a + foreground service on Android 8 and later. +- The LocalSocket read timeout now applies only to the bounded bootstrap. The + established control monitor remains blocking until request-scoped cleanup + closes it, preventing normal SAF waits from being mistaken for disconnects + on Android releases that surface idle LocalSocket timeouts as `IOException`. + +### Security and lifecycle + +- The bootstrap secret and generated session metadata remain in memory and are + not placed in process arguments or request/status files on the Android + filesystem. ACBridge accepts the forwarded control channel only from the + Android shell or root peer identity. +- Cancellation and cleanup are request-scoped. Success, cancellation, timeout, + and startup failure close the control sockets and remove only the matching + temporary ADB forward, without overriding a more useful primary error. +- OpenADB also recovers the unique matching forward when Platform Tools loses + or returns a malformed creation response, and sends a public-ID cancellation + fallback so closing the tunnel cannot race ahead of Android consuming + `CANCEL`. +- Control messages are size-bounded and deadline-aware; authenticated startup + validation and the existing authenticated P2P data protocol remain in + effect. + +### Version + +- Updated OpenADB, ACBridge, Windows metadata, build workflows, screenshots, + and active release documentation to version 3.0.2. +- ACBridge 3.0.2 uses `versionCode 30201` under the documented version-code + policy and is released as `ACBridge-3.0.2.apk`. + +### Validation + +- Added automated regression coverage for the ADB-forwarded control bootstrap, + authenticated startup acknowledgement, cancellation, cleanup, redaction, + control-frame limits, and Android-version-specific service startup. +- A disposable read-only API 36 Android emulator completed a two-session + nested-folder upload: three files, six entries, 1,048,624 bytes, an empty + directory, and every SHA-256 were verified. Emulator NAT required a + test-only ADB forward for the data sockets, so this validates ACBridge, + control, folder, and integrity behavior but not direct-LAN routing or speed. +- The unsigned one-file EXE passed a clean-profile title, bundled Platform + Tools, normal-close, and crash-log smoke test. Thirty-eight isolated test + modules passed cleanly; all 41 assertions in the remaining adaptive-window + module passed, but its local PySide6 offscreen process then exited with a + native Windows heap-corruption code. The packaged EXE did not reproduce that + teardown failure. Windows CI run `29409867004` subsequently passed the full + clean-process matrix on CPython 3.10–3.14, so the native exit remains a + local-host-only observation rather than a reproduced release failure. +- No physical Android device, OEM Android 17 build, removable storage, or + direct-LAN route was available, so physical hardware transfer success + remains to be verified. + +## [3.0.1] — 2026-07-13 ### Fixed diff --git a/GUI_AUDIT.md b/GUI_AUDIT.md index ade5cd3..8f6ffb8 100644 --- a/GUI_AUDIT.md +++ b/GUI_AUDIT.md @@ -2,7 +2,7 @@ Дата аудита: 10 июля 2026 года -Текущая версия: OpenADB 3.0.1 +Текущая версия: OpenADB 3.0.2 Среда: Windows, Python 3.14.3, PySide6 6.11.1, экран 1920×1080, системная тёмная тема diff --git a/GUI_REDESIGN_REPORT.md b/GUI_REDESIGN_REPORT.md index 40035a6..791256d 100644 --- a/GUI_REDESIGN_REPORT.md +++ b/GUI_REDESIGN_REPORT.md @@ -2,7 +2,7 @@ Дата: 12 июля 2026 года -Версия отчёта: OpenADB 3.0.1 +Версия отчёта: OpenADB 3.0.2 Платформа проверки: Windows, Python 3.14.3, PySide6 6.11.1 diff --git a/README.md b/README.md index 0a568ca..fbab47e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![OpenADB logo](logo.png) -Version: `3.0.1` +Version: `3.0.2` OpenADB is a Windows desktop GUI for Android Platform Tools. It uses ADB and fastboot directly, without MTP and without root requirements, to inspect devices, manage apps, back up APKs before uninstalling, restore backups, transfer files, run common commands, and keep useful logs. @@ -14,35 +14,35 @@ The main window uses the same adaptive navigation, device status bar, keyboard f Dashboard keeps connection state and the recommended next action visible. Technical device information and Wireless ADB are compact, expandable sections. -![Dashboard in the dark theme](docs/screenshots/dashboard-dark-v3.0.1.png) +![Dashboard in the dark theme](docs/screenshots/dashboard-dark-v3.0.2.png) -![Dashboard in the light theme](docs/screenshots/dashboard-light-v3.0.1.png) +![Dashboard in the light theme](docs/screenshots/dashboard-light-v3.0.2.png) ### Applications Applications combines independent type, state, and UAD-category filters while preserving selections that are temporarily hidden by search or filtering. With no selection the table keeps its full height; selecting rows opens the contextual action bar inside the same table area. -![Applications with no selected rows](docs/screenshots/applications-dark-v3.0.1.png) +![Applications with no selected rows](docs/screenshots/applications-dark-v3.0.2.png) -![Applications contextual action bar](docs/screenshots/applications-contextual-actions-dark-v3.0.1.png) +![Applications contextual action bar](docs/screenshots/applications-contextual-actions-dark-v3.0.2.png) ### File Manager File Manager uses a resizable Android/action/Windows layout. Transfers, file operations, storage selection, optional existing-root support, and the Auto (recommended) or 1–8 manual stream selector for P2P uploads remain visible without hiding either file panel. -![File Manager in the dark theme](docs/screenshots/file-manager-dark-v3.0.1.png) +![File Manager in the dark theme](docs/screenshots/file-manager-dark-v3.0.2.png) ### Commands Commands provides a searchable Basic/Advanced catalog, availability and risk explanations, and an inline stdout/stderr result area. -![Commands in the dark theme](docs/screenshots/commands-dark-v3.0.1.png) +![Commands in the dark theme](docs/screenshots/commands-dark-v3.0.2.png) ### Settings Settings groups Platform Tools, appearance, device monitoring, application safety, root-assisted features, storage, and maintenance into scrollable sections. -![Settings in the dark theme](docs/screenshots/settings-dark-v3.0.1.png) +![Settings in the dark theme](docs/screenshots/settings-dark-v3.0.2.png) ## Independence and Attribution @@ -56,7 +56,7 @@ OpenADB uses its own package name for its optional Android bridge helper: com.communism420.acbridge ``` -The bundled `ACBridge-3.0.1.apk` is an independent helper built from the source in `openadb/resources/acbridge/`. Do not use ADB AppControl branding, package identity, code, or assets as OpenADB branding. +The bundled `ACBridge-3.0.2.apk` is an independent helper built from the source in `openadb/resources/acbridge/`. Do not use ADB AppControl branding, package identity, code, or assets as OpenADB branding. ## Acknowledgements @@ -214,9 +214,9 @@ Dashboard puts the textual connection state, active device, ADB/Recovery/Fastboo Apps lists installed packages with checkbox, icon or fallback icon, label/package name, type, state, version, APK paths, and size when Android allows it. -For faster real labels and rendered application icons, OpenADB automatically installs and starts its own helper APK, `com.communism420.acbridge`, from `openadb/resources/acbridge/ACBridge-3.0.1.apk`. The helper exports app labels and PNG icons through ADB-readable files, then OpenADB caches them locally. If the helper cannot be installed or started, OpenADB falls back to APK metadata parsing and clearly reports that fallback in the Apps status line. +For faster real labels and rendered application icons, OpenADB automatically installs and starts its own helper APK, `com.communism420.acbridge`, from `openadb/resources/acbridge/ACBridge-3.0.2.apk`. The helper exports app labels and PNG icons through ADB-readable files, then OpenADB caches them locally. If the helper cannot be installed or started, OpenADB falls back to APK metadata parsing and clearly reports that fallback in the Apps status line. -ACBridge 3.0.1 (`versionCode 30101`) exports only the packages OpenADB asks for, reports live label/icon progress, exports versionName/versionCode and APK size through Android PackageManager, stores pre-rendered PNG icons without extra ZIP recompression, and OpenADB imports those PNGs directly into the icon cache. Like ADB AppControl's bridge workflow, OpenADB exchanges compact cache files instead of pulling hundreds of APK files. On phones it keeps the public `/sdcard/.adac` exchange folder for compatibility; on Android TV it is packaged as a leanback-compatible helper and prefers its app-specific external folder first, because some TV firmwares restrict public hidden folders more aggressively. +ACBridge 3.0.2 (`versionCode 30201`) exports only the packages OpenADB asks for, reports live label/icon progress, exports versionName/versionCode and APK size through Android PackageManager, stores pre-rendered PNG icons without extra ZIP recompression, and OpenADB imports those PNGs directly into the icon cache. Like ADB AppControl's bridge workflow, OpenADB exchanges compact cache files instead of pulling hundreds of APK files. On phones it keeps the public `/sdcard/.adac` exchange folder for compatibility; on Android TV it is packaged as a leanback-compatible helper and prefers its app-specific external folder first, because some TV firmwares restrict public hidden folders more aggressively. OpenADB does not automatically delete an installed ACBridge package. If Android reports a signature mismatch while updating ACBridge, OpenADB keeps the existing helper and explains the issue. To move from an older manually built/debug-signed ACBridge to the bundled helper, uninstall `com.communism420.acbridge` manually and refresh Apps again. @@ -286,9 +286,9 @@ adb push ADB remains the default upload transport for a new device profile. For PC → Android uploads, the transport selector can instead use `P2P via ACBridge`. On the first unacknowledged P2P selection for each device profile, OpenADB explains that the connection is authenticated and file integrity is verified, but the file data is not encrypted. Accepting the warning suppresses repeats for the current run; selecting `Do not show this warning again` persists the acknowledgement only in that profile. Cancelling the warning keeps or restores ADB. While P2P is selected, the compact `Authenticated, not encrypted` status remains visible. Use P2P only on a trusted private network, never on public, shared, guest, or otherwise untrusted Wi-Fi. -P2P parallelism defaults to `Auto (recommended)`. Its deterministic planner selects 1–4 streams from the captured file count, total size, average size, and largest-file share. It does not probe, benchmark, or guess device or network speed. A per-profile manual override offers 1–8 streams; the actual count never exceeds the number of files, so a single file always uses one stream. OpenADB balances files between independent sessions by size and includes directory entries in those sessions; ACBridge serializes directory creation across concurrent sessions and keeps every individual file atomic. Platform Tools remains the control plane: OpenADB installs/updates the security-hardened ACBridge 3.0.1 build 1 (`versionCode 30101`), streams a one-shot authenticated request through ADB `run-as` standard input into ACBridge private app storage, starts short-lived foreground sessions, and retrieves their generated session keys. The request ID is only a public locator for request-scoped control files: the bootstrap secret stays inside the streamed payload, and the generated session key is returned only in authenticated `READY` metadata. On the first transfer to a MicroSD/USB location, ACBridge pauses in `PERMISSION_REQUIRED`, opens its Android storage-access flow, and waits for the user to approve the requested SAF tree or Android's `All files access` fallback. A P2P server does not open its TCP port and no file bytes are sent before that permission is available. File bytes then travel directly from the PC to the Android device over the local network. ACBridge writes them through the granted SAF tree, or through the granted storage-manager access on TV firmware without a working folder picker, so removable MicroSD/USB storage can be written without root even when the Android `shell` user is blocked. Android → PC transfers continue through Platform Tools in this version. +P2P parallelism defaults to `Auto (recommended)`. Its deterministic planner selects 1–4 streams from the captured file count, total size, average size, and largest-file share. It does not probe, benchmark, or guess device or network speed. A per-profile manual override offers 1–8 streams; the actual count never exceeds the number of files, so a single file always uses one stream. OpenADB balances files between independent sessions by size and includes directory entries in those sessions; ACBridge serializes directory creation across concurrent sessions and keeps every individual file atomic. Platform Tools remains the control plane: OpenADB installs or updates the security-hardened ACBridge 3.0.2 build 1 (`versionCode 30201`), creates a request-scoped abstract Android control socket, and reaches it only through a temporary local-only `adb forward`. The bootstrap secret, permission status, authenticated startup acknowledgement, and primary cancellation/close signals stay in that bounded in-memory channel instead of process arguments or device files, so the flow does not depend on `run-as` or permissive OEM `/data` modes. A best-effort fallback cancellation intent contains only the public request ID and still targets one session. Android 6–7 use their compatible service-start path, while Android 8 and later use a foreground service. On the first transfer to a MicroSD/USB location, ACBridge pauses in `PERMISSION_REQUIRED`, opens its Android storage-access flow, and waits for the user to approve the requested SAF tree or Android's `All files access` fallback. A P2P server does not open its TCP port and no file bytes are sent before that permission is available. File bytes then travel directly from the PC to the Android device over the local network. ACBridge writes them through the granted SAF tree, or through the granted storage-manager access on TV firmware without a working folder picker, so removable MicroSD/USB storage can be written without root even when the Android `shell` user is blocked. Android → PC transfers continue through Platform Tools in this version. -Each P2P session accepts one authenticated connection, and ACBridge can keep several selected sessions active concurrently. The foreground service stops only after every session has finished or timed out. Session keys are never placed in an ADB command line, and their authenticated `READY` status files are removed before data transfer. HMAC-SHA256 authenticates the connection, every entry-metadata control frame, the canonical request transcript, each file payload, and the terminal success response with exact entry/file/byte counts. SHA-256 verifies each completed file before ACBridge replaces an existing destination. Partial files use temporary SAF documents and are removed after cancellation or failure. These checks authenticate the one-shot session and verify integrity; they do not encrypt the file data. Use P2P only on a trusted private network. Router/AP client isolation and host firewalls can prevent the PC from reaching the TV directly. +Each P2P session accepts one authenticated connection, and ACBridge can keep several selected sessions active concurrently. The transfer service stops only after every session has finished or timed out. Session keys are never placed in an ADB command line or written to Android storage; authenticated `READY` metadata is returned only through the request-scoped in-memory control channel before data transfer. HMAC-SHA256 authenticates the connection, every entry-metadata control frame, the canonical request transcript, each file payload, and the terminal success response with exact entry/file/byte counts. SHA-256 verifies each completed file before ACBridge replaces an existing destination. Partial files use temporary SAF documents and are removed after cancellation or failure. These checks authenticate the one-shot session and verify integrity; they do not encrypt the file data. Use P2P only on a trusted private network. Router/AP client isolation and host firewalls can prevent the PC from reaching the TV directly. MTP is not used. Drag and drop works in both directions, transfers show progress and support cancellation, and the splitter position, last paths, upload transport, and P2P stream preference are saved per device profile. The optional P2P warning acknowledgement is also profile-local. `F5`, `F2`, `Delete`, `Enter`, and `Backspace` provide common keyboard operations when a file panel has focus. Android protected paths show a warning because non-root ADB usually cannot write to system partitions. @@ -304,7 +304,7 @@ When `Use root for transfers` is explicitly enabled and root is already granted /mnt/media_rw/ ``` -File creation, deletion, rename, pull, and the default push transport work through ADB on the selected storage volume. If P2P upload or ACBridge deletion needs removable-storage access, OpenADB asks ACBridge to request Android Storage Access Framework access on the TV screen. Select the requested MicroSD/USB storage location once; Android persists that permission, and future P2P uploads and deletes can use `DocumentsContract` through ACBridge without MTP. ACBridge 3.0.1 opens the picker for the matching storage volume when Android exposes it, resolves files by traversing the granted SAF tree, and falls back to Android's All files access settings for OpenADB Bridge if the firmware has no system folder picker. If Android still denies write access, OpenADB reports the error instead of silently pretending the operation succeeded. +File creation, deletion, rename, pull, and the default push transport work through ADB on the selected storage volume. If P2P upload or ACBridge deletion needs removable-storage access, OpenADB asks ACBridge to request Android Storage Access Framework access on the TV screen. Select the requested MicroSD/USB storage location once; Android persists that permission, and future P2P uploads and deletes can use `DocumentsContract` through ACBridge without MTP. ACBridge 3.0.2 opens the picker for the matching storage volume when Android exposes it, resolves files by traversing the granted SAF tree, and falls back to Android's All files access settings for OpenADB Bridge if the firmware has no system folder picker. If Android still denies write access, OpenADB reports the error instead of silently pretending the operation succeeded. ## Commands diff --git a/docs/DEVICE_LAB_MATRIX.md b/docs/DEVICE_LAB_MATRIX.md index a927403..aa26439 100644 --- a/docs/DEVICE_LAB_MATRIX.md +++ b/docs/DEVICE_LAB_MATRIX.md @@ -1,6 +1,6 @@ -# OpenADB 3.0.1 device-lab matrix +# OpenADB 3.0.2 device-lab matrix -Last updated: 2026-07-13 +Last updated: 2026-07-15 This matrix separates automated evidence from physical-device evidence. A mock, offscreen Qt test, or source inspection is never reported as a successful @@ -9,16 +9,20 @@ hardware run. ## Current baseline - The local host identifies itself as Windows 11 Pro, version `10.0.26200`. -- The final strict clean-process test run passed all 39 tracked test modules - and all 564 tests on CPython 3.14.3 with - `QT_QPA_PLATFORM=offscreen`. -- Hosted Windows CI run `29259146171` passed on CPython 3.10, 3.11, 3.12, - 3.13, and 3.14 after the Stage 8 device-lab tooling was added. -- Hosted Windows CI run `29257684156` passed the complete validation matrix on - CPython 3.10, 3.11, 3.12, 3.13, and 3.14. This is hosted automated evidence, - not Android hardware or physical Windows 10 evidence. -- Read-only `adb devices -l` and `fastboot devices` probes both returned exit - code 0 and no connected targets. No device identifier was recorded. +- Thirty-eight of 39 isolated modules passed their clean-process gate on + CPython 3.14.3 with `QT_QPA_PLATFORM=offscreen` (531 tests). All 41 + assertions in `test_main_window_adaptive` also passed, but that local + PySide6 process exited afterward with Windows status `0xc0000374`. +- Windows CI run `29409867004` passed the complete 3.0.2 clean-process matrix + on CPython 3.10, 3.11, 3.12, 3.13, and 3.14, including the adaptive-window + module. The native teardown was therefore not reproduced on hosted Windows. +- Hosted Windows CI runs `29259146171` and `29257684156` passed the earlier + baseline on CPython 3.10–3.14. They remain historical automated evidence and + are not a substitute for CI on the 3.0.2 commit, Android hardware, or a + physical Windows 10 host. +- Initial and final `adb devices -l` probes returned no connected physical + target. A disposable API 36 emulator was used only for the virtual smoke + described below, then stopped without saving its read-only AVD overlay. - The local default `device_lab_smoke.py` invocation produced validated JSON and JUnit reports with `mode=read_only`, `status=not_run`, zero failures and zero transports. Tool/version probes passed and no mutation command ran; the @@ -29,13 +33,20 @@ hardware run. appearance. It verified the title, startup, normal close, and absence of a crash log; it did not navigate every page. This is not a substitute for the complete DPI, multi-monitor, signed-build, Windows 10, or Android rows below. -- The final unsigned one-file preview is 90,452,041 bytes with SHA-256 - `B48BCB48F868581384D68EFAA2DC373317C347E90967AA7F11B393F4B8C01A5B`. +- The local unsigned one-file 3.0.2 intermediate is 90,459,651 bytes with + SHA-256 + `A95290646287FF32479B8F6EDE6F1A05063698FFC8536E6CD3C06F4496A07B51`. Its clean-profile Windows 11 smoke passed and Authenticode correctly reports `NotSigned`; this adds no signed-build or Android hardware evidence. -- No Android device, Windows 10 host, signing certificate, removable Android - storage, rooted disposable device, multi-monitor lab, or controlled network - fault lab was available. Those results remain explicitly unclaimed. +- A read-only API 36 emulator accepted ACBridge 3.0.2 (`versionCode 30201`) and + completed a two-session nested-folder upload with three files, six entries, + 1,048,624 verified bytes, an empty directory, and matching SHA-256 values. + Because emulator NAT required a test-only ADB forward for the data sockets, + this is a virtual ACBridge/protocol proxy, not direct-LAN evidence. +- No physical Android device, OEM Android 17 build, Windows 10 host, signing + certificate, removable Android storage, rooted disposable device, + multi-monitor lab, or controlled network fault lab was available. Those + results remain explicitly unclaimed. Status meanings: diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 6a9d4d0..cf69ba0 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -1,14 +1,14 @@ # OpenADB release process This document is the operator checklist for producing an OpenADB Windows -release. It describes the automated 3.0.1 pipeline and the manual evidence +release. It describes the automated 3.0.2 pipeline and the manual evidence that must be retained. A green workflow is necessary, but it does not replace physical Windows or Android device-lab validation. ## Release invariants -- Build and release only from an immutable `v` tag. OpenADB 3.0.1 is - intentionally restricted to `v3.0.1` by the release workflow. +- Build and release only from an immutable `v` tag. OpenADB 3.0.2 is + intentionally restricted to `v3.0.2` by the release workflow. - The Python package, window title, Windows resources, PyInstaller filename, ACBridge source, manifest, APK, documentation, and changelog must name the same version. @@ -69,8 +69,8 @@ change and must pass the complete Windows Python matrix before release. python -m unittest -q tests.test_version_metadata ``` -For OpenADB 3.0.1 the required helper identity is -`com.communism420.acbridge`, `versionName=3.0.1`, and `versionCode=30101`. +For OpenADB 3.0.2 the required helper identity is +`com.communism420.acbridge`, `versionName=3.0.2`, and `versionCode=30201`. ## 2. Build and verify ACBridge @@ -94,21 +94,22 @@ Record the output of the following independent checks in the release evidence: ```powershell $buildTools = Get-ChildItem "$env:ANDROID_HOME\build-tools" -Directory | Sort-Object Name -Descending | Select-Object -First 1 -& "$($buildTools.FullName)\aapt.exe" dump badging openadb\resources\acbridge\ACBridge-3.0.1.apk -& "$($buildTools.FullName)\zipalign.exe" -c -v 4 openadb\resources\acbridge\ACBridge-3.0.1.apk -java -jar "$($buildTools.FullName)\lib\apksigner.jar" verify --verbose --print-certs openadb\resources\acbridge\ACBridge-3.0.1.apk +& "$($buildTools.FullName)\aapt.exe" dump badging openadb\resources\acbridge\ACBridge-3.0.2.apk +& "$($buildTools.FullName)\zipalign.exe" -c -v 4 openadb\resources\acbridge\ACBridge-3.0.2.apk +java -jar "$($buildTools.FullName)\lib\apksigner.jar" verify --verbose --print-certs openadb\resources\acbridge\ACBridge-3.0.2.apk ``` The bundled ACBridge APK uses the repository's intentionally public Android debug signing identity so existing helper installs remain upgrade-compatible. Its signature check proves build/identity continuity, not private publisher -authenticity. The helper is also deliberately `debuggable` because its current -private status-file protocol uses Android `run-as`; that lifecycle dependency -is separate from the choice of signing key. This public debug identity is not -the Windows Authenticode certificate and must never be reused as one. Do not -rotate either installed identity as an incidental build fix, and never add a -private publisher keystore, PFX, password, or certificate data to the -repository or workflow logs. +authenticity. ACBridge itself is not a debuggable application. Its exported +P2P service requires Android's shell-only `DUMP` permission, verifies the +LocalSocket peer UID, and receives one-shot control data through a temporary +request-scoped ADB forward without `run-as` or status files. The public debug +signing identity is not the Windows Authenticode certificate and must never be +reused as one. Do not rotate either installed identity as an incidental build +fix, and never add a private publisher keystore, PFX, password, or certificate +data to the repository or workflow logs. ## 3. Run source validation @@ -156,12 +157,12 @@ python -m pip check python -m PyInstaller --noconfirm --clean OpenADB.spec ``` -`OpenADB.spec` produces a one-file `OpenADB-3.0.1.exe` build intermediate and +`OpenADB.spec` produces a one-file `OpenADB-3.0.2.exe` build intermediate and bundles the current ADB/fastboot binaries and DLLs, their notice when available, the versioned ACBridge APK, UI resources, and required Python packages. Until Authenticode succeeds, that stable-looking intermediate is not a publishable stable artifact: inspect it, then rename it to -`OpenADB-3.0.1-unsigned.exe`. Do not commit the large EXE; publish it as an +`OpenADB-3.0.2-unsigned.exe`. Do not commit the large EXE; publish it as an Actions/release artifact. Automation downloads the exact stable Platform Tools 37.0.0 Windows archive. @@ -170,17 +171,17 @@ recorded SHA-256 of those same bytes before extracting or executing anything; either mismatch stops the build. The `Windows release build` workflow runs on `workflow_dispatch`, calls from -the release workflow, and the exact `v3.0.1` tag. Its smoke test uses a clean +the release workflow, and the exact `v3.0.2` tag. Its smoke test uses a clean temporary OpenADB profile and a read-only startup path. It checks: - process startup and clean shutdown; -- the exact `OpenADB 3.0.1` window title; +- the exact `OpenADB 3.0.2` window title; - bundled ADB, fastboot, Platform Tools libraries, and notice; - bundled ACBridge package/version metadata; - absence of a crash log. -The workflow uploads either `OpenADB-3.0.1-windows-signed` or -`OpenADB-3.0.1-windows-unsigned`. The artifact contains exactly one +The workflow uploads either `OpenADB-3.0.2-windows-signed` or +`OpenADB-3.0.2-windows-unsigned`. The artifact contains exactly one appropriately named EXE plus `BUILD_STATUS.json` and `SHA256SUMS.txt`; its dynamic artifact name, signed state, and filename are also exposed as reusable workflow outputs and must agree with the status file. Treat a missing or @@ -216,10 +217,10 @@ configuration is an error. When all are present, automation must: 1. decode the PFX into the isolated runner temporary directory; 2. use it without echoing the password or certificate bytes; -3. sign the temporary `OpenADB-3.0.1-unsigned.exe` candidate with SHA-256 and +3. sign the temporary `OpenADB-3.0.2-unsigned.exe` candidate with SHA-256 and the configured timestamp; 4. run `signtool verify /pa /all /v /tw` and require exit code zero; -5. only after verification, rename it to the stable `OpenADB-3.0.1.exe`; +5. only after verification, rename it to the stable `OpenADB-3.0.2.exe`; 6. independently check Authenticode again in the release job; 7. delete the temporary PFX in an always-run cleanup step (and delete any temporary certificate-store entry if a future implementation imports one). @@ -241,7 +242,7 @@ Tools archive hashes so the release gate can reject a substituted build input. Local verification uses: ```powershell -$executables = @(Get-ChildItem . -File -Filter 'OpenADB-3.0.1*.exe') +$executables = @(Get-ChildItem . -File -Filter 'OpenADB-3.0.2*.exe') if ($executables.Count -ne 1) { throw 'Expected exactly one signed or unsigned release EXE.' } $digest = Get-FileHash $executables[0].FullName -Algorithm SHA256 "$($digest.Hash) *$($executables[0].Name)" | Set-Content .\SHA256SUMS.txt -Encoding ascii @@ -252,8 +253,8 @@ For a verified signed build, first require the stable filename and then retain this command's successful output: ```powershell -if ($executables[0].Name -ne 'OpenADB-3.0.1.exe') { throw 'Unsigned EXE cannot pass the signed gate.' } -signtool verify /pa /all /v /tw .\OpenADB-3.0.1.exe +if ($executables[0].Name -ne 'OpenADB-3.0.2.exe') { throw 'Unsigned EXE cannot pass the signed gate.' } +signtool verify /pa /all /v /tw .\OpenADB-3.0.2.exe ``` Do not copy a checksum from an earlier build: signing changes the executable @@ -281,9 +282,9 @@ commit and push only that tag: ```powershell git status --short -git tag -a v3.0.1 -m "OpenADB 3.0.1" -git show --no-patch --decorate v3.0.1 -git push origin v3.0.1 +git tag -a v3.0.2 -m "OpenADB 3.0.2" +git show --no-patch --decorate v3.0.2 +git push origin v3.0.2 ``` The tag starts `Windows CI`, the standalone Windows build, and the release @@ -292,7 +293,7 @@ same reusable Windows builder, downloads its artifact, validates the strict metadata schema, recomputes hashes, independently verifies Authenticode, and only then calls GitHub Releases. -Release notes are generated from the English 3.0.1 changelog section and add: +Release notes are generated from the English 3.0.2 changelog section and add: - signed/unsigned state and executable SHA-256; - Platform Tools and ACBridge metadata; @@ -307,7 +308,7 @@ profiles, signing password, and successful-test logs are never release assets. ## 9. Behavior without a signing certificate With all three signing secrets absent, the builder creates -`OpenADB-3.0.1-unsigned.exe`, records `"signed": false`, and never uses the +`OpenADB-3.0.2-unsigned.exe`, records `"signed": false`, and never uses the stable signed filename. An automatic tag run creates only a clearly labelled draft/prerelease unsigned preview. Reviewers must check its checksum, metadata, limitations, and Windows warning behavior before deciding what to do next. @@ -315,13 +316,13 @@ limitations, and Windows warning behavior before deciding what to do next. The preferred resolution is to configure a protected certificate and rerun the pipeline. If project policy explicitly permits an unsigned stable release, a maintainer must delete the existing draft preview after preserving its audit -record, select the `v3.0.1` ref in Actions, manually dispatch -`OpenADB 3.0.1 release`, and enable `allow_unsigned_stable`. That explicit +record, select the `v3.0.2` ref in Actions, manually dispatch +`OpenADB 3.0.2 release`, and enable `allow_unsigned_stable`. That explicit input is unavailable to an automatic tag run. The published executable still keeps the `-unsigned` suffix and the release notes prominently disclose its state. -Never rename an unsigned EXE to `OpenADB-3.0.1.exe`, manually set +Never rename an unsigned EXE to `OpenADB-3.0.2.exe`, manually set `"signed": true`, or publish an unsigned automatic preview as a final signed release. diff --git a/docs/screenshots/applications-contextual-actions-dark-v3.0.1.png b/docs/screenshots/applications-contextual-actions-dark-v3.0.1.png deleted file mode 100644 index e614da3..0000000 Binary files a/docs/screenshots/applications-contextual-actions-dark-v3.0.1.png and /dev/null differ diff --git a/docs/screenshots/applications-contextual-actions-dark-v3.0.2.png b/docs/screenshots/applications-contextual-actions-dark-v3.0.2.png new file mode 100644 index 0000000..5387d68 Binary files /dev/null and b/docs/screenshots/applications-contextual-actions-dark-v3.0.2.png differ diff --git a/docs/screenshots/applications-dark-v3.0.1.png b/docs/screenshots/applications-dark-v3.0.1.png deleted file mode 100644 index 5179bfd..0000000 Binary files a/docs/screenshots/applications-dark-v3.0.1.png and /dev/null differ diff --git a/docs/screenshots/applications-dark-v3.0.2.png b/docs/screenshots/applications-dark-v3.0.2.png new file mode 100644 index 0000000..a2ea51c Binary files /dev/null and b/docs/screenshots/applications-dark-v3.0.2.png differ diff --git a/docs/screenshots/commands-dark-v3.0.1.png b/docs/screenshots/commands-dark-v3.0.1.png deleted file mode 100644 index f8a6806..0000000 Binary files a/docs/screenshots/commands-dark-v3.0.1.png and /dev/null differ diff --git a/docs/screenshots/commands-dark-v3.0.2.png b/docs/screenshots/commands-dark-v3.0.2.png new file mode 100644 index 0000000..32c7044 Binary files /dev/null and b/docs/screenshots/commands-dark-v3.0.2.png differ diff --git a/docs/screenshots/dashboard-dark-v3.0.1.png b/docs/screenshots/dashboard-dark-v3.0.1.png deleted file mode 100644 index a520f2f..0000000 Binary files a/docs/screenshots/dashboard-dark-v3.0.1.png and /dev/null differ diff --git a/docs/screenshots/dashboard-dark-v3.0.2.png b/docs/screenshots/dashboard-dark-v3.0.2.png new file mode 100644 index 0000000..2206a72 Binary files /dev/null and b/docs/screenshots/dashboard-dark-v3.0.2.png differ diff --git a/docs/screenshots/dashboard-light-v3.0.1.png b/docs/screenshots/dashboard-light-v3.0.1.png deleted file mode 100644 index a52052e..0000000 Binary files a/docs/screenshots/dashboard-light-v3.0.1.png and /dev/null differ diff --git a/docs/screenshots/dashboard-light-v3.0.2.png b/docs/screenshots/dashboard-light-v3.0.2.png new file mode 100644 index 0000000..21ee7ba Binary files /dev/null and b/docs/screenshots/dashboard-light-v3.0.2.png differ diff --git a/docs/screenshots/file-manager-dark-v3.0.1.png b/docs/screenshots/file-manager-dark-v3.0.1.png deleted file mode 100644 index f1782b0..0000000 Binary files a/docs/screenshots/file-manager-dark-v3.0.1.png and /dev/null differ diff --git a/docs/screenshots/file-manager-dark-v3.0.2.png b/docs/screenshots/file-manager-dark-v3.0.2.png new file mode 100644 index 0000000..edfe180 Binary files /dev/null and b/docs/screenshots/file-manager-dark-v3.0.2.png differ diff --git a/docs/screenshots/settings-dark-v3.0.1.png b/docs/screenshots/settings-dark-v3.0.1.png deleted file mode 100644 index 7b733ee..0000000 Binary files a/docs/screenshots/settings-dark-v3.0.1.png and /dev/null differ diff --git a/docs/screenshots/settings-dark-v3.0.2.png b/docs/screenshots/settings-dark-v3.0.2.png new file mode 100644 index 0000000..6e9cb59 Binary files /dev/null and b/docs/screenshots/settings-dark-v3.0.2.png differ diff --git a/openadb/core/acbridge_p2p.py b/openadb/core/acbridge_p2p.py index 3ec34d0..5f3ab8d 100644 --- a/openadb/core/acbridge_p2p.py +++ b/openadb/core/acbridge_p2p.py @@ -12,7 +12,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field from pathlib import Path -from typing import BinaryIO, Callable, Iterable +from typing import Callable, Iterable from openadb.core.acbridge import ACBridgeClient from openadb.core.path_utils import ensure_dir, shell_quote @@ -35,9 +35,10 @@ P2P_BUFFER_SIZE = 1024 * 1024 P2P_MAX_ENTRIES = 100_000 P2P_CONNECT_ATTEMPT_TIMEOUT = 0.5 -P2P_BOOTSTRAP_REQUEST_PREFIX = "p2p_request_" -P2P_BOOTSTRAP_STATUS_PREFIX = "p2p_status_" -P2P_BOOTSTRAP_CANCEL_PREFIX = "p2p_cancel_" +P2P_CONTROL_MAGIC = "OPENADB_P2P_2" +P2P_CONTROL_ACCEPTED = "ACCEPTED" +P2P_CONTROL_SOCKET_PREFIX = "openadb_p2p_control_" +P2P_CONTROL_MAX_LINE_BYTES = 65_536 class P2PTransferError(RuntimeError): @@ -107,6 +108,121 @@ def close(self) -> None: self._closed = True +class _P2PControlChannel: + """Private ADB-forwarded bootstrap/status channel for one P2P session.""" + + def __init__(self, sock: socket.socket, forward_port: int) -> None: + self._socket = sock + self.forward_port = int(forward_port) + self._buffer = bytearray() + self._send_lock = threading.Lock() + self._closed = False + self._socket.settimeout(0.25) + + def write_payload( + self, + payload: bytes, + cancel_event=None, + *, + deadline: float | None = None, + ) -> None: + view = memoryview(payload) + sent = 0 + write_deadline = ( + float(deadline) if deadline is not None else time.monotonic() + 5.0 + ) + with self._send_lock: + while sent < len(view): + if cancel_event is not None and cancel_event.is_set(): + raise P2PTransferError("P2P transfer cancelled by user.") + if self._closed: + raise EOFError("ACBridge closed the P2P control channel") + try: + count = self._socket.send(view[sent:]) + except socket.timeout: + if time.monotonic() >= write_deadline: + raise TimeoutError( + "ACBridge P2P control channel write timed out" + ) from None + continue + if count <= 0: + raise EOFError("ACBridge closed the P2P control channel") + sent += count + + def read_line( + self, + *, + deadline: float, + cancel_event=None, + allow_timeout: bool = False, + ) -> str: + while True: + newline = self._buffer.find(b"\n") + if newline >= 0: + if newline > P2P_CONTROL_MAX_LINE_BYTES: + raise P2PTransferError( + "ACBridge returned oversized control metadata." + ) + raw = bytes(self._buffer[:newline]) + del self._buffer[: newline + 1] + if raw.endswith(b"\r"): + raw = raw[:-1] + return raw.decode("utf-8", errors="replace") + if len(self._buffer) > P2P_CONTROL_MAX_LINE_BYTES: + raise P2PTransferError("ACBridge returned oversized control metadata.") + if cancel_event is not None and cancel_event.is_set(): + raise P2PTransferError("P2P transfer cancelled by user.") + if time.monotonic() >= deadline: + if allow_timeout: + return "" + raise TimeoutError("ACBridge P2P control channel timed out") + if self._closed: + raise EOFError("ACBridge closed the P2P control channel") + try: + chunk = self._socket.recv(8192) + except socket.timeout: + continue + if not chunk: + raise EOFError("ACBridge closed the P2P control channel") + self._buffer.extend(chunk) + if ( + len(self._buffer) > P2P_CONTROL_MAX_LINE_BYTES + and b"\n" not in self._buffer + ): + raise P2PTransferError( + "ACBridge returned oversized control metadata." + ) + + def send_command(self, command: str) -> bool: + if command not in {"CANCEL", "CLOSE"}: + raise ValueError("Unsupported P2P control command") + try: + self.write_payload((command + "\n").encode("ascii")) + except (OSError, EOFError, P2PTransferError): + return False + return True + + def close(self) -> None: + if self._closed: + return + self._closed = True + try: + self._socket.shutdown(socket.SHUT_RDWR) + except OSError: + pass + try: + self._socket.close() + except OSError: + pass + + +@dataclass(slots=True) +class _P2PControlHandle: + forward_port: int + channel: _P2PControlChannel | None = None + service_started: bool = False + + @dataclass(slots=True, frozen=True) class P2PEntry: source: Path | None @@ -138,10 +254,10 @@ class P2PTransferResult: class ACBridgeP2PClient: """Send PC files directly over the LAN into ACBridge's SAF grant. - ADB remains the control plane: it installs the bundled bridge, streams a - secret-bearing request into ACBridge's private files directory, starts the - foreground service with a public correlation id, and reads authenticated - READY metadata in memory. File bytes never pass through ADB. + ADB remains the control plane: it installs the bundled bridge and forwards + one localhost TCP port to a per-request ACBridge abstract socket. Bootstrap + secrets and authenticated READY metadata stay in that in-memory channel; + file bytes never pass through ADB. """ SERVICE = f"{ACBridgeClient.PACKAGE}/.P2PTransferService" @@ -156,6 +272,8 @@ def __init__( Path(temp_folder).expanduser() if temp_folder is not None else None ) self._session_prepare_lock = threading.Lock() + self._control_handles_lock = threading.Lock() + self._control_handles: dict[str, _P2PControlHandle] = {} def upload( self, @@ -623,96 +741,79 @@ def _prepare_session_locked( ) # The request id is a public correlation locator. Authentication does - # not depend on it: the one-shot bootstrap secret remains inside the - # request payload and is never placed in argv or a filename. - request_id = uuid.uuid4().hex + # not depend on it: the one-shot bootstrap secret only traverses the + # private ADB-forwarded control socket and never appears in argv. + request_id = self._new_request_id() bootstrap_secret = secrets.token_hex(32) - # Port 0 lets Android choose an actually free ephemeral port. The - # authenticated READY response carries the selected value back. port = 0 timeout_seconds = max(30, min(600, int(timeout_seconds))) - remote_request = self._remote_request_path(request_id) - remote_cancel = self._remote_cancel_path(request_id) request_text = ( - f"OPENADB_P2P_2\n{port}\n{timeout_seconds}\n{destination}\n" + f"{P2P_CONTROL_MAGIC}\n{port}\n{timeout_seconds}\n{destination}\n" f"{bootstrap_secret}\n" ) - remote_bootstrap_started = False - service_started = False + handle: _P2PControlHandle | None = None remote_terminal_status = False try: self._check_cancelled(cancel_event) - remote_bootstrap_started = True - with self._private_adb_log("prepare request", request_id): - prepare_command = ( - "mkdir -p files && " - f"rm -f {shell_quote(remote_request)} {shell_quote(remote_cancel)}" - ) - prepared = self.adb.run_shell( - f"run-as {shell_quote(ACBridgeClient.PACKAGE)} " - f"sh -c {shell_quote(prepare_command)}", - timeout=15, - cancel_event=cancel_event, + remote_control_spec = ( + f"localabstract:{P2P_CONTROL_SOCKET_PREFIX}{request_id}" + ) + with self._private_adb_log("create control forward", request_id): + try: + forwarded = self.adb.run_raw( + [ + "forward", + "tcp:0", + remote_control_spec, + ], + timeout=15, + cancel_event=cancel_event, + ) + except Exception as exc: + self._remove_untracked_control_forward_safely( + remote_control_spec, + request_id, + ) + raise P2PTransferError( + _redact_exact(str(exc), request_id, bootstrap_secret) + or "Could not create the private ACBridge P2P control tunnel." + ) from None + if not forwarded.success: + self._remove_untracked_control_forward_safely( + remote_control_spec, + request_id, ) - self._check_cancelled(cancel_event) - if not prepared.success: - detail = prepared.stderr or prepared.status + self._check_cancelled(cancel_event) + detail = forwarded.stderr or forwarded.status raise P2PTransferError( _redact_exact(detail, request_id, bootstrap_secret) - or "Could not prepare the ACBridge P2P request folder." + or "Could not create the private ACBridge P2P control tunnel." ) - self._remove_status_file(request_id, cancel_event=cancel_event) - self._check_cancelled(cancel_event) - - def write_request(stream: BinaryIO) -> None: - stream.write(request_text.encode("utf-8")) - stream.flush() - - # `adb shell` reconstructs multiple argv items as one remote shell - # command. Passing run-as/sh/-c/redirection as separate items lets - # Android's outer shell consume `>` before run-as is entered. Keep - # the complete nested command in the single argument after - # `shell`, so the request is written inside ACBridge's private - # files directory rather than the shell user's working directory. - write_script = f"cat > {shell_quote(remote_request)}" - remote_write_command = ( - f"run-as {shell_quote(ACBridgeClient.PACKAGE)} " - f"sh -c {shell_quote(write_script)}" - ) try: - with self._private_adb_log( - "write request", + forward_port = _parse_forward_port(forwarded.stdout) + except P2PTransferError: + self._remove_untracked_control_forward_safely( + remote_control_spec, request_id, - bootstrap_secret, - ): - pushed = self.adb.run_raw_with_input_stream( - ["shell", remote_write_command], - input_writer=write_request, - timeout=30, - cancel_event=cancel_event, - ) - except Exception as exc: - detail = _redact_exact(str(exc), request_id, bootstrap_secret) - raise P2PTransferError( - detail or "Could not pass the P2P request to ACBridge." - ) from None - self._check_cancelled(cancel_event) - if not pushed.success: - detail = pushed.stderr or pushed.status - raise P2PTransferError( - _redact_exact(detail, request_id, bootstrap_secret) - or "Could not pass the P2P request to ACBridge." ) + raise + handle = _P2PControlHandle(forward_port=forward_port) + with self._control_handles_lock: + if request_id in self._control_handles: + raise P2PTransferError( + "ACBridge generated a duplicate P2P request identifier." + ) + self._control_handles[request_id] = handle + # From this point every exit path can remove the exact forward. self._check_cancelled(cancel_event) with self._private_adb_log("start service", request_id): started = self.adb.run_shell( - f"am start-foreground-service -n {shell_quote(self.SERVICE)} " - f"--es request_id {shell_quote(request_id)}", + self._service_control_command("request_id", request_id), timeout=20, cancel_event=cancel_event, ) - service_started = bool(started.success) + handle.service_started = bool(started.success) self._check_cancelled(cancel_event) if not started.success: detail = started.stderr or started.status @@ -720,32 +821,45 @@ def write_request(stream: BinaryIO) -> None: _redact_exact(detail, request_id, bootstrap_secret) or "Android refused to start the ACBridge P2P foreground service." ) + handle.channel = self._connect_control_channel( + handle.forward_port, + request_text.encode("utf-8"), + connect_timeout=connect_timeout, + cancel_event=cancel_event, + ) except Exception: - if remote_bootstrap_started: - self._cleanup_session_files_safely( - request_id, - cancel_event=cancel_event, - progress_callback=progress_callback, - signal_cancel=service_started, - ) + self._cleanup_session_files_safely( + request_id, + cancel_event=cancel_event, + progress_callback=progress_callback, + signal_cancel=bool(handle and handle.service_started), + ) raise deadline = time.monotonic() + max(5.0, connect_timeout) permission_requested = False try: + assert handle is not None and handle.channel is not None while time.monotonic() < deadline: self._check_cancelled(cancel_event) - probe = self._status_file_probe(request_id, cancel_event=cancel_event) - if "READY" not in (probe.stdout or ""): - time.sleep(0.2) - continue - pulled, status = self._read_status( + try: + status = handle.channel.read_line( + deadline=deadline, + cancel_event=cancel_event, + ) + except TimeoutError: + break + except EOFError as exc: + raise P2PTransferError( + "ACBridge closed the private P2P control channel before the session was ready." + ) from exc + status = _redact_exact( + status, request_id, - cancel_event=cancel_event, + bootstrap_secret, ) self._check_cancelled(cancel_event) - if not pulled.success or not status: - time.sleep(0.2) + if not status: continue if status.startswith("ERROR\t"): remote_terminal_status = True @@ -806,34 +920,150 @@ def write_request(stream: BinaryIO) -> None: expected_port=port, bootstrap_secret=bootstrap_secret, ) - self._remove_status_file( - request_id, - cancel_event=cancel_event, - ) self._check_cancelled(cancel_event) return P2PSession( addresses[0], ready_port, token, expires_at_ms, request_id ) - time.sleep(0.2) except Exception: self._cleanup_session_files_safely( request_id, cancel_event=cancel_event, progress_callback=progress_callback, - signal_cancel=service_started and not remote_terminal_status, + signal_cancel=bool( + handle.service_started and not remote_terminal_status + ), ) raise self._cleanup_session_files_safely( request_id, cancel_event=cancel_event, progress_callback=progress_callback, - signal_cancel=service_started, + signal_cancel=handle.service_started, ) raise P2PTransferError( "ACBridge did not open the one-time P2P session before timeout. " - "Check the TV notification and local-network connectivity." + "Check the Android notification and Platform Tools connection." + ) + + def _connect_control_channel( + self, + forward_port: int, + request_payload: bytes, + *, + connect_timeout: float, + cancel_event=None, + ) -> _P2PControlChannel: + deadline = time.monotonic() + max(5.0, float(connect_timeout)) + last_error: BaseException | None = None + while time.monotonic() < deadline: + self._check_cancelled(cancel_event) + remaining = deadline - time.monotonic() + sock: socket.socket | None = None + channel: _P2PControlChannel | None = None + try: + sock = socket.create_connection( + ("127.0.0.1", forward_port), + timeout=min(P2P_CONNECT_ATTEMPT_TIMEOUT, remaining), + ) + channel = _P2PControlChannel(sock, forward_port) + self._check_cancelled(cancel_event) + channel.write_payload( + request_payload, + cancel_event=cancel_event, + deadline=deadline, + ) + acknowledgement = channel.read_line( + deadline=deadline, + cancel_event=cancel_event, + ) + if acknowledgement == P2P_CONTROL_ACCEPTED: + return channel + if acknowledgement.startswith("ERROR\t"): + raise P2PTransferError( + acknowledgement.split("\t", 1)[1].strip() + or "ACBridge rejected the private P2P control channel." + ) + raise P2PTransferError( + "ACBridge returned an invalid P2P control acknowledgement." + ) + except P2PTransferError: + if channel is not None: + channel.close() + elif sock is not None: + sock.close() + raise + except (OSError, EOFError) as exc: + last_error = exc + if channel is not None: + channel.close() + elif sock is not None: + sock.close() + self._check_cancelled(cancel_event) + time.sleep(0.1) + detail = f" Details: {last_error}" if last_error else "" + raise P2PTransferError( + "Could not connect to ACBridge through the private ADB control tunnel." + + detail + ) + + def _new_request_id(self) -> str: + """Return a public correlation id not used by another live session.""" + + for _attempt in range(8): + request_id = uuid.uuid4().hex + with self._control_handles_lock: + if request_id not in self._control_handles: + return request_id + raise P2PTransferError("Could not allocate a unique P2P request identifier.") + + def _service_control_command(self, extra_name: str, request_id: str) -> str: + """Build an API-aware explicit service command for Android 6+.""" + + if extra_name not in {"request_id", "cancel_id"}: + raise ValueError("Unsupported ACBridge P2P service extra") + service = shell_quote(self.SERVICE) + extra = f"--es {extra_name} {shell_quote(request_id)}" + foreground = f"am start-foreground-service -n {service} {extra}" + legacy = f"am startservice -n {service} {extra}" + return ( + "sdk=$(getprop ro.build.version.sdk); " + "if [ \"$sdk\" -ge 26 ] 2>/dev/null; then " + f"{foreground}; else {legacy}; fi" ) + def _remove_untracked_control_forward_safely( + self, + remote_spec: str, + request_id: str, + ) -> None: + """Best-effort cleanup when adb created a forward but hid its port.""" + + try: + with self._private_adb_log("locate malformed control forward", request_id): + listed = self.adb.run_raw(["forward", "--list"], timeout=5) + if not listed.success: + return + local_specs: list[str] = [] + for line in str(listed.stdout or "").splitlines(): + fields = line.split() + if len(fields) >= 3 and fields[-1] == remote_spec: + local_spec = fields[-2] + if local_spec.startswith("tcp:"): + local_specs.append(local_spec) + for local_spec in dict.fromkeys(local_specs): + with self._private_adb_log( + "remove malformed control forward", + request_id, + ): + self.adb.run_raw( + ["forward", "--remove", local_spec], + timeout=5, + ) + except Exception: + # Preserve the authoritative malformed-forward error. The remote + # spec contains a fresh request UUID, so no broad cleanup is safe. + pass + def _connect( self, session: P2PSession, connect_timeout: float, cancel_event=None ) -> socket.socket: @@ -889,24 +1119,54 @@ def _cleanup_session_files( ) -> None: if not session_id or len(session_id) != 32: return + with self._control_handles_lock: + handle = self._control_handles.pop(session_id, None) + if handle is None: + return + cancelled = cancel_event is not None and cancel_event.is_set() - relative = self._status_relative_path(session_id) - cancel_command = ( - f"touch {shell_quote(self._remote_cancel_path(session_id))}" - if signal_cancel - else f"rm -f {shell_quote(self._remote_cancel_path(session_id))}" - ) - cleanup_command = ( - f"{cancel_command}; " - f"rm -f {shell_quote(self._remote_request_path(session_id))} " - f"{shell_quote(relative)}" - ) - with self._private_adb_log("clean session", session_id): - self.adb.run_shell( - f"run-as {shell_quote(ACBridgeClient.PACKAGE)} " - f"sh -c {shell_quote(cleanup_command)} >/dev/null 2>&1 || true", - timeout=1.5 if cancelled else 10, + timeout = 1.5 if cancelled else 10 + cleanup_error = "" + if handle.channel is not None: + handle.channel.send_command( + "CANCEL" if signal_cancel else "CLOSE" ) + handle.channel.close() + + if signal_cancel and handle.service_started: + try: + with self._private_adb_log("cancel session", session_id): + cancelled_result = self.adb.run_shell( + self._service_control_command("cancel_id", session_id), + timeout=timeout, + ) + if not cancelled_result.success: + cleanup_error = ( + cancelled_result.stderr + or cancelled_result.status + or "Android did not acknowledge P2P cancellation." + ) + except Exception as exc: + cleanup_error = str(exc) + + try: + with self._private_adb_log("remove control forward", session_id): + removed = self.adb.run_raw( + ["forward", "--remove", f"tcp:{handle.forward_port}"], + timeout=timeout, + ) + if not removed.success and not cleanup_error: + cleanup_error = ( + removed.stderr + or removed.status + or "Could not remove the ACBridge P2P control tunnel." + ) + except Exception as exc: + if not cleanup_error: + cleanup_error = str(exc) + + if cleanup_error: + raise P2PTransferError(_redact_exact(cleanup_error, session_id)) def _cleanup_session_files_safely( self, @@ -956,27 +1216,26 @@ def _fetch_session_error_safely(self, session_id: str, cancel_event=None) -> str def _fetch_session_error(self, session_id: str, cancel_event=None) -> str: if cancel_event is not None and cancel_event.is_set(): return "" + with self._control_handles_lock: + handle = self._control_handles.get(session_id) + if handle is None or handle.channel is None: + return "" deadline = time.monotonic() + 1.0 while time.monotonic() < deadline: if cancel_event is not None and cancel_event.is_set(): return "" - probe = self._status_file_probe( - session_id, + status = handle.channel.read_line( + deadline=deadline, cancel_event=cancel_event, + allow_timeout=True, ) - if cancel_event is not None and cancel_event.is_set(): + if not status: return "" - if "READY" in (probe.stdout or ""): - pulled, status = self._read_status( + if status.startswith("ERROR\t"): + return _redact_exact( + status.split("\t", 1)[1].strip(), session_id, - cancel_event=cancel_event, ) - if cancel_event is not None and cancel_event.is_set(): - return "" - if pulled.success and status.startswith("ERROR\t"): - return status.split("\t", 1)[1].strip() - return "" - time.sleep(0.1) return "" def _local_temp_dir(self) -> Path: @@ -1056,54 +1315,6 @@ def _emit(callback: Callable[[dict], None] | None, update: dict) -> None: if callback is not None: callback(update) - @staticmethod - def _remote_request_path(request_id: str) -> str: - return f"files/{P2P_BOOTSTRAP_REQUEST_PREFIX}{request_id}.txt" - - @staticmethod - def _status_relative_path(request_id: str) -> str: - return f"files/{P2P_BOOTSTRAP_STATUS_PREFIX}{request_id}.txt" - - @staticmethod - def _remote_cancel_path(request_id: str) -> str: - return f"files/{P2P_BOOTSTRAP_CANCEL_PREFIX}{request_id}" - - def _status_file_probe(self, session_id: str, cancel_event=None): - relative = self._status_relative_path(session_id) - command = f"test -f {shell_quote(relative)} && echo READY" - with self._private_adb_log("check session status", session_id): - return self.adb.run_shell( - f"run-as {shell_quote(ACBridgeClient.PACKAGE)} sh -c {shell_quote(command)}", - timeout=8, - cancel_event=cancel_event, - ) - - def _read_status(self, session_id: str, cancel_event=None): - """Return bootstrap metadata in memory so session keys never touch PC disk.""" - - with self._private_adb_log("read session status", session_id): - result, payload = self.adb.run_raw_binary_output( - [ - "exec-out", - "run-as", - ACBridgeClient.PACKAGE, - "cat", - self._status_relative_path(session_id), - ], - timeout=20, - cancel_event=cancel_event, - ) - return result, payload.decode("utf-8", errors="replace").strip() - - def _remove_status_file(self, session_id: str, cancel_event=None) -> None: - relative = self._status_relative_path(session_id) - with self._private_adb_log("remove session status", session_id): - self.adb.run_shell( - f"run-as {shell_quote(ACBridgeClient.PACKAGE)} rm -f {shell_quote(relative)}", - timeout=8, - cancel_event=cancel_event, - ) - def _private_adb_log( self, operation: str, @@ -1295,6 +1506,19 @@ def _friendly_network_error(exc: BaseException) -> str: ) +def _parse_forward_port(output: object) -> int: + for line in reversed(str(output or "").splitlines()): + value = line.strip() + if not value.isdecimal(): + continue + port = int(value) + if 1 <= port <= 65_535: + return port + raise P2PTransferError( + "Platform Tools did not return a valid local port for the ACBridge control tunnel." + ) + + def _redact_exact(value: object, *secrets_to_remove: str) -> str: """Remove caller-known one-shot values without mutating unrelated data.""" diff --git a/openadb/resources/acbridge/ACBridge-3.0.1.apk b/openadb/resources/acbridge/ACBridge-3.0.1.apk deleted file mode 100644 index d958cbd..0000000 Binary files a/openadb/resources/acbridge/ACBridge-3.0.1.apk and /dev/null differ diff --git a/openadb/resources/acbridge/ACBridge-3.0.2.apk b/openadb/resources/acbridge/ACBridge-3.0.2.apk new file mode 100644 index 0000000..48821c0 Binary files /dev/null and b/openadb/resources/acbridge/ACBridge-3.0.2.apk differ diff --git a/openadb/resources/acbridge/ACBridge.apk b/openadb/resources/acbridge/ACBridge.apk index d958cbd..48821c0 100644 Binary files a/openadb/resources/acbridge/ACBridge.apk and b/openadb/resources/acbridge/ACBridge.apk differ diff --git a/openadb/resources/acbridge/AndroidManifest.xml b/openadb/resources/acbridge/AndroidManifest.xml index 88f1240..c47621e 100644 --- a/openadb/resources/acbridge/AndroidManifest.xml +++ b/openadb/resources/acbridge/AndroidManifest.xml @@ -1,7 +1,7 @@ + android:versionCode="30201" + android:versionName="3.0.2"> @@ -21,7 +21,6 @@ diff --git a/openadb/resources/acbridge/src/com/communism420/acbridge/BuildInfo.java b/openadb/resources/acbridge/src/com/communism420/acbridge/BuildInfo.java index 87b6336..d570b5d 100644 --- a/openadb/resources/acbridge/src/com/communism420/acbridge/BuildInfo.java +++ b/openadb/resources/acbridge/src/com/communism420/acbridge/BuildInfo.java @@ -2,8 +2,8 @@ /** Release metadata mirrored by the manifest and verified by the desktop test suite. */ final class BuildInfo { - static final String VERSION_NAME = "3.0.1"; - static final long VERSION_CODE = 30101L; + static final String VERSION_NAME = "3.0.2"; + static final long VERSION_CODE = 30201L; private BuildInfo() { } diff --git a/openadb/resources/acbridge/src/com/communism420/acbridge/P2PTransferService.java b/openadb/resources/acbridge/src/com/communism420/acbridge/P2PTransferService.java index aa6c1a0..00ce293 100644 --- a/openadb/resources/acbridge/src/com/communism420/acbridge/P2PTransferService.java +++ b/openadb/resources/acbridge/src/com/communism420/acbridge/P2PTransferService.java @@ -9,6 +9,9 @@ import android.content.SharedPreferences; import android.content.UriPermission; import android.database.Cursor; +import android.net.Credentials; +import android.net.LocalServerSocket; +import android.net.LocalSocket; import android.net.Uri; import android.os.Build; import android.os.Environment; @@ -19,14 +22,13 @@ import java.io.BufferedInputStream; import java.io.BufferedOutputStream; -import java.io.BufferedReader; import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; -import java.io.FileReader; +import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.InetSocketAddress; @@ -43,6 +45,7 @@ import java.util.Locale; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import javax.crypto.Mac; @@ -51,12 +54,13 @@ /** * One-shot LAN receiver controlled by ADB and scoped by an existing SAF grant. * - * OpenADB streams a one-shot request into this app's private files directory - * through ADB run-as. Its public request id only correlates control files; a - * separate secret authenticates the READY metadata, and a fresh token - * authenticates the complete request transcript and terminal response of the - * only accepted TCP connection. The server stops after that connection, a - * per-request cancellation signal, or a short timeout. + * OpenADB forwards a request-specific abstract LocalSocket through ADB. Only + * Android's shell/root UID may connect, and the secret-bearing bootstrap is + * streamed over that socket instead of being placed in an argv or filesystem + * hand-off. A separate secret authenticates the READY metadata, and a fresh + * token authenticates the complete request transcript and terminal response + * of the only accepted TCP connection. The server stops after that connection, + * an authenticated control-channel cancellation, or a bounded timeout. */ public final class P2PTransferService extends Service { private static final byte[] MAGIC = "OADBP2P2".getBytes(StandardCharsets.US_ASCII); @@ -72,12 +76,14 @@ public final class P2PTransferService extends Service { private static final int MAX_TEXT_BYTES = 65536; private static final int DEFAULT_TIMEOUT_SECONDS = 120; private static final int STORAGE_PERMISSION_TIMEOUT_SECONDS = 660; + private static final int CONTROL_ACCEPT_TIMEOUT_SECONDS = 45; + private static final int CONTROL_BOOTSTRAP_READ_TIMEOUT_MILLIS = 5000; + private static final int ROOT_UID = 0; + private static final int ADB_SHELL_UID = 2000; private static final int COPY_BUFFER_SIZE = 1024 * 1024; private static final String PREFS = "openadb_bridge"; private static final String PREF_LAST_TREE_URI = "last_tree_uri"; - private static final String BOOTSTRAP_REQUEST_PREFIX = "p2p_request_"; - private static final String BOOTSTRAP_STATUS_PREFIX = "p2p_status_"; - private static final String BOOTSTRAP_CANCEL_PREFIX = "p2p_cancel_"; + private static final String CONTROL_SOCKET_PREFIX = "openadb_p2p_control_"; private final Set activeServers = Collections.newSetFromMap( new ConcurrentHashMap() @@ -85,6 +91,8 @@ public final class P2PTransferService extends Service { private final Set activeSockets = Collections.newSetFromMap( new ConcurrentHashMap() ); + private final ConcurrentHashMap activeControlSessions = + new ConcurrentHashMap(); private final AtomicInteger activeSessionCount = new AtomicInteger(); private final AtomicInteger latestStartId = new AtomicInteger(); private final Object sessionLifecycleLock = new Object(); @@ -99,31 +107,39 @@ public void onCreate() { @Override public int onStartCommand(Intent intent, int flags, int startId) { - stopping = false; startForeground(NOTIFICATION_ID, notification("Waiting for OpenADB P2P transfer")); String requestId = intent == null ? "" : safeRequestId(intent.getStringExtra("request_id")); - if (requestId.length() == 0) { - boolean stopImmediately; - synchronized (sessionLifecycleLock) { - latestStartId.set(startId); - stopImmediately = activeSessionCount.get() == 0; - } - if (stopImmediately && stopSelfResult(startId)) { - stopForeground(true); + String cancelId = intent == null ? "" : safeRequestId(intent.getStringExtra("cancel_id")); + synchronized (sessionLifecycleLock) { + latestStartId.set(startId); + } + if (cancelId.length() > 0) { + ControlSession target = activeControlSessions.get(cancelId); + if (target != null) { + target.cancel(); } + stopIfIdle(startId); + return START_NOT_STICKY; + } + if (requestId.length() == 0) { + stopIfIdle(startId); + return START_NOT_STICKY; + } + final ControlSession controlSession = new ControlSession(requestId); + if (activeControlSessions.putIfAbsent(requestId, controlSession) != null) { return START_NOT_STICKY; } - final String serviceRequestId = requestId; synchronized (sessionLifecycleLock) { - latestStartId.set(startId); activeSessionCount.incrementAndGet(); } Thread worker = new Thread(new Runnable() { @Override public void run() { try { - runSession(serviceRequestId); + runSession(controlSession); } finally { + controlSession.finish(); + activeControlSessions.remove(controlSession.requestId, controlSession); synchronized (sessionLifecycleLock) { if (activeSessionCount.decrementAndGet() == 0) { if (stopSelfResult(latestStartId.get())) { @@ -133,14 +149,27 @@ public void run() { } } } - }, "OpenADB-P2P-" + requestId.substring(0, 8)); + }, "OpenADB-P2P-" + controlSession.requestId.substring(0, 8)); worker.start(); return START_NOT_STICKY; } + private void stopIfIdle(int startId) { + boolean stopImmediately; + synchronized (sessionLifecycleLock) { + stopImmediately = activeSessionCount.get() == 0; + } + if (stopImmediately && stopSelfResult(startId)) { + stopForeground(true); + } + } + @Override public void onDestroy() { stopping = true; + for (ControlSession session : activeControlSessions.values()) { + session.cancel(); + } for (Socket socket : activeSockets) { closeQuietly(socket); } @@ -157,30 +186,38 @@ public IBinder onBind(Intent intent) { return null; } - private void runSession(String requestId) { - File privateDir = getFilesDir(); - File requestFile = new File(privateDir, BOOTSTRAP_REQUEST_PREFIX + requestId + ".txt"); - File statusFile = new File(privateDir, BOOTSTRAP_STATUS_PREFIX + requestId + ".txt"); - File cancelFile = new File(privateDir, BOOTSTRAP_CANCEL_PREFIX + requestId); - SessionRequest request; - try { - request = readAndConsumeRequest(requestFile); - } catch (Throwable exc) { - if (!cancelFile.isFile()) { - writeStatus(statusFile, "ERROR\tInvalid or missing ADB bootstrap request: " + cleanMessage(exc)); - } - cancelFile.delete(); - return; - } - - String token = randomHex(32); + private void runSession(ControlSession controlSession) { + Thread controlMonitor = null; ServerSocket server = null; Socket socket = null; try { + LocalSocket controlSocket = acceptControlSocket(controlSession); + controlSession.attachControlSocket(controlSocket); + controlSocket.setSoTimeout(CONTROL_BOOTSTRAP_READ_TIMEOUT_MILLIS); + InputStream controlInput = controlSocket.getInputStream(); + controlSession.attachControlOutput(controlSocket.getOutputStream()); + + SessionRequest request = readControlRequest(controlInput); + throwIfCancelled(controlSession); + // ADB may accept the host-side forwarded TCP socket before adbd + // has reached this abstract endpoint. Confirm that ACBridge itself + // parsed the complete secret-bearing request before the desktop + // treats the control channel as established. + writeControlLine(controlSession, "ACCEPTED"); + // Android LocalSocket reports an idle SO_TIMEOUT as a plain + // IOException on some releases. The monitor must remain blocking + // while SAF permission or the LAN peer is pending; cancellation + // closes the request-specific socket and unblocks this read. + controlSocket.setSoTimeout(0); + controlMonitor = startControlMonitor(controlSession, controlInput); + // Do not open a socket or accept file bytes until Android has // granted ACBridge access to the requested storage tree. - waitForDestinationAccess(request.destination, statusFile, cancelFile); + waitForDestinationAccess(request.destination, controlSession); + throwIfCancelled(controlSession); + String token = randomHex(32); server = new ServerSocket(); + controlSession.attachDataServer(server); activeServers.add(server); server.setReuseAddress(true); server.bind(new InetSocketAddress(request.port), 1); @@ -190,31 +227,37 @@ private void runSession(String requestId) { String bootstrapProof = hexString( hmac(hexBytes(request.bootstrapSecret), ready.getBytes(StandardCharsets.UTF_8)) ); - writeStatus(statusFile, ready + "\t" + bootstrapProof); + controlSession.readyPublished = true; + try { + writeControlLine(controlSession, ready + "\t" + bootstrapProof); + } catch (Exception exc) { + controlSession.readyPublished = false; + throw exc; + } long acceptDeadline = System.currentTimeMillis() + request.timeoutSeconds * 1000L; while (socket == null && System.currentTimeMillis() < acceptDeadline) { - throwIfCancelled(cancelFile); + throwIfCancelled(controlSession); try { socket = server.accept(); } catch (SocketTimeoutException waiting) { - // Poll the per-request marker so Cancel remains prompt. + // Poll the request-specific control state so Cancel remains prompt. } } if (socket == null) { throw new SocketTimeoutException("P2P client did not connect before timeout"); } + controlSession.attachDataSocket(socket); activeSockets.add(socket); socket.setSoTimeout(request.timeoutSeconds * 1000); - throwIfCancelled(cancelFile); + throwIfCancelled(controlSession); updateNotification("Receiving files from OpenADB"); handleUpload(socket, token, request.destination); } catch (Exception exc) { - if (cancelFile.isFile()) { - statusFile.delete(); - } else { - writeStatus(statusFile, "ERROR\t" + cleanMessage(exc)); + if (!controlSession.isCancelled()) { + writeControlErrorSafely(controlSession, cleanMessage(exc)); } } finally { + controlSession.completed = true; closeQuietly(socket); closeQuietly(server); if (socket != null) { @@ -223,19 +266,19 @@ private void runSession(String requestId) { if (server != null) { activeServers.remove(server); } - cancelFile.delete(); + controlSession.closeControlResources(); + joinControlMonitor(controlMonitor); } } private void waitForDestinationAccess( String destination, - File statusFile, - File cancelFile + ControlSession controlSession ) throws Exception { long deadline = System.currentTimeMillis() + STORAGE_PERMISSION_TIMEOUT_SECONDS * 1000L; boolean permissionPublished = false; while (!stopping && System.currentTimeMillis() < deadline) { - throwIfCancelled(cancelFile); + throwIfCancelled(controlSession); try { if (hasAllFilesAccess()) { resolveDirectDestinationDirectory(destination); @@ -245,7 +288,7 @@ private void waitForDestinationAccess( return; } catch (SecurityException permissionMissing) { if (!permissionPublished) { - writeStatus(statusFile, "PERMISSION_REQUIRED\t" + destination); + writeControlLine(controlSession, "PERMISSION_REQUIRED\t" + destination); updateNotification("Grant MicroSD/USB access on the Android device"); permissionPublished = true; } @@ -261,12 +304,247 @@ private void waitForDestinationAccess( ); } - private void throwIfCancelled(File cancelFile) throws InterruptedException { - if (stopping || cancelFile.isFile()) { + private void throwIfCancelled(ControlSession controlSession) throws InterruptedException { + if (stopping || controlSession.isCancelled()) { throw new InterruptedException("P2P transfer was cancelled"); } } + private LocalSocket acceptControlSocket(final ControlSession controlSession) throws Exception { + throwIfCancelled(controlSession); + final LocalServerSocket listener = new LocalServerSocket( + CONTROL_SOCKET_PREFIX + controlSession.requestId + ); + controlSession.attachControlListener(listener); + throwIfCancelled(controlSession); + + final AtomicBoolean acceptFinished = new AtomicBoolean(false); + final long acceptDeadline = System.currentTimeMillis() + + CONTROL_ACCEPT_TIMEOUT_SECONDS * 1000L; + Thread acceptWatchdog = new Thread(new Runnable() { + @Override + public void run() { + try { + while (!acceptFinished.get() && !controlSession.isCancelled()) { + long remaining = acceptDeadline - System.currentTimeMillis(); + if (remaining <= 0L) { + controlSession.controlAcceptExpired = true; + closeQuietly(listener); + return; + } + Thread.sleep(Math.min(250L, remaining)); + } + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + }, "OpenADB-P2P-control-timeout-" + controlSession.requestId.substring(0, 8)); + acceptWatchdog.start(); + + try { + while (true) { + throwIfCancelled(controlSession); + LocalSocket candidate; + try { + candidate = listener.accept(); + } catch (IOException exc) { + if (controlSession.controlAcceptExpired) { + throw new SocketTimeoutException( + "ADB did not open the one-time P2P control channel before timeout" + ); + } + throwIfCancelled(controlSession); + throw exc; + } + Credentials credentials = null; + try { + credentials = candidate.getPeerCredentials(); + } catch (IOException ignored) { + // Missing credentials never downgrade the shell/root-only policy. + } + int uid = credentials == null ? -1 : credentials.getUid(); + if (uid == ADB_SHELL_UID || uid == ROOT_UID) { + return candidate; + } + try { + writeRawControlLine( + candidate.getOutputStream(), + "ERROR\tP2P control access is restricted to the ADB shell" + ); + } catch (Throwable ignored) { + } finally { + closeQuietly(candidate); + } + } + } finally { + acceptFinished.set(true); + acceptWatchdog.interrupt(); + closeQuietly(listener); + joinThreadQuietly(acceptWatchdog, 1000L); + } + } + + private SessionRequest readControlRequest(InputStream input) throws Exception { + String version = readControlLine(input, 64); + String portText = readControlLine(input, 16); + String timeoutText = readControlLine(input, 16); + String destination = readControlLine(input, MAX_TEXT_BYTES); + String bootstrapSecret = readControlLine(input, 128); + if (!"OPENADB_P2P_2".equals(version) + || portText == null + || timeoutText == null + || destination == null + || bootstrapSecret == null + || bootstrapSecret.length() != 64) { + throw new IOException("invalid control request format"); + } + hexBytes(bootstrapSecret); + int port = Integer.parseInt(portText); + int timeout = Integer.parseInt(timeoutText); + if (port != 0 && (port < 1024 || port > 65535)) { + throw new IllegalArgumentException("invalid TCP port"); + } + timeout = Math.max(30, Math.min(600, timeout)); + destination = normalizeStoragePath(destination); + if (!destination.startsWith("/storage/") || destination.indexOf('\0') >= 0) { + throw new IllegalArgumentException( + "destination is not an Android shared storage path" + ); + } + return new SessionRequest(port, timeout, destination, bootstrapSecret); + } + + private Thread startControlMonitor( + final ControlSession controlSession, + final InputStream input + ) { + Thread monitor = new Thread(new Runnable() { + @Override + public void run() { + while (!controlSession.completed && !controlSession.isCancelled()) { + String command; + try { + command = readControlLine(input, 64); + } catch (Throwable exc) { + if (!controlSession.completed && !controlSession.controlCloseRequested) { + handleControlDisconnect(controlSession); + } + return; + } + if (command == null) { + if (!controlSession.completed && !controlSession.controlCloseRequested) { + handleControlDisconnect(controlSession); + } + return; + } + if ("CANCEL".equals(command)) { + controlSession.cancel(); + return; + } + if ("CLOSE".equals(command)) { + if (controlSession.readyPublished) { + controlSession.controlCloseRequested = true; + } else { + controlSession.cancel(); + } + return; + } + writeControlErrorSafely( + controlSession, + "Unsupported P2P control command" + ); + controlSession.cancel(); + return; + } + } + }, "OpenADB-P2P-control-" + controlSession.requestId.substring(0, 8)); + controlSession.controlMonitor = monitor; + monitor.start(); + return monitor; + } + + private void handleControlDisconnect(ControlSession controlSession) { + if (controlSession.readyPublished) { + // The authenticated LAN transfer can finish if Wireless ADB drops + // after READY. Its own socket timeout still bounds the orphan. + controlSession.controlCloseRequested = true; + } else { + controlSession.cancel(); + } + } + + private String readControlLine(InputStream input, int maxBytes) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + while (true) { + int value = input.read(); + if (value < 0) { + if (buffer.size() == 0) { + return null; + } + break; + } + if (value == '\n') { + break; + } + if (buffer.size() >= maxBytes) { + throw new IOException("oversized P2P control line"); + } + buffer.write(value); + } + byte[] bytes = buffer.toByteArray(); + int length = bytes.length; + if (length > 0 && bytes[length - 1] == '\r') { + length--; + } + return new String(bytes, 0, length, StandardCharsets.UTF_8); + } + + private void writeControlLine(ControlSession controlSession, String text) throws IOException { + OutputStream output = controlSession.controlOutput; + if (output == null) { + throw new IOException("P2P control channel is unavailable"); + } + synchronized (controlSession.outputLock) { + writeRawControlLine(output, text); + } + } + + private void writeRawControlLine(OutputStream output, String text) throws IOException { + String safe = text == null ? "" : text.replace('\n', ' ').replace('\r', ' '); + byte[] payload = (safe + "\n").getBytes(StandardCharsets.UTF_8); + if (payload.length > MAX_TEXT_BYTES + 256) { + throw new IOException("oversized P2P control response"); + } + output.write(payload); + output.flush(); + } + + private void writeControlErrorSafely(ControlSession controlSession, String message) { + if (!controlSession.errorPublished.compareAndSet(false, true)) { + return; + } + try { + writeControlLine(controlSession, "ERROR\t" + cleanMessageText(message)); + } catch (Throwable ignored) { + } + } + + private void joinControlMonitor(Thread monitor) { + if (monitor == null || monitor == Thread.currentThread()) { + return; + } + monitor.interrupt(); + joinThreadQuietly(monitor, 1000L); + } + + private void joinThreadQuietly(Thread thread, long timeoutMillis) { + try { + thread.join(timeoutMillis); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + } + private void handleUpload(Socket socket, String token, String destination) throws Exception { DataInputStream input = new DataInputStream(new BufferedInputStream(socket.getInputStream(), COPY_BUFFER_SIZE)); DataOutputStream output = new DataOutputStream(new BufferedOutputStream(socket.getOutputStream(), 65536)); @@ -781,44 +1059,6 @@ private boolean containsUri(List uris, Uri candidate) { return false; } - private SessionRequest readAndConsumeRequest(File requestFile) throws Exception { - if (!requestFile.isFile()) { - throw new java.io.IOException("request file not found"); - } - BufferedReader reader = null; - try { - reader = new BufferedReader(new FileReader(requestFile)); - String version = reader.readLine(); - String portText = reader.readLine(); - String timeoutText = reader.readLine(); - String destination = reader.readLine(); - String bootstrapSecret = reader.readLine(); - if (!"OPENADB_P2P_2".equals(version) - || destination == null - || bootstrapSecret == null - || bootstrapSecret.length() != 64) { - throw new java.io.IOException("invalid request format"); - } - hexBytes(bootstrapSecret); - int port = Integer.parseInt(portText); - int timeout = Integer.parseInt(timeoutText); - if (port != 0 && (port < 1024 || port > 65535)) { - throw new IllegalArgumentException("invalid TCP port"); - } - timeout = Math.max(30, Math.min(600, timeout)); - destination = normalizeStoragePath(destination); - if (!destination.startsWith("/storage/") || destination.indexOf('\0') >= 0) { - throw new IllegalArgumentException("destination is not an Android shared storage path"); - } - return new SessionRequest(port, timeout, destination, bootstrapSecret); - } finally { - if (reader != null) { - reader.close(); - } - requestFile.delete(); - } - } - private String validateRelativePath(String path) { if (path == null || path.length() == 0 || path.length() > MAX_TEXT_BYTES) { throw new IllegalArgumentException("Invalid empty or oversized relative path"); @@ -1065,33 +1305,18 @@ private byte[] hmac(byte[] key, byte[] data) throws Exception { return newHmac(key).doFinal(data); } - private void writeStatus(File target, String text) { - File temp = new File(target.getParentFile(), target.getName() + ".tmp"); - OutputStream output = null; - try { - output = new FileOutputStream(temp); - output.write(text.getBytes(StandardCharsets.UTF_8)); - output.flush(); - output.close(); - output = null; - if (target.exists()) { - target.delete(); - } - if (!temp.renameTo(target)) { - throw new java.io.IOException("could not publish status"); - } - } catch (Throwable ignored) { - } finally { - closeQuietly(output); - temp.delete(); - } - } - private String cleanMessage(Throwable exc) { String message = exc.getMessage(); if (message == null || message.trim().length() == 0) { message = exc.getClass().getSimpleName(); } + return cleanMessageText(message); + } + + private String cleanMessageText(String message) { + if (message == null || message.trim().length() == 0) { + return "P2P control operation failed"; + } return message.replace('\t', ' ').replace('\n', ' ').replace('\r', ' ').trim(); } @@ -1138,6 +1363,120 @@ private void closeQuietly(java.io.Closeable closeable) { } } + private final class ControlSession { + final String requestId; + final Object outputLock = new Object(); + final AtomicBoolean cancelled = new AtomicBoolean(false); + final AtomicBoolean errorPublished = new AtomicBoolean(false); + final Object resourceLock = new Object(); + volatile LocalServerSocket controlListener; + volatile LocalSocket controlSocket; + volatile OutputStream controlOutput; + volatile ServerSocket dataServer; + volatile Socket dataSocket; + volatile Thread controlMonitor; + volatile boolean controlAcceptExpired; + volatile boolean controlCloseRequested; + volatile boolean readyPublished; + volatile boolean completed; + + ControlSession(String requestId) { + this.requestId = requestId; + } + + boolean isCancelled() { + return cancelled.get(); + } + + void attachControlListener(LocalServerSocket listener) { + synchronized (resourceLock) { + if (cancelled.get() || completed) { + closeQuietly(listener); + return; + } + controlListener = listener; + } + } + + void attachControlSocket(LocalSocket socket) { + synchronized (resourceLock) { + if (cancelled.get() || completed) { + closeQuietly(socket); + return; + } + controlSocket = socket; + } + } + + void attachControlOutput(OutputStream output) { + synchronized (resourceLock) { + if (cancelled.get() || completed) { + closeQuietly(output); + return; + } + controlOutput = output; + } + } + + void attachDataServer(ServerSocket server) { + synchronized (resourceLock) { + if (cancelled.get() || completed) { + closeQuietly(server); + return; + } + dataServer = server; + } + } + + void attachDataSocket(Socket socket) { + synchronized (resourceLock) { + if (cancelled.get() || completed) { + closeQuietly(socket); + return; + } + dataSocket = socket; + } + } + + void cancel() { + cancelled.set(true); + synchronized (resourceLock) { + closeResourcesLocked(true); + } + } + + void closeControlResources() { + synchronized (resourceLock) { + closeQuietly(controlSocket); + closeQuietly(controlListener); + controlSocket = null; + controlListener = null; + controlOutput = null; + } + } + + void finish() { + completed = true; + synchronized (resourceLock) { + closeResourcesLocked(true); + } + } + + private void closeResourcesLocked(boolean includeData) { + closeQuietly(controlSocket); + closeQuietly(controlListener); + controlSocket = null; + controlListener = null; + controlOutput = null; + if (includeData) { + closeQuietly(dataSocket); + closeQuietly(dataServer); + dataSocket = null; + dataServer = null; + } + } + } + private static final class SessionRequest { final int port; final int timeoutSeconds; diff --git a/openadb/version.py b/openadb/version.py index 885aa7d..301c068 100644 --- a/openadb/version.py +++ b/openadb/version.py @@ -3,15 +3,15 @@ from __future__ import annotations -VERSION = "3.0.1" -VERSION_PARTS = (3, 0, 1) +VERSION = "3.0.2" +VERSION_PARTS = (3, 0, 2) # Android versionCode policy: major * 10_000 + minor * 1_000 + patch * 100 # + build. This preserves the established sequence 20004 (2.0.0 build 4), -# 20101 (2.0.1 build 1), and 30002 (3.0.0 build 2). Patch release 3.0.1 starts -# its helper build sequence at 1 and therefore uses versionCode 30101. +# 20101 (2.0.1 build 1), and 30002 (3.0.0 build 2). Patch release 3.0.2 starts +# its helper build sequence at 1 and therefore uses versionCode 30201. ACBRIDGE_BUILD = 1 -ACBRIDGE_VERSION_CODE = 30101 +ACBRIDGE_VERSION_CODE = 30201 ACBRIDGE_PACKAGE = "com.communism420.acbridge" ACBRIDGE_APK_FILENAME = f"ACBridge-{VERSION}.apk" ACBRIDGE_SIGNER_SHA256 = "57d0f9154b24fa9e5aebf40e4e4b8f83c42b281e08e22d4cc34ee842c030ecd7" diff --git a/tests/test_acbridge_p2p.py b/tests/test_acbridge_p2p.py index b18e115..da66855 100644 --- a/tests/test_acbridge_p2p.py +++ b/tests/test_acbridge_p2p.py @@ -2,15 +2,13 @@ import hashlib import hmac -import shlex import socket import struct import tempfile import threading import time import unittest -from contextlib import contextmanager, nullcontext -from io import BytesIO +from contextlib import contextmanager from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -18,6 +16,8 @@ from openadb.core.acbridge import ACBridgeClient, ACBridgeExportState from openadb.core.acbridge_p2p import ( P2P_AUTH_TAG_SIZE, + P2P_CONTROL_MAX_LINE_BYTES, + P2P_CONTROL_SOCKET_PREFIX, P2P_ENTRY_CONTROL_CONTEXT, P2P_MAGIC, P2P_REQUEST_TRANSCRIPT_CONTEXT, @@ -27,6 +27,8 @@ P2PTransferResult, P2PSession, P2PTransferError, + _P2PControlChannel, + _parse_forward_port, collect_p2p_entries, ) @@ -202,20 +204,44 @@ def test_session_repr_never_exposes_authentication_material(self) -> None: self.assertIn("192.0.2.10", rendered) def test_android_bootstrap_separates_public_locator_from_secret(self) -> None: - source = (Path(__file__).resolve().parents[1] / Path( - "openadb/resources/acbridge/src/com/communism420/acbridge/" + root = Path(__file__).resolve().parents[1] + source = ( + root + / "openadb/resources/acbridge/src/com/communism420/acbridge/" "P2PTransferService.java" - )).read_text(encoding="utf-8") + ).read_text(encoding="utf-8") + manifest = ( + root / "openadb/resources/acbridge/AndroidManifest.xml" + ).read_text(encoding="utf-8") self.assertIn('getStringExtra("request_id")', source) - self.assertIn('BOOTSTRAP_REQUEST_PREFIX = "p2p_request_"', source) - self.assertIn('BOOTSTRAP_STATUS_PREFIX = "p2p_status_"', source) - self.assertIn('BOOTSTRAP_CANCEL_PREFIX = "p2p_cancel_"', source) + self.assertIn('getStringExtra("cancel_id")', source) + self.assertIn('CONTROL_SOCKET_PREFIX = "openadb_p2p_control_"', source) + self.assertIn("new LocalServerSocket(", source) + self.assertIn("candidate.getPeerCredentials()", source) + self.assertIn("uid == ADB_SHELL_UID || uid == ROOT_UID", source) + self.assertIn("CONTROL_ACCEPT_TIMEOUT_SECONDS", source) self.assertIn('"OPENADB_P2P_2"', source) self.assertIn('"OADBP2P2"', source) - self.assertIn("File privateDir = getFilesDir()", source) self.assertIn("request.bootstrapSecret", source) - self.assertIn("throwIfCancelled(cancelFile)", source) + self.assertIn("throwIfCancelled(controlSession)", source) + self.assertIn('"PERMISSION_REQUIRED\\t"', source) + self.assertIn('writeControlLine(controlSession, "ACCEPTED")', source) + accepted = source.index('writeControlLine(controlSession, "ACCEPTED")') + blocking_monitor = source.index("controlSocket.setSoTimeout(0)", accepted) + self.assertLess(accepted, blocking_monitor) + self.assertLess( + blocking_monitor, + source.index("startControlMonitor(controlSession", blocking_monitor), + ) + monitor_source = source[ + source.index("private Thread startControlMonitor(") : source.index( + "private void handleControlDisconnect(" + ) + ] + self.assertNotIn("SocketTimeoutException", monitor_source) + self.assertIn('"CANCEL".equals(command)', source) + self.assertIn('"CLOSE".equals(command)', source) self.assertIn("REQUEST_TRANSCRIPT_CONTEXT", source) self.assertIn("ENTRY_CONTROL_CONTEXT", source) self.assertIn("writeAuthenticatedResponse", source) @@ -223,9 +249,15 @@ def test_android_bootstrap_separates_public_locator_from_secret(self) -> None: self.assertIn("server.getLocalPort()", source) self.assertIn("latestStartId.set(startId)", source) self.assertIn("stopSelfResult(startId)", source) - self.assertNotIn('writeStatus(statusFile, "DONE', source) + self.assertNotIn("BOOTSTRAP_REQUEST_PREFIX", source) + self.assertNotIn("BOOTSTRAP_STATUS_PREFIX", source) + self.assertNotIn("BOOTSTRAP_CANCEL_PREFIX", source) + self.assertNotIn("getFilesDir()", source) + self.assertNotIn("run-as", source) self.assertNotIn('getStringExtra("session")', source) self.assertNotIn("appOutputDir()", source) + self.assertIn('android:permission="android.permission.DUMP"', manifest) + self.assertNotIn("android:debuggable=", manifest) control_verified = source.index( "P2P entry metadata authentication failed" ) @@ -278,24 +310,116 @@ def test_ready_metadata_requires_the_bootstrap_hmac(self) -> None: bootstrap_secret=bootstrap_secret, ) - def test_public_request_ids_isolate_parallel_bootstrap_files(self) -> None: + def test_public_request_ids_isolate_parallel_control_sockets(self) -> None: first = "01" * 16 second = "02" * 16 bootstrap_secret = "ff" * 32 - paths = { - ACBridgeP2PClient._remote_request_path(first), - ACBridgeP2PClient._remote_request_path(second), - ACBridgeP2PClient._status_relative_path(first), - ACBridgeP2PClient._status_relative_path(second), - ACBridgeP2PClient._remote_cancel_path(first), - ACBridgeP2PClient._remote_cancel_path(second), + socket_names = { + P2P_CONTROL_SOCKET_PREFIX + first, + P2P_CONTROL_SOCKET_PREFIX + second, } - self.assertEqual(len(paths), 6) - self.assertTrue(all(bootstrap_secret not in path for path in paths)) - self.assertTrue(all(path.startswith("files/p2p_") for path in paths)) - self.assertTrue(all("/sdcard/" not in path for path in paths)) + self.assertEqual(len(socket_names), 2) + self.assertTrue( + all(bootstrap_secret not in socket_name for socket_name in socket_names) + ) + self.assertTrue( + all( + socket_name.startswith("openadb_p2p_control_") + for socket_name in socket_names + ) + ) + self.assertTrue(all(len(socket_name) < 108 for socket_name in socket_names)) + + def test_control_channel_rejects_an_oversized_status_line(self) -> None: + class OneChunkSocket: + def __init__(self) -> None: + self.chunk = b"x" * (P2P_CONTROL_MAX_LINE_BYTES + 1) + b"\n" + + def settimeout(self, _timeout): + return None + + def recv(self, _size): + chunk, self.chunk = self.chunk, b"" + return chunk + + channel = _P2PControlChannel(OneChunkSocket(), 43120) # type: ignore[arg-type] + + with self.assertRaisesRegex(P2PTransferError, "oversized"): + channel.read_line(deadline=time.monotonic() + 1) + + def test_control_forward_port_parser_rejects_unusable_output(self) -> None: + self.assertEqual(_parse_forward_port("43123\r\n"), 43123) + for value in ("", "not-a-port", "0", "65536", "43123 extra"): + with self.subTest(value=value): + with self.assertRaises(P2PTransferError): + _parse_forward_port(value) + + def test_control_connection_retries_until_android_listener_is_ready(self) -> None: + attempts: list[tuple[tuple[str, int], float]] = [] + sent = bytearray() + acknowledgement_reads: list[int] = [] + + class RecordingSocket: + def __init__(self) -> None: + self.response = b"ACCEPTED\n" + + def settimeout(self, _timeout): + return None + + def send(self, payload): + sent.extend(payload) + return len(payload) + + def recv(self, _size): + acknowledgement_reads.append(_size) + response, self.response = self.response, b"" + return response + + def shutdown(self, _how): + return None + + def close(self): + return None + + outcomes = iter( + [ + OSError("listener not registered yet"), + OSError("listener still starting"), + RecordingSocket(), + ] + ) + + def connect(address, timeout): + attempts.append((address, timeout)) + outcome = next(outcomes) + if isinstance(outcome, OSError): + raise outcome + return outcome + + client = ACBridgeP2PClient( + SimpleNamespace(adb=SimpleNamespace(), settings=SimpleNamespace()) + ) + payload = b"OPENADB_P2P_2\n0\n120\n/storage/emulated/0\nsecret\n" + with ( + patch( + "openadb.core.acbridge_p2p.socket.create_connection", + side_effect=connect, + ), + patch("openadb.core.acbridge_p2p.time.sleep"), + ): + channel = client._connect_control_channel( + 43123, + payload, + connect_timeout=5, + ) + channel.close() + + self.assertEqual(len(attempts), 3) + self.assertTrue(all(address == ("127.0.0.1", 43123) for address, _ in attempts)) + self.assertEqual(bytes(sent), payload) + self.assertEqual(len(acknowledgement_reads), 1) def test_bridge_control_plane_uses_captured_temp_folder(self) -> None: with tempfile.TemporaryDirectory() as temp: @@ -497,7 +621,7 @@ def test_adb_bootstrap_never_places_session_key_on_command_line(self) -> None: bootstrap_secret = "b2" * 32 commands: list[str] = [] pushed_text: list[str] = [] - stream_args: list[list[str]] = [] + control_commands: list[str] = [] granted_paths: list[str] = [] status_reads = 0 @@ -519,10 +643,7 @@ def scoped_log_command(self, command, *, sensitive_values=()): def record_private_command(command: str) -> None: commands.append(command) - if ( - "p2p_request_" in command - or "p2p_status_" in command - ): + if request_id in command: self.assertGreater(runner.active, 0) class FakeAdb: @@ -534,52 +655,54 @@ def device_ip_addresses(self, cancel_event=None) -> list[str]: def run_shell(self, command: str, timeout=None, cancel_event=None): record_private_command(command) - stdout = "READY\n" if "test -f" in command else "" return SimpleNamespace( - success=True, stdout=stdout, stderr="", status="" + success=True, stdout="", stderr="", status="" ) - def run_raw_with_input_stream( - self, - args, - *, - input_writer, - timeout=None, - cancel_event=None, - ): - stream = BytesIO() - input_writer(stream) - stream_args.append(list(args)) + def run_raw(self, args, timeout=None, cancel_event=None): record_private_command(" ".join(args)) - pushed_text.append(stream.getvalue().decode("utf-8")) + stdout = "43123\n" if args[:2] == ["forward", "tcp:0"] else "" return SimpleNamespace( - success=True, stdout="", stderr="", status="" + success=True, stdout=stdout, stderr="", status="" ) - def run_raw_binary_output( - self, - args, - timeout=None, - cancel_event=None, - ): + class FakeControlChannel: + forward_port = 43123 + + def read_line(self, **_kwargs): nonlocal status_reads - record_private_command(" ".join(args)) status_reads += 1 - status = "PERMISSION_REQUIRED\t/storage/ABCD-1234/Movies" - if status_reads > 1: - ready = f"READY\t42042\t{'c' * 64}\t{int(time.time() * 1000) + 60_000}" - proof = hmac.new( - bytes.fromhex(bootstrap_secret), - ready.encode("utf-8"), - hashlib.sha256, - ).hexdigest() - status = f"{ready}\t{proof}" - return ( - SimpleNamespace( - success=True, stdout="", stderr="", status="" - ), - status.encode("utf-8"), + if status_reads == 1: + return "PERMISSION_REQUIRED\t/storage/ABCD-1234/Movies" + ready = ( + f"READY\t42042\t{'c' * 64}\t" + f"{int(time.time() * 1000) + 60_000}" ) + proof = hmac.new( + bytes.fromhex(bootstrap_secret), + ready.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{ready}\t{proof}" + + def send_command(self, command): + control_commands.append(command) + return True + + def close(self): + return None + + class TestClient(ACBridgeP2PClient): + def _connect_control_channel( + self, + forward_port, + request_payload, + **_kwargs, + ): + if forward_port != 43123: + raise AssertionError("unexpected forwarded control port") + pushed_text.append(request_payload.decode("utf-8")) + return FakeControlChannel() cancel_event = threading.Event() @@ -601,7 +724,7 @@ def grant_storage_access(path: str, timeout: int, cancel_event=None): ), grant_storage_access=grant_storage_access, ) - client = ACBridgeP2PClient(bridge) + client = TestClient(bridge) updates: list[dict] = [] with ( patch( @@ -620,40 +743,46 @@ def grant_storage_access(path: str, timeout: int, cancel_event=None): cancel_event=cancel_event, progress_callback=updates.append, ) + client._cleanup_session_files( + session.session_id, + signal_cancel=False, + ) self.assertEqual(session.port, 42042) self.assertEqual(session.token, "c" * 64) - self.assertEqual(len(stream_args), 1) - self.assertEqual(stream_args[0][0], "shell") - self.assertEqual(len(stream_args[0]), 2) self.assertEqual( - shlex.split(stream_args[0][1]), + pushed_text[0].splitlines(), [ - "run-as", - ACBridgeClient.PACKAGE, - "sh", - "-c", - f"cat > 'files/p2p_request_{request_id}.txt'", + "OPENADB_P2P_2", + "0", + "120", + "/storage/ABCD-1234/Movies", + bootstrap_secret, ], ) - self.assertNotIn(bootstrap_secret, stream_args[0][1]) - self.assertTrue(pushed_text[0].startswith("OPENADB_P2P_2\n0\n")) - self.assertIn(bootstrap_secret, pushed_text[0]) self.assertNotIn("c" * 64, "\n".join(commands)) self.assertNotIn(bootstrap_secret, "\n".join(commands)) + self.assertNotIn("/storage/ABCD-1234/Movies", "\n".join(commands)) + self.assertNotIn("run-as", "\n".join(commands)) self.assertNotIn(bootstrap_secret, repr(session)) self.assertNotIn(bootstrap_secret, repr(updates)) self.assertEqual(session.session_id, request_id) self.assertNotIn("--es session", "\n".join(commands)) self.assertIn("--es request_id", "\n".join(commands)) - self.assertNotIn(ACBridgeClient.REMOTE_APP_DIR, "\n".join(commands)) - self.assertTrue( - all( - "run-as" in command and ACBridgeClient.PACKAGE in command - for command in commands - if "p2p_request_" in command or "p2p_status_" in command - ) + self.assertIn( + f"forward tcp:0 localabstract:{P2P_CONTROL_SOCKET_PREFIX}{request_id}", + commands, ) + self.assertIn("forward --remove tcp:43123", commands) + self.assertEqual(control_commands, ["CLOSE"]) + service_commands = [ + command for command in commands if "--es request_id" in command + ] + self.assertEqual(len(service_commands), 1) + self.assertIn("getprop ro.build.version.sdk", service_commands[0]) + self.assertIn("startservice", service_commands[0]) + self.assertIn("start-foreground-service", service_commands[0]) + self.assertNotIn(ACBridgeClient.REMOTE_APP_DIR, "\n".join(commands)) self.assertTrue(runner.scopes) self.assertTrue( all(session.session_id in values for _display, values in runner.scopes) @@ -664,7 +793,7 @@ def grant_storage_access(path: str, timeout: int, cancel_event=None): for display, _values in runner.scopes ) ) - self.assertTrue( + self.assertFalse( any(bootstrap_secret in values for _display, values in runner.scopes) ) self.assertEqual(granted_paths, ["/storage/ABCD-1234/Movies"]) @@ -675,12 +804,16 @@ def grant_storage_access(path: str, timeout: int, cancel_event=None): ) ) - def test_bootstrap_write_failures_redact_the_exact_one_shot_secret(self) -> None: + def test_control_forward_failures_redact_one_shot_values(self) -> None: request_id = "d4" * 16 bootstrap_secret = "e5" * 32 - for failure_mode in ("stdout", "result", "exception"): + for failure_mode in ("result", "exception"): with self.subTest(failure_mode=failure_mode): scopes: list[tuple[str, ...]] = [] + raw_calls: list[list[str]] = [] + remote_spec = ( + f"localabstract:{P2P_CONTROL_SOCKET_PREFIX}{request_id}" + ) class RecordingRunner: @contextmanager @@ -696,29 +829,31 @@ def device_ip_addresses(cancel_event=None): return ["192.0.2.10"] @staticmethod - def run_shell(_command, **_kwargs): - return SimpleNamespace( - success=True, - stdout="", - stderr="", - status="", - ) - - @staticmethod - def run_raw_with_input_stream(_args, **_kwargs): + def run_raw(args, **_kwargs): + raw_calls.append(list(args)) + if args == ["forward", "--list"]: + return SimpleNamespace( + success=True, + stdout=f"device tcp:43129 {remote_spec}\n", + stderr="", + status="", + ) + if args == ["forward", "--remove", "tcp:43129"]: + return SimpleNamespace( + success=True, + stdout="", + stderr="", + status="", + ) if failure_mode == "exception": raise RuntimeError( - f"writer echoed one-shot {bootstrap_secret}" + f"forward rejected {request_id} {bootstrap_secret}" ) return SimpleNamespace( success=False, - stdout=f"echo {bootstrap_secret}", - stderr="", - status=( - "" - if failure_mode == "stdout" - else f"write rejected {bootstrap_secret}" - ), + stdout="", + stderr=f"forward rejected {request_id} {bootstrap_secret}", + status="Command failed with exit code 1", ) bridge = SimpleNamespace( @@ -745,34 +880,33 @@ def run_raw_with_input_stream(_args, **_kwargs): ) self.assertNotIn(bootstrap_secret, str(raised.exception)) - if failure_mode != "stdout": - self.assertIn("[private]", str(raised.exception)) - self.assertTrue(any(bootstrap_secret in values for values in scopes)) + self.assertNotIn(request_id, str(raised.exception)) + self.assertIn("[private]", str(raised.exception)) + self.assertTrue(any(request_id in values for values in scopes)) + self.assertFalse(any(bootstrap_secret in values for values in scopes)) + self.assertIn(["forward", "--list"], raw_calls) + self.assertIn( + ["forward", "--remove", "tcp:43129"], + raw_calls, + ) - def test_bootstrap_write_failure_prefers_actionable_stderr(self) -> None: + def test_control_forward_failure_prefers_actionable_stderr(self) -> None: request_id = "d5" * 16 bootstrap_secret = "e6" * 32 class FakeAdb: - runner = SimpleNamespace(scoped_log_command=lambda *_args, **_kwargs: nullcontext()) + runner = SimpleNamespace() @staticmethod def device_ip_addresses(cancel_event=None): return ["192.0.2.10"] @staticmethod - def run_shell(_command, **_kwargs): - return SimpleNamespace(success=True, stdout="", stderr="", status="") - - @staticmethod - def run_raw_with_input_stream(_args, **_kwargs): + def run_raw(_args, **_kwargs): return SimpleNamespace( success=False, stdout="", - stderr=( - "/system/bin/sh: can't create " - f"files/p2p_request_{request_id}.txt: No such file or directory" - ), + stderr="error: cannot bind the requested local control listener", status="Command failed with exit code 1", ) @@ -800,12 +934,216 @@ def run_raw_with_input_stream(_args, **_kwargs): ) message = str(raised.exception) - self.assertIn("can't create files/p2p_request_[private].txt", message) - self.assertIn("No such file or directory", message) + self.assertIn("cannot bind the requested local control listener", message) self.assertNotIn("Command failed with exit code 1", message) self.assertNotIn(request_id, message) self.assertNotIn(bootstrap_secret, message) + def test_cancel_immediately_after_forward_removes_exact_port(self) -> None: + request_id = "d7" * 16 + cancel_event = threading.Event() + raw_calls: list[list[str]] = [] + + class FakeAdb: + @staticmethod + def device_ip_addresses(cancel_event=None): + return ["192.0.2.10"] + + @staticmethod + def run_raw(args, **_kwargs): + raw_calls.append(list(args)) + if args[:2] == ["forward", "tcp:0"]: + cancel_event.set() + return SimpleNamespace( + success=True, + stdout="43128\n", + stderr="", + status="", + ) + return SimpleNamespace( + success=True, + stdout="", + stderr="", + status="", + ) + + @staticmethod + def run_shell(*_args, **_kwargs): + raise AssertionError("cancelled bootstrap must not start ACBridge") + + bridge = SimpleNamespace( + adb=FakeAdb(), + settings=SimpleNamespace(temp_folder=Path("unused")), + ensure_installed=lambda **_kwargs: (True, "ready"), + ) + client = ACBridgeP2PClient(bridge) + with ( + patch( + "openadb.core.acbridge_p2p.uuid.uuid4", + return_value=SimpleNamespace(hex=request_id), + ), + self.assertRaisesRegex(P2PTransferError, "cancelled"), + ): + client._prepare_session( + "/storage/emulated/0/Download", + timeout_seconds=120, + connect_timeout=2, + cancel_event=cancel_event, + ) + + self.assertEqual( + raw_calls, + [ + [ + "forward", + "tcp:0", + f"localabstract:{P2P_CONTROL_SOCKET_PREFIX}{request_id}", + ], + ["forward", "--remove", "tcp:43128"], + ], + ) + self.assertNotIn(request_id, client._control_handles) + + def test_malformed_forward_output_removes_only_matching_forward(self) -> None: + request_id = "d8" * 16 + remote_spec = f"localabstract:{P2P_CONTROL_SOCKET_PREFIX}{request_id}" + unrelated_spec = f"localabstract:{P2P_CONTROL_SOCKET_PREFIX}{'f9' * 16}" + raw_calls: list[list[str]] = [] + + class FakeAdb: + @staticmethod + def device_ip_addresses(cancel_event=None): + return ["192.0.2.10"] + + @staticmethod + def run_raw(args, **_kwargs): + raw_calls.append(list(args)) + if args[:2] == ["forward", "tcp:0"]: + return SimpleNamespace( + success=True, + stdout="unexpected dynamic-forward response", + stderr="", + status="", + ) + if args == ["forward", "--list"]: + return SimpleNamespace( + success=True, + stdout=( + f"device-1 tcp:43129 {unrelated_spec}\n" + f"device-1 tcp:43130 {remote_spec}\n" + ), + stderr="", + status="", + ) + return SimpleNamespace( + success=True, + stdout="", + stderr="", + status="", + ) + + @staticmethod + def run_shell(*_args, **_kwargs): + raise AssertionError("malformed forward must not start ACBridge") + + bridge = SimpleNamespace( + adb=FakeAdb(), + settings=SimpleNamespace(temp_folder=Path("unused")), + ensure_installed=lambda **_kwargs: (True, "ready"), + ) + client = ACBridgeP2PClient(bridge) + with ( + patch( + "openadb.core.acbridge_p2p.uuid.uuid4", + return_value=SimpleNamespace(hex=request_id), + ), + self.assertRaisesRegex( + P2PTransferError, + "did not return a valid local port", + ), + ): + client._prepare_session( + "/storage/emulated/0/Download", + timeout_seconds=120, + connect_timeout=2, + ) + + self.assertEqual( + raw_calls, + [ + ["forward", "tcp:0", remote_spec], + ["forward", "--list"], + ["forward", "--remove", "tcp:43130"], + ], + ) + self.assertNotIn(["forward", "--remove", "tcp:43129"], raw_calls) + self.assertNotIn(request_id, client._control_handles) + + def test_control_status_error_redacts_request_id_and_secret(self) -> None: + request_id = "d6" * 16 + bootstrap_secret = "e7" * 32 + + class FakeAdb: + @staticmethod + def device_ip_addresses(cancel_event=None): + return ["192.0.2.10"] + + @staticmethod + def run_raw(args, **_kwargs): + stdout = "43127\n" if args[:2] == ["forward", "tcp:0"] else "" + return SimpleNamespace( + success=True, + stdout=stdout, + stderr="", + status="", + ) + + @staticmethod + def run_shell(_command, **_kwargs): + return SimpleNamespace(success=True, stdout="", stderr="", status="") + + class ErrorChannel: + def read_line(self, **_kwargs): + return f"ERROR\tbootstrap rejected {request_id} {bootstrap_secret}" + + class TestClient(ACBridgeP2PClient): + def _connect_control_channel(self, *_args, **_kwargs): + return ErrorChannel() + + def _cleanup_session_files( + self, session_id, cancel_event=None, *, signal_cancel=True + ): + return None + + bridge = SimpleNamespace( + adb=FakeAdb(), + settings=SimpleNamespace(temp_folder=Path("unused")), + ensure_installed=lambda **_kwargs: (True, "ready"), + ) + client = TestClient(bridge) + with ( + patch( + "openadb.core.acbridge_p2p.uuid.uuid4", + return_value=SimpleNamespace(hex=request_id), + ), + patch( + "openadb.core.acbridge_p2p.secrets.token_hex", + return_value=bootstrap_secret, + ), + self.assertRaises(P2PTransferError) as raised, + ): + client._prepare_session( + "/storage/emulated/0/Download", + timeout_seconds=120, + connect_timeout=2, + ) + + message = str(raised.exception) + self.assertIn("bootstrap rejected", message) + self.assertIn("[private]", message) + self.assertNotIn(request_id, message) + self.assertNotIn(bootstrap_secret, message) + def test_collects_files_and_empty_directories_without_flattening(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) / "Media" @@ -1190,6 +1528,20 @@ class FakeAdb: def device_ip_addresses(self, cancel_event=None): return ["192.0.2.10"] + def run_raw(self, args, **_kwargs): + self.assert_forward(args) + return SimpleNamespace( + success=True, + status="", + stdout="43126\n", + stderr="", + ) + + @staticmethod + def assert_forward(args): + if args[:2] != ["forward", "tcp:0"]: + raise AssertionError(f"unexpected ADB command: {args!r}") + def run_shell(self, _command, **_kwargs): return SimpleNamespace( success=False, @@ -1260,42 +1612,122 @@ def server() -> None: self.assertLess(time.monotonic() - started, 2.0) - def test_cancelled_session_cleanup_uses_one_short_bounded_adb_call(self) -> None: - calls: list[tuple[str, float]] = [] + def test_cancelled_session_cleanup_closes_one_private_forward(self) -> None: + raw_calls: list[tuple[list[str], float]] = [] + shell_calls: list[str] = [] + channel_commands: list[str] = [] class FakeAdb: def run_shell(self, command, timeout=None): - calls.append((command, timeout)) + shell_calls.append(command) return SimpleNamespace(success=True, stdout="", stderr="", status="") + def run_raw(self, args, timeout=None): + raw_calls.append((list(args), timeout)) + return SimpleNamespace(success=True, stdout="", stderr="", status="") + + class FakeChannel: + def send_command(self, command): + channel_commands.append(command) + return True + + def close(self): + return None + client = ACBridgeP2PClient( SimpleNamespace( adb=FakeAdb(), settings=SimpleNamespace(temp_folder=Path("unused")), ) ) + session_id = "a" * 32 + client._control_handles[session_id] = SimpleNamespace( + forward_port=43123, + channel=FakeChannel(), + service_started=True, + ) cancel = threading.Event() cancel.set() - client._cleanup_session_files("a" * 32, cancel_event=cancel) + client._cleanup_session_files(session_id, cancel_event=cancel) - self.assertEqual(len(calls), 1) - self.assertEqual(calls[0][1], 1.5) - self.assertIn("p2p_request_", calls[0][0]) - self.assertIn("p2p_status_", calls[0][0]) - self.assertIn("p2p_cancel_", calls[0][0]) - self.assertIn("run-as", calls[0][0]) - self.assertIn(ACBridgeClient.PACKAGE, calls[0][0]) - self.assertNotIn(ACBridgeClient.REMOTE_APP_DIR, calls[0][0]) + self.assertEqual(channel_commands, ["CANCEL"]) + self.assertEqual(raw_calls, [(["forward", "--remove", "tcp:43123"], 1.5)]) + self.assertEqual(len(shell_calls), 1) + self.assertIn("--es cancel_id", shell_calls[0]) + self.assertIn(session_id, shell_calls[0]) - def test_parallel_session_cancel_markers_never_cross_request_ids(self) -> None: - commands: list[str] = [] + def test_cleanup_falls_back_to_service_cancel_before_removing_forward( + self, + ) -> None: + operations: list[tuple[str, object]] = [] class FakeAdb: def run_shell(self, command, timeout=None): - commands.append(command) + operations.append(("shell", command)) + return SimpleNamespace(success=True, stdout="", stderr="", status="") + + def run_raw(self, args, timeout=None): + operations.append(("raw", list(args))) return SimpleNamespace(success=True, stdout="", stderr="", status="") + client = ACBridgeP2PClient( + SimpleNamespace( + adb=FakeAdb(), + settings=SimpleNamespace(temp_folder=Path("unused")), + ) + ) + session_id = "04" * 16 + client._control_handles[session_id] = SimpleNamespace( + forward_port=43104, + channel=None, + service_started=True, + ) + + client._cleanup_session_files(session_id) + + self.assertEqual([kind for kind, _value in operations], ["shell", "raw"]) + cancel_command = str(operations[0][1]) + self.assertIn("--es cancel_id", cancel_command) + self.assertIn(session_id, cancel_command) + self.assertIn("getprop ro.build.version.sdk", cancel_command) + self.assertIn("startservice", cancel_command) + self.assertIn("start-foreground-service", cancel_command) + self.assertNotIn("run-as", cancel_command) + self.assertEqual( + operations[1], + ("raw", ["forward", "--remove", "tcp:43104"]), + ) + + def test_parallel_session_control_cleanup_never_crosses_forwards(self) -> None: + removed_forwards: list[str] = [] + cancelled_sessions: list[str] = [] + channel_commands: dict[str, list[str]] = {} + + class FakeAdb: + def run_shell(self, command, timeout=None): + for session_id in (first, second): + if session_id in command: + cancelled_sessions.append(session_id) + break + return SimpleNamespace(success=True, stdout="", stderr="", status="") + + def run_raw(self, args, timeout=None): + removed_forwards.append(args[-1]) + return SimpleNamespace(success=True, stdout="", stderr="", status="") + + class FakeChannel: + def __init__(self, session_id): + self.session_id = session_id + channel_commands[session_id] = [] + + def send_command(self, command): + channel_commands[self.session_id].append(command) + return True + + def close(self): + return None + client = ACBridgeP2PClient( SimpleNamespace( adb=FakeAdb(), @@ -1304,24 +1736,42 @@ def run_shell(self, command, timeout=None): ) first = "01" * 16 second = "02" * 16 + client._control_handles[first] = SimpleNamespace( + forward_port=43101, + channel=FakeChannel(first), + service_started=True, + ) + client._control_handles[second] = SimpleNamespace( + forward_port=43102, + channel=FakeChannel(second), + service_started=True, + ) client._cleanup_session_files(first) client._cleanup_session_files(second) - self.assertEqual(len(commands), 2) - self.assertIn(first, commands[0]) - self.assertNotIn(second, commands[0]) - self.assertIn(second, commands[1]) - self.assertNotIn(first, commands[1]) + self.assertEqual(channel_commands[first], ["CANCEL"]) + self.assertEqual(channel_commands[second], ["CANCEL"]) + self.assertEqual(cancelled_sessions, [first, second]) + self.assertEqual(removed_forwards, ["tcp:43101", "tcp:43102"]) - def test_success_cleanup_removes_control_files_without_orphan_cancel_marker(self) -> None: - commands: list[str] = [] + def test_success_cleanup_closes_channel_without_remote_cancel(self) -> None: + raw_commands: list[list[str]] = [] + channel_commands: list[str] = [] class FakeAdb: - def run_shell(self, command, timeout=None): - commands.append(command) + def run_raw(self, args, timeout=None): + raw_commands.append(list(args)) return SimpleNamespace(success=True, stdout="", stderr="", status="") + class FakeChannel: + def send_command(self, command): + channel_commands.append(command) + return True + + def close(self): + return None + client = ACBridgeP2PClient( SimpleNamespace( adb=FakeAdb(), @@ -1329,14 +1779,22 @@ def run_shell(self, command, timeout=None): ) ) - client._cleanup_session_files("03" * 16, signal_cancel=False) + session_id = "03" * 16 + client._control_handles[session_id] = SimpleNamespace( + forward_port=43103, + channel=FakeChannel(), + service_started=True, + ) - self.assertEqual(len(commands), 1) - self.assertNotIn("touch", commands[0]) - self.assertIn("rm -f", commands[0]) - self.assertIn("p2p_cancel_", commands[0]) + client._cleanup_session_files(session_id, signal_cancel=False) - def test_cancellation_during_status_read_cleans_remote_session(self) -> None: + self.assertEqual(channel_commands, ["CLOSE"]) + self.assertEqual( + raw_commands, + [["forward", "--remove", "tcp:43103"]], + ) + + def test_cancellation_during_control_read_cleans_remote_session(self) -> None: cancel_event = threading.Event() cleaned_sessions: list[str] = [] @@ -1347,19 +1805,18 @@ def device_ip_addresses(self, cancel_event=None): def run_shell(self, _command, **_kwargs): return SimpleNamespace(success=True, stdout="", stderr="", status="") - def run_raw_with_input_stream(self, _args, **_kwargs): - return SimpleNamespace(success=True, stdout="", stderr="", status="") - - class CancelDuringStatusClient(ACBridgeP2PClient): - def _remove_status_file(self, session_id, cancel_event=None) -> None: - return None - - def _status_file_probe(self, session_id, cancel_event=None): - return SimpleNamespace(stdout="READY") + def run_raw(self, args, **_kwargs): + stdout = "43124\n" if args[:2] == ["forward", "tcp:0"] else "" + return SimpleNamespace(success=True, stdout=stdout, stderr="", status="") - def _read_status(self, session_id, cancel_event=None): + class CancelDuringReadChannel: + def read_line(self, **_kwargs): cancel_event.set() - return SimpleNamespace(success=False), "" + return "" + + class CancelDuringStatusClient(ACBridgeP2PClient): + def _connect_control_channel(self, *_args, **_kwargs): + return CancelDuringReadChannel() def _cleanup_session_files( self, session_id, cancel_event=None, *, signal_cancel=True @@ -1475,7 +1932,7 @@ def expires_at_ms(self): self.assertIs(result, connected) - def test_local_status_read_error_still_cleans_remote_session(self) -> None: + def test_local_control_read_error_still_cleans_remote_session(self) -> None: cleaned_sessions: list[str] = [] class FakeAdb: @@ -1485,18 +1942,17 @@ def device_ip_addresses(self, cancel_event=None): def run_shell(self, _command, **_kwargs): return SimpleNamespace(success=True, stdout="", stderr="", status="") - def run_raw_with_input_stream(self, _args, **_kwargs): - return SimpleNamespace(success=True, stdout="", stderr="", status="") - - class FailingStatusClient(ACBridgeP2PClient): - def _remove_status_file(self, session_id, cancel_event=None) -> None: - return None + def run_raw(self, args, **_kwargs): + stdout = "43125\n" if args[:2] == ["forward", "tcp:0"] else "" + return SimpleNamespace(success=True, stdout=stdout, stderr="", status="") - def _status_file_probe(self, session_id, cancel_event=None): - return SimpleNamespace(stdout="READY") + class FailingControlChannel: + def read_line(self, **_kwargs): + raise OSError("temporary control channel unavailable") - def _read_status(self, session_id, cancel_event=None): - raise OSError("temporary status drive unavailable") + class FailingStatusClient(ACBridgeP2PClient): + def _connect_control_channel(self, *_args, **_kwargs): + return FailingControlChannel() def _cleanup_session_files( self, session_id, cancel_event=None, *, signal_cancel=True @@ -1514,7 +1970,7 @@ def _cleanup_session_files( ) client = FailingStatusClient(bridge) - with self.assertRaisesRegex(OSError, "status drive"): + with self.assertRaisesRegex(OSError, "control channel"): client._prepare_session( "/storage/emulated/0/Download", timeout_seconds=120, diff --git a/tests/test_device_lab_smoke.py b/tests/test_device_lab_smoke.py index 8a56c36..869e59f 100644 --- a/tests/test_device_lab_smoke.py +++ b/tests/test_device_lab_smoke.py @@ -165,7 +165,7 @@ def test_no_hardware_is_truthfully_not_run_in_json_and_junit(self) -> None: temporary_reports = tuple(Path(temp).glob(".*.tmp")) self.assertEqual(payload["schema"], REPORT_SCHEMA) - self.assertEqual(payload["product"]["version"], "3.0.1") + self.assertEqual(payload["product"]["version"], "3.0.2") self.assertRegex(payload["product"]["source_commit"], r"^(?:unavailable|[0-9a-f]{40})$") self.assertEqual(set(payload["environment"]), {"os", "release", "architecture"}) self.assertEqual(payload["status"], "not_run") diff --git a/tests/test_version_metadata.py b/tests/test_version_metadata.py index bc1fe80..5218513 100644 --- a/tests/test_version_metadata.py +++ b/tests/test_version_metadata.py @@ -41,24 +41,25 @@ class VersionMetadataTests(unittest.TestCase): def test_openadb_public_version_is_consistent(self) -> None: - self.assertEqual(VERSION, "3.0.1") + self.assertEqual(VERSION, "3.0.2") self.assertEqual(__version__, VERSION) - self.assertEqual(RELEASE_EXE_FILENAME, "OpenADB-3.0.1.exe") - self.assertIn("Version: `3.0.1`", (ROOT / "README.md").read_text(encoding="utf-8")) - self.assertIn("## [3.0.1]", (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")) - self.assertIn("## [3.0.1]", (ROOT / "CHANGELOG_EN.md").read_text(encoding="utf-8")) - self.assertIn("OpenADB 3.0.1", (ROOT / "GUI_AUDIT.md").read_text(encoding="utf-8")) - self.assertIn("OpenADB 3.0.1", (ROOT / "GUI_REDESIGN_REPORT.md").read_text(encoding="utf-8")) + self.assertEqual(RELEASE_EXE_FILENAME, "OpenADB-3.0.2.exe") + self.assertIn("Version: `3.0.2`", (ROOT / "README.md").read_text(encoding="utf-8")) + self.assertIn("## [3.0.2]", (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")) + self.assertIn("## [3.0.2]", (ROOT / "CHANGELOG_EN.md").read_text(encoding="utf-8")) + self.assertIn("OpenADB 3.0.2", (ROOT / "GUI_AUDIT.md").read_text(encoding="utf-8")) + self.assertIn("OpenADB 3.0.2", (ROOT / "GUI_REDESIGN_REPORT.md").read_text(encoding="utf-8")) def test_android_version_code_policy_is_documented_and_monotonic(self) -> None: - self.assertEqual(VERSION_PARTS, (3, 0, 1)) + self.assertEqual(VERSION_PARTS, (3, 0, 2)) self.assertEqual(ACBRIDGE_BUILD, 1) self.assertEqual(android_version_code((2, 0, 0), 4), 20004) self.assertEqual(android_version_code((2, 0, 1), 1), 20101) self.assertEqual(android_version_code((3, 0, 0), 2), 30002) - self.assertEqual(android_version_code(VERSION_PARTS, ACBRIDGE_BUILD), 30101) - self.assertEqual(ACBRIDGE_VERSION_CODE, 30101) - self.assertGreater(ACBRIDGE_VERSION_CODE, 30002) + self.assertEqual(android_version_code((3, 0, 1), 1), 30101) + self.assertEqual(android_version_code(VERSION_PARTS, ACBRIDGE_BUILD), 30201) + self.assertEqual(ACBRIDGE_VERSION_CODE, 30201) + self.assertGreater(ACBRIDGE_VERSION_CODE, 30101) self.assertRegex(ACBRIDGE_SIGNER_SHA256, r"^[0-9a-f]{64}$") def test_acbridge_source_metadata_matches_client(self) -> None: @@ -80,6 +81,7 @@ def test_acbridge_source_metadata_matches_client(self) -> None: def test_bundled_apks_are_real_current_signed_builds(self) -> None: versioned_apk = BRIDGE_ROOT / ACBRIDGE_APK_FILENAME compatible_apk = BRIDGE_ROOT / "ACBridge.apk" + self.assertFalse((BRIDGE_ROOT / "ACBridge-3.0.1.apk").exists()) self.assertGreater(versioned_apk.stat().st_size, 0) self.assertGreater(compatible_apk.stat().st_size, 0) self.assertEqual( @@ -105,26 +107,36 @@ def test_pyinstaller_and_windows_metadata_match_release_artifact(self) -> None: spec = (ROOT / "OpenADB.spec").read_text(encoding="utf-8") self.assertIn("RELEASE_EXE_FILENAME", spec) self.assertIn("ACBRIDGE_APK_FILENAME", spec) - self.assertNotIn("ACBridge-2.0.1.apk", spec) + self.assertNotIn("ACBridge-3.0.1.apk", spec) windows_metadata = (ROOT / "tools" / "openadb_version_info.txt").read_text(encoding="utf-8") - self.assertIn("filevers=(3, 0, 1, 0)", windows_metadata) - self.assertIn("prodvers=(3, 0, 1, 0)", windows_metadata) - self.assertIn("FileVersion', '3.0.1'", windows_metadata) + self.assertIn("filevers=(3, 0, 2, 0)", windows_metadata) + self.assertIn("prodvers=(3, 0, 2, 0)", windows_metadata) + self.assertIn("FileVersion', '3.0.2'", windows_metadata) self.assertIn(f"OriginalFilename', '{RELEASE_EXE_FILENAME}'", windows_metadata) - self.assertIn("ProductVersion', '3.0.1'", windows_metadata) + self.assertIn("ProductVersion', '3.0.2'", windows_metadata) release_workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text( encoding="utf-8" ) escaped_version = re.escape(VERSION) self.assertIn(rf"^## \[{escaped_version}\]", release_workflow) - self.assertNotIn(r"^## \[3\.0\.0\]", release_workflow) + self.assertNotIn(r"^## \[3\.0\.1\]", release_workflow) + + release_process = (ROOT / "docs" / "RELEASE_PROCESS.md").read_text( + encoding="utf-8" + ) + self.assertIn("ACBridge itself is not a debuggable application", release_process) + self.assertNotIn("private status-file protocol", release_process) def test_release_screenshot_names_match_version(self) -> None: screenshots = ROOT / "docs" / "screenshots" - self.assertTrue(EXPECTED_SCREENSHOTS.issubset({path.name for path in screenshots.glob("*.png")})) + actual_screenshots = {path.name for path in screenshots.glob("*.png")} + prior_screenshots = {name.replace("v3.0.2", "v3.0.1") for name in EXPECTED_SCREENSHOTS} + self.assertTrue(EXPECTED_SCREENSHOTS.issubset(actual_screenshots)) + self.assertTrue(prior_screenshots.isdisjoint(actual_screenshots)) readme = (ROOT / "README.md").read_text(encoding="utf-8") + self.assertNotIn("-v3.0.1.png", readme) generator = (ROOT / "tools" / "capture_readme_screenshots.py").read_text(encoding="utf-8") for filename in EXPECTED_SCREENSHOTS: screenshot = screenshots / filename diff --git a/tools/openadb_version_info.txt b/tools/openadb_version_info.txt index 3ffe1be..39846c7 100644 --- a/tools/openadb_version_info.txt +++ b/tools/openadb_version_info.txt @@ -1,7 +1,7 @@ VSVersionInfo( ffi=FixedFileInfo( - filevers=(3, 0, 1, 0), - prodvers=(3, 0, 1, 0), + filevers=(3, 0, 2, 0), + prodvers=(3, 0, 2, 0), mask=0x3f, flags=0x0, OS=0x40004, @@ -16,12 +16,12 @@ VSVersionInfo( [ StringStruct('CompanyName', 'OpenADB'), StringStruct('FileDescription', 'OpenADB — Android Platform Tools GUI'), - StringStruct('FileVersion', '3.0.1'), + StringStruct('FileVersion', '3.0.2'), StringStruct('InternalName', 'OpenADB'), StringStruct('LegalCopyright', 'OpenADB contributors'), - StringStruct('OriginalFilename', 'OpenADB-3.0.1.exe'), + StringStruct('OriginalFilename', 'OpenADB-3.0.2.exe'), StringStruct('ProductName', 'OpenADB'), - StringStruct('ProductVersion', '3.0.1') + StringStruct('ProductVersion', '3.0.2') ] ) ]),