diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9d3bd4..fa0bcba 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.2.apk'; - assert RELEASE_EXE_FILENAME == 'OpenADB-3.0.2.exe'" + assert ACBRIDGE_APK_FILENAME == 'ACBridge-3.0.3.apk'; + assert RELEASE_EXE_FILENAME == 'OpenADB-3.0.3.exe'" - name: Check repository privacy guardrails shell: pwsh diff --git a/.github/workflows/device-lab.yml b/.github/workflows/device-lab.yml index 49bf084..bdf7261 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.2" -or + $json.product.version -ne "3.0.3" -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 941df7d..4944a4d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,15 +1,15 @@ -name: OpenADB 3.0.2 release +name: OpenADB 3.0.3 release on: push: tags: - - v3.0.2 + - v3.0.3 workflow_dispatch: inputs: tag: description: Release tag; dispatch this workflow from the same tag ref required: true - default: v3.0.2 + default: v3.0.3 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.2 tag + - name: Require the immutable 3.0.3 tag id: release-ref shell: pwsh env: @@ -47,8 +47,8 @@ jobs: '${{ github.ref_name }}' } - if ($tag -ne 'v3.0.2') { - throw "This release workflow is intentionally restricted to v3.0.2; received '$tag'." + if ($tag -ne 'v3.0.3') { + throw "This release workflow is intentionally restricted to v3.0.3; 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.2') { + if ($status.version -ne '3.0.3') { 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.2' -or - [int]$status.acbridge.version_code -ne 30201 -or - $status.acbridge.apk_filename -ne 'ACBridge-3.0.2.apk') { + $status.acbridge.version_name -ne '3.0.3' -or + [int]$status.acbridge.version_code -ne 30301 -or + $status.acbridge.apk_filename -ne 'ACBridge-3.0.3.apk') { throw 'BUILD_STATUS.json contains unexpected ACBridge metadata.' } $expectedName = if ($status.signed) { - 'OpenADB-3.0.2.exe' + 'OpenADB-3.0.3.exe' } else { - 'OpenADB-3.0.2-unsigned.exe' + 'OpenADB-3.0.3-unsigned.exe' } $expectedArtifactName = if ($status.signed) { - 'OpenADB-3.0.2-windows-signed' + 'OpenADB-3.0.3-windows-signed' } else { - 'OpenADB-3.0.2-windows-unsigned' + 'OpenADB-3.0.3-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.2*.exe') + $executables = @(Get-ChildItem -LiteralPath $release -File -Filter 'OpenADB-3.0.3*.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.2.apk' + $apkSource = 'openadb/resources/acbridge/ACBridge-3.0.3.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.2.apk'), + (Join-Path $release 'ACBridge-3.0.3.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\.2\].*?(?=^## \[|\z)' + '(?ms)^## \[3\.0\.3\].*?(?=^## \[|\z)' ) if (-not $match.Success) { - throw 'CHANGELOG_EN.md does not contain a 3.0.2 release section.' + throw 'CHANGELOG_EN.md does not contain a 3.0.3 release section.' } $status = Get-Content -LiteralPath 'release/BUILD_STATUS.json' -Raw -Encoding utf8 | ConvertFrom-Json @@ -360,7 +360,7 @@ jobs: } $notes = @" - # OpenADB 3.0.2 + # OpenADB 3.0.3 ## Artifact status @@ -419,11 +419,11 @@ jobs: } $title = if ($env:SIGNED -eq 'true') { - 'OpenADB 3.0.2' + 'OpenADB 3.0.3' } elseif ($env:STABLE -eq 'true') { - 'OpenADB 3.0.2 (explicitly approved unsigned)' + 'OpenADB 3.0.3 (explicitly approved unsigned)' } else { - 'OpenADB 3.0.2 unsigned preview' + 'OpenADB 3.0.3 unsigned preview' } $arguments = @( 'release', 'create', $env:RELEASE_TAG, @@ -436,7 +436,7 @@ jobs: } $arguments += @( (Join-Path 'release' $env:EXE_FILENAME), - 'release/ACBridge-3.0.2.apk', + 'release/ACBridge-3.0.3.apk', 'release/BUILD_STATUS.json', 'release/SHA256SUMS.txt' ) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 04e4757..a7f8dc1 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -25,7 +25,7 @@ on: required: false push: tags: - - "v3.0.2" + - "v3.0.3" permissions: contents: read @@ -39,7 +39,7 @@ defaults: shell: pwsh env: - OPENADB_VERSION: "3.0.2" + OPENADB_VERSION: "3.0.3" 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 78f0405..40b7d06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,67 @@ 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.2. +The format is based on Keep a Changelog. The current development version is +3.0.3. -## [3.0.2] — Unreleased +## [3.0.3] — Unreleased + +### Fixed + +- Fixed PC-to-Android P2P uploads to removable MicroSD/USB storage failing + after startup with `.openadb-*.part: open failed: EACCES (Permission + denied)` when Android's global All files access setting was enabled. +- ACBridge now resolves removable destinations through the matching persisted + Storage Access Framework tree before considering any direct filesystem + fallback. A global primary-storage permission can no longer silently bypass + an existing MicroSD/USB SAF grant. +- Storage access is selected once before ACBridge publishes `READY` and the + same pinned backend is used for the complete upload. This removes the former + check/use mismatch and prevents the PC from sending buffered file data to a + destination that only appeared writable. +- On TV firmware without a usable SAF picker, the compatibility fallback now + requires the storage-access flow to record approval for that removable + volume, Android's global storage-manager permission, and a successful + create/delete write probe. The probe completes before the P2P data server is + opened; if it fails, ACBridge clears the fallback approval and remains in + the permission flow instead of starting a transfer that will fail with + `EACCES`. +- Persisted SAF trees are now accepted only while Android still reports an + active read/write grant. ACBridge pins the grant by volume and performs a real + create/open/delete probe in the exact destination before publishing + `READY`, including when the destination is the granted tree root. +- The storage picker now reports success only after Android confirms an exact, + persistent read/write tree grant. Invalid picker results and failed direct + write probes are rejected, and removable-storage probing runs off the Android + UI thread. +- Privileged bridge commands now use a shell-permission-protected activity; + the public launcher rejects command extras and destructive operations from + public bridge settings. + +### Version + +- Updated OpenADB, ACBridge, Windows metadata, build workflows, screenshots, + and active release documentation to version 3.0.3. +- ACBridge 3.0.3 uses `versionCode 30301` and is rebuilt from source as + `ACBridge-3.0.3.apk`. + +### Validation + +- Added source-level regressions for SAF-first removable-storage routing with + All files access enabled, pre-`READY` access pinning, active read/write-grant + checks, per-volume fallback approval, and Unicode removable paths. Added a + host regression that converts the exact removable `.part`/`EACCES` failure + into one permission request and one bounded retry. +- A disposable Android 16 emulator with global All files access enabled + transferred 2,097,455 bytes through an approved public removable SAF tree; + SHA-256 matched and no `.openadb-*` residue remained. After clearing the SAF + grant, ACBridge requested permission without opening the data connection or + creating the remote file. The emulator also rejected a command sent to the + public launcher and accepted the storage flow through the DUMP-protected + command activity. Physical Android TV MicroSD validation remains pending + because no physical ADB device was connected. + +## [3.0.2] — Unreleased (superseded by 3.0.3) ### Fixed @@ -47,7 +104,7 @@ The format is based on Keep a Changelog. The current public project version is - 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`. + policy and was built as `ACBridge-3.0.2.apk`. ### Validation @@ -359,8 +416,9 @@ a self-contained Windows executable was prepared. `/storage/self/primary/` to `/storage/emulated/0/`. - Partially received files are written to temporary `.part` or SAF documents and removed after cancellation or failure. -- After successful verification, the temporary file atomically replaces the - existing destination file. +- After successful verification, ACBridge commits the temporary file. Providers + without rename support use a non-atomic copy fallback when replacing an + existing destination. - ACBridge was updated to `versionName 2.0.0`, `versionCode 20004`. - The primary bundled APK was renamed to `ACBridge-2.0.0.apk`; the compatible `ACBridge.apk` contains the same build. diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 78f0405..40b7d06 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -3,10 +3,67 @@ 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.2. +The format is based on Keep a Changelog. The current development version is +3.0.3. -## [3.0.2] — Unreleased +## [3.0.3] — Unreleased + +### Fixed + +- Fixed PC-to-Android P2P uploads to removable MicroSD/USB storage failing + after startup with `.openadb-*.part: open failed: EACCES (Permission + denied)` when Android's global All files access setting was enabled. +- ACBridge now resolves removable destinations through the matching persisted + Storage Access Framework tree before considering any direct filesystem + fallback. A global primary-storage permission can no longer silently bypass + an existing MicroSD/USB SAF grant. +- Storage access is selected once before ACBridge publishes `READY` and the + same pinned backend is used for the complete upload. This removes the former + check/use mismatch and prevents the PC from sending buffered file data to a + destination that only appeared writable. +- On TV firmware without a usable SAF picker, the compatibility fallback now + requires the storage-access flow to record approval for that removable + volume, Android's global storage-manager permission, and a successful + create/delete write probe. The probe completes before the P2P data server is + opened; if it fails, ACBridge clears the fallback approval and remains in + the permission flow instead of starting a transfer that will fail with + `EACCES`. +- Persisted SAF trees are now accepted only while Android still reports an + active read/write grant. ACBridge pins the grant by volume and performs a real + create/open/delete probe in the exact destination before publishing + `READY`, including when the destination is the granted tree root. +- The storage picker now reports success only after Android confirms an exact, + persistent read/write tree grant. Invalid picker results and failed direct + write probes are rejected, and removable-storage probing runs off the Android + UI thread. +- Privileged bridge commands now use a shell-permission-protected activity; + the public launcher rejects command extras and destructive operations from + public bridge settings. + +### Version + +- Updated OpenADB, ACBridge, Windows metadata, build workflows, screenshots, + and active release documentation to version 3.0.3. +- ACBridge 3.0.3 uses `versionCode 30301` and is rebuilt from source as + `ACBridge-3.0.3.apk`. + +### Validation + +- Added source-level regressions for SAF-first removable-storage routing with + All files access enabled, pre-`READY` access pinning, active read/write-grant + checks, per-volume fallback approval, and Unicode removable paths. Added a + host regression that converts the exact removable `.part`/`EACCES` failure + into one permission request and one bounded retry. +- A disposable Android 16 emulator with global All files access enabled + transferred 2,097,455 bytes through an approved public removable SAF tree; + SHA-256 matched and no `.openadb-*` residue remained. After clearing the SAF + grant, ACBridge requested permission without opening the data connection or + creating the remote file. The emulator also rejected a command sent to the + public launcher and accepted the storage flow through the DUMP-protected + command activity. Physical Android TV MicroSD validation remains pending + because no physical ADB device was connected. + +## [3.0.2] — Unreleased (superseded by 3.0.3) ### Fixed @@ -47,7 +104,7 @@ The format is based on Keep a Changelog. The current public project version is - 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`. + policy and was built as `ACBridge-3.0.2.apk`. ### Validation @@ -359,8 +416,9 @@ a self-contained Windows executable was prepared. `/storage/self/primary/` to `/storage/emulated/0/`. - Partially received files are written to temporary `.part` or SAF documents and removed after cancellation or failure. -- After successful verification, the temporary file atomically replaces the - existing destination file. +- After successful verification, ACBridge commits the temporary file. Providers + without rename support use a non-atomic copy fallback when replacing an + existing destination. - ACBridge was updated to `versionName 2.0.0`, `versionCode 20004`. - The primary bundled APK was renamed to `ACBridge-2.0.0.apk`; the compatible `ACBridge.apk` contains the same build. diff --git a/GUI_AUDIT.md b/GUI_AUDIT.md index 8f6ffb8..8823fe7 100644 --- a/GUI_AUDIT.md +++ b/GUI_AUDIT.md @@ -2,7 +2,7 @@ Дата аудита: 10 июля 2026 года -Текущая версия: OpenADB 3.0.2 +Текущая версия: OpenADB 3.0.3 Среда: Windows, Python 3.14.3, PySide6 6.11.1, экран 1920×1080, системная тёмная тема diff --git a/GUI_REDESIGN_REPORT.md b/GUI_REDESIGN_REPORT.md index 791256d..3fa8e13 100644 --- a/GUI_REDESIGN_REPORT.md +++ b/GUI_REDESIGN_REPORT.md @@ -2,7 +2,7 @@ Дата: 12 июля 2026 года -Версия отчёта: OpenADB 3.0.2 +Версия отчёта: OpenADB 3.0.3 Платформа проверки: Windows, Python 3.14.3, PySide6 6.11.1 diff --git a/README.md b/README.md index fbab47e..9ecf043 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![OpenADB logo](logo.png) -Version: `3.0.2` +Version: `3.0.3` 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.2.png) +![Dashboard in the dark theme](docs/screenshots/dashboard-dark-v3.0.3.png) -![Dashboard in the light theme](docs/screenshots/dashboard-light-v3.0.2.png) +![Dashboard in the light theme](docs/screenshots/dashboard-light-v3.0.3.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.2.png) +![Applications with no selected rows](docs/screenshots/applications-dark-v3.0.3.png) -![Applications contextual action bar](docs/screenshots/applications-contextual-actions-dark-v3.0.2.png) +![Applications contextual action bar](docs/screenshots/applications-contextual-actions-dark-v3.0.3.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.2.png) +![File Manager in the dark theme](docs/screenshots/file-manager-dark-v3.0.3.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.2.png) +![Commands in the dark theme](docs/screenshots/commands-dark-v3.0.3.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.2.png) +![Settings in the dark theme](docs/screenshots/settings-dark-v3.0.3.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.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. +The bundled `ACBridge-3.0.3.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.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. +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.3.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.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. +ACBridge 3.0.3 (`versionCode 30301`) 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,7 +286,7 @@ 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 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. +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, stages each file in a temporary document, and verifies it before commit. Providers that cannot rename a document use a copy fallback, so replacement of an existing file is not claimed to be atomic on every Android storage provider. Platform Tools remains the control plane: OpenADB installs or updates the security-hardened ACBridge 3.0.3 build 1 (`versionCode 30301`), creates a request-scoped abstract Android control socket, and reaches it only through a temporary local-only `adb forward`. The ADB command activity and P2P service require Android's shell-only `DUMP` permission; the public launcher activity rejects command extras and destructive operations read from public bridge settings. 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. Removable destinations resolve a matching active SAF read/write grant before any direct fallback, even if global All files access is already enabled. Firmware without a usable picker may use direct storage-manager access only after the storage-access flow records approval for that removable volume and a create/delete probe succeeds. ACBridge also probes the exact SAF destination through `DocumentsContract`; access is pinned before the P2P server opens, so no file bytes are sent to a backend that cannot write the destination. File bytes then travel directly from the PC to the Android device over the local network, and removable MicroSD/USB storage remains writable 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 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. @@ -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.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. +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.3 opens the picker for the matching storage volume when Android exposes it and resolves files by traversing the granted SAF tree. If the firmware has no system folder picker, its All files access fallback must have explicit approval recorded for that removable volume and pass a real write probe before a transfer can start. 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 aa26439..cefb096 100644 --- a/docs/DEVICE_LAB_MATRIX.md +++ b/docs/DEVICE_LAB_MATRIX.md @@ -1,4 +1,4 @@ -# OpenADB 3.0.2 device-lab matrix +# OpenADB 3.0.3 device-lab matrix Last updated: 2026-07-15 @@ -8,46 +8,52 @@ hardware run. ## Current baseline -- The local host identifies itself as Windows 11 Pro, version `10.0.26200`. -- 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 - disposable reports were kept outside the repository and removed after - validation. -- The Stage 7 local one-file build and clean-profile launch smoke passed on the - Windows 11 host at 100% DPI with one monitor and the Apps/System Dark - 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 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. -- 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. +- The local host identifies itself as Windows 11 Pro, version `10.0.26200`, + with CPython 3.14.3 and `QT_QPA_PLATFORM=offscreen` for GUI tests. +- All 577 unittest assertions passed across 39 isolated modules. Thirty-eight + modules exited cleanly; `test_main_window_adaptive` reported all 41 tests + `OK` and then the local PySide6 process exited with the previously observed + Windows native status `0xc0000374`. Compileall, Ruff, workflow YAML parsing, + and `git diff --check` also pass. Hosted Windows CI for the 3.0.3 commit has + not run yet. +- ACBridge 3.0.3 (`versionCode 30301`) was rebuilt from source, ZIP-aligned, + verified with v1/v2/v3 signatures and the established signer, and published + into the two byte-identical bundled APK aliases. +- A disposable read-only API 36 Android emulator completed a two-stream + internal-storage upload with two files, five entries, 393,266 verified + bytes, a nested Unicode filename, and an empty directory. +- The same emulator exposed public removable volume `/storage/0000-0000`. + With global All files access deliberately left enabled, ACBridge used an + approved SAF tree, transferred 2,097,455 bytes, matched SHA-256, and left no + `.openadb-*` residue. The public launcher rejected a command intent while the + DUMP-protected command activity completed the storage grant. After app data + and the SAF grant were cleared, ACBridge requested storage permission without + opening the data connection or creating the selected remote file. +- Emulator NAT required a test-only ADB forward for the data sockets. These + runs validate ACBridge 3.0.3 routing, permission gating, write probes, + protocol integrity, and cleanup, but they are not direct-LAN or physical-TV + evidence. +- No OpenADB 3.0.3 EXE was built in this change, so no 3.0.3 packaged Windows + smoke, Authenticode, or executable hash is claimed. - No physical Android device, OEM Android 17 build, Windows 10 host, signing - certificate, removable Android storage, rooted disposable device, + certificate, physical removable Android storage, rooted disposable device, multi-monitor lab, or controlled network fault lab was available. Those results remain explicitly unclaimed. +### Historical 3.0.2 evidence + +- Windows CI run `29409867004` passed the complete 3.0.2 clean-process matrix + on CPython 3.10–3.14, including the adaptive-window module. +- The local unsigned one-file 3.0.2 intermediate was 90,459,651 bytes with + SHA-256 + `A95290646287FF32479B8F6EDE6F1A05063698FFC8536E6CD3C06F4496A07B51`; + its clean-profile Windows 11 smoke passed and Authenticode reported + `NotSigned`. +- An API 36 emulator accepted ACBridge 3.0.2 (`versionCode 30201`) and completed + the previously documented two-session nested-folder proxy upload. These + historical results are not substitutes for CI, a packaged EXE, or physical + Android TV MicroSD validation on the 3.0.3 commit. + Status meanings: - **Passed — automated proxy**: deterministic local mock/offscreen coverage @@ -98,20 +104,20 @@ Raw ADB/fastboot output and console logs are not release artifacts. | ID | Scenario | Safe procedure and expected result | Automated evidence | Physical status | Required evidence | |---|---|---|---|---|---| | WIN-01 | Windows 10 physical | Launch the release candidate from a clean profile; navigate every page; close normally with no crash log. | Windows CI is defined, but no Windows 10 run exists yet. | **Not run — hardware unavailable** | OS build, EXE SHA-256, launch/close outcome, crash-log absence. | -| WIN-02 | Windows 11 physical | Launch, navigate, resize, and close the one-file build normally. | Local Windows 11 build/clean-profile launch smoke passed; offscreen adaptive tests passed. | **Partial — local smoke only** | OS build, EXE SHA-256, pages visited, close outcome. | -| WIN-03 | 100% DPI | Verify normal/minimized/maximized layouts, focus rings, menus, dialogs, and no clipping. | Local frozen-EXE title/start/close smoke at 100% plus `test_design_system`, `test_dashboard_page`, `test_main_window_adaptive`. | **Partial — local smoke only** | DPI, resolution, page/dialog checklist, sanitized screenshots if used. | +| WIN-02 | Windows 11 physical | Launch, navigate, resize, and close the one-file build normally. | Historical 3.0.2 local Windows 11 smoke passed; 3.0.3 offscreen adaptive tests passed. | **Not run for 3.0.3 — historical smoke only** | OS build, EXE SHA-256, pages visited, close outcome. | +| WIN-03 | 100% DPI | Verify normal/minimized/maximized layouts, focus rings, menus, dialogs, and no clipping. | Historical 3.0.2 frozen-EXE smoke plus current `test_design_system`, `test_dashboard_page`, `test_main_window_adaptive`. | **Not run for 3.0.3 — historical smoke only** | DPI, resolution, page/dialog checklist, sanitized screenshots if used. | | WIN-04 | 125% DPI | Same checks as WIN-03 after sign-out/restart if Windows requires it. | Same layout proxies as WIN-03. | **Not run — hardware unavailable** | DPI, resolution, clipping/focus result. | | WIN-05 | 150% DPI | Same checks as WIN-03, including long labels and paths. | Same layout proxies as WIN-03. | **Not run — hardware unavailable** | DPI, resolution, clipping/elision/tooltips result. | | WIN-06 | 200% DPI | Same checks as WIN-03 at maximum supported scaling. | Same layout proxies as WIN-03. | **Not run — hardware unavailable** | DPI, resolution, reachable controls and dialog result. | -| WIN-07 | Single monitor | Save/restore window geometry and maximize state on one display. | Local frozen-EXE title/start/close smoke used one monitor; geometry persistence is covered in `test_main_window_adaptive`. | **Partial — local smoke only** | Monitor count, geometry before/after restart. | +| WIN-07 | Single monitor | Save/restore window geometry and maximize state on one display. | Historical 3.0.2 frozen-EXE smoke used one monitor; current geometry persistence is covered in `test_main_window_adaptive`. | **Not run for 3.0.3 — historical smoke only** | Monitor count, geometry before/after restart. | | WIN-08 | Multiple monitors | Move between displays with different bounds/scales and restart. | Synthetic multi-screen bounds are covered in `test_main_window_adaptive`. | **Not run — hardware unavailable** | Sanitized display topology/scales and restored display. | | WIN-09 | Monitor disconnect | Close on secondary display, disconnect it, relaunch, and verify recovery onto the remaining display. | Disconnected-screen geometry recovery has an automated proxy. | **Not run — hardware unavailable** | Before/after topology and recovered window bounds. | | WIN-10 | Light theme | Select Light and inspect all pages, dialogs, disabled/hover/selected/focus states. | `test_design_system`, `test_main_window_adaptive`. | **Passed — automated proxy; physical run pending** | Theme, page/state checklist, contrast issues. | -| WIN-11 | Dark theme | Select Dark and perform the same state inspection. | Local frozen-EXE smoke observed Apps/System Dark without a full page sweep; `test_design_system`, `test_main_window_adaptive` passed. | **Partial — local smoke only** | Theme, page/state checklist, contrast issues. | +| WIN-11 | Dark theme | Select Dark and perform the same state inspection. | Historical 3.0.2 frozen-EXE smoke observed Apps/System Dark without a full page sweep; current `test_design_system`, `test_main_window_adaptive` passed. | **Not run for 3.0.3 — historical smoke only** | Theme, page/state checklist, contrast issues. | | WIN-12 | System theme live change | Keep OpenADB on System; change Windows Light to Dark and back without restarting; verify one refresh per change. | `test_system_theme` passed, including timer lifecycle and live mock changes. | **Passed — automated proxy; physical run pending** | Windows theme transitions, observed app transitions, icon/style result. | -| WIN-13 | Unsigned build behavior | Verify `NotSigned`, use the explicit `-unsigned.exe` name, launch cleanly, and never describe it as signed/stable. | Local unsigned one-file/clean-profile smoke passed; build workflow fails closed on naming/status mismatch. | **Partial — local smoke only** | Filename, SHA-256, `Get-AuthenticodeSignature` status, launch outcome. | +| WIN-13 | Unsigned build behavior | Verify `NotSigned`, use the explicit `-unsigned.exe` name, launch cleanly, and never describe it as signed/stable. | Historical 3.0.2 unsigned one-file/clean-profile smoke passed; current build workflow fails closed on naming/status mismatch. | **Not run for 3.0.3 — historical smoke only** | Filename, SHA-256, `Get-AuthenticodeSignature` status, launch outcome. | | WIN-14 | Signed build behavior | Verify Authenticode chain and timestamp before allowing the stable filename. | Signing workflow is defined but no certificate was available locally. | **Not run — certificate unavailable** | Stable filename, SHA-256, signer subject/thumbprint, timestamp and verification status. | -| WIN-15 | Clean profile | Redirect profile roots to a new temporary directory; launch, verify defaults/bundled tools, close, and check no crash log. | Local one-file clean-profile smoke and settings tests passed. | **Partial — local smoke only** | Temporary profile marker, defaults, tools selection, clean close. | +| WIN-15 | Clean profile | Redirect profile roots to a new temporary directory; launch, verify defaults/bundled tools, close, and check no crash log. | Historical 3.0.2 one-file clean-profile smoke and current settings tests passed. | **Not run for 3.0.3 — historical smoke only** | Temporary profile marker, defaults, tools selection, clean close. | | WIN-16 | Migrated profile | Copy a sanitized legacy-layout fixture, launch once, and verify settings/backups remain separated and preserved. | Migration proxies in `test_settings_page` and settings-manager tests passed. | **Passed — automated proxy; physical run pending** | Fixture version, migrated keys/folders, preservation result. | | WIN-17 | Corrupted settings recovery | Corrupt only a disposable settings file; verify backup/default recovery, one warning with path, preserved data, and recovery log. | `test_settings_recovery` and `test_main_window_adaptive` passed. | **Passed — automated proxy; packaged run pending** | Recovery source, preserved folders, warning count, sanitized recovery-log path. | diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index cf69ba0..46a961d 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.2 pipeline and the manual evidence +release. It describes the automated 3.0.3 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.2 is - intentionally restricted to `v3.0.2` by the release workflow. +- Build and release only from an immutable `v` tag. OpenADB 3.0.3 is + intentionally restricted to `v3.0.3` 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. @@ -58,7 +58,7 @@ change and must pass the complete Windows Python matrix before release. 4. Search for stale identifiers before building: ```powershell - rg -n "2\.0\.[01]|20004|20101|OpenADB-2|ACBridge-2" . + rg -n "3\.0\.2|30201|OpenADB-3\.0\.2|ACBridge-3\.0\.2" . ``` Historical changelog entries are expected matches; active metadata and @@ -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.2 the required helper identity is -`com.communism420.acbridge`, `versionName=3.0.2`, and `versionCode=30201`. +For OpenADB 3.0.3 the required helper identity is +`com.communism420.acbridge`, `versionName=3.0.3`, and `versionCode=30301`. ## 2. Build and verify ACBridge @@ -94,9 +94,9 @@ 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.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 +& "$($buildTools.FullName)\aapt.exe" dump badging openadb\resources\acbridge\ACBridge-3.0.3.apk +& "$($buildTools.FullName)\zipalign.exe" -c -v 4 openadb\resources\acbridge\ACBridge-3.0.3.apk +java -jar "$($buildTools.FullName)\lib\apksigner.jar" verify --verbose --print-certs openadb\resources\acbridge\ACBridge-3.0.3.apk ``` The bundled ACBridge APK uses the repository's intentionally public Android @@ -157,12 +157,12 @@ python -m pip check python -m PyInstaller --noconfirm --clean OpenADB.spec ``` -`OpenADB.spec` produces a one-file `OpenADB-3.0.2.exe` build intermediate and +`OpenADB.spec` produces a one-file `OpenADB-3.0.3.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.2-unsigned.exe`. Do not commit the large EXE; publish it as an +`OpenADB-3.0.3-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. @@ -171,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.2` tag. Its smoke test uses a clean +the release workflow, and the exact `v3.0.3` 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.2` window title; +- the exact `OpenADB 3.0.3` 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.2-windows-signed` or -`OpenADB-3.0.2-windows-unsigned`. The artifact contains exactly one +The workflow uploads either `OpenADB-3.0.3-windows-signed` or +`OpenADB-3.0.3-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 @@ -217,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.2-unsigned.exe` candidate with SHA-256 and +3. sign the temporary `OpenADB-3.0.3-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.2.exe`; +5. only after verification, rename it to the stable `OpenADB-3.0.3.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). @@ -242,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.2*.exe') +$executables = @(Get-ChildItem . -File -Filter 'OpenADB-3.0.3*.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 @@ -253,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.2.exe') { throw 'Unsigned EXE cannot pass the signed gate.' } -signtool verify /pa /all /v /tw .\OpenADB-3.0.2.exe +if ($executables[0].Name -ne 'OpenADB-3.0.3.exe') { throw 'Unsigned EXE cannot pass the signed gate.' } +signtool verify /pa /all /v /tw .\OpenADB-3.0.3.exe ``` Do not copy a checksum from an earlier build: signing changes the executable @@ -282,9 +282,9 @@ commit and push only that tag: ```powershell git status --short -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 +git tag -a v3.0.3 -m "OpenADB 3.0.3" +git show --no-patch --decorate v3.0.3 +git push origin v3.0.3 ``` The tag starts `Windows CI`, the standalone Windows build, and the release @@ -293,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.2 changelog section and add: +Release notes are generated from the English 3.0.3 changelog section and add: - signed/unsigned state and executable SHA-256; - Platform Tools and ACBridge metadata; @@ -308,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.2-unsigned.exe`, records `"signed": false`, and never uses the +`OpenADB-3.0.3-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. @@ -316,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.2` ref in Actions, manually dispatch -`OpenADB 3.0.2 release`, and enable `allow_unsigned_stable`. That explicit +record, select the `v3.0.3` ref in Actions, manually dispatch +`OpenADB 3.0.3 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.2.exe`, manually set +Never rename an unsigned EXE to `OpenADB-3.0.3.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.2.png b/docs/screenshots/applications-contextual-actions-dark-v3.0.2.png deleted file mode 100644 index 5387d68..0000000 Binary files a/docs/screenshots/applications-contextual-actions-dark-v3.0.2.png and /dev/null differ diff --git a/docs/screenshots/applications-contextual-actions-dark-v3.0.3.png b/docs/screenshots/applications-contextual-actions-dark-v3.0.3.png new file mode 100644 index 0000000..084410c Binary files /dev/null and b/docs/screenshots/applications-contextual-actions-dark-v3.0.3.png differ diff --git a/docs/screenshots/applications-dark-v3.0.2.png b/docs/screenshots/applications-dark-v3.0.2.png deleted file mode 100644 index a2ea51c..0000000 Binary files a/docs/screenshots/applications-dark-v3.0.2.png and /dev/null differ diff --git a/docs/screenshots/applications-dark-v3.0.3.png b/docs/screenshots/applications-dark-v3.0.3.png new file mode 100644 index 0000000..1284411 Binary files /dev/null and b/docs/screenshots/applications-dark-v3.0.3.png differ diff --git a/docs/screenshots/commands-dark-v3.0.2.png b/docs/screenshots/commands-dark-v3.0.2.png deleted file mode 100644 index 32c7044..0000000 Binary files a/docs/screenshots/commands-dark-v3.0.2.png and /dev/null differ diff --git a/docs/screenshots/commands-dark-v3.0.3.png b/docs/screenshots/commands-dark-v3.0.3.png new file mode 100644 index 0000000..acf98c5 Binary files /dev/null and b/docs/screenshots/commands-dark-v3.0.3.png differ diff --git a/docs/screenshots/dashboard-dark-v3.0.2.png b/docs/screenshots/dashboard-dark-v3.0.2.png deleted file mode 100644 index 2206a72..0000000 Binary files a/docs/screenshots/dashboard-dark-v3.0.2.png and /dev/null differ diff --git a/docs/screenshots/dashboard-dark-v3.0.3.png b/docs/screenshots/dashboard-dark-v3.0.3.png new file mode 100644 index 0000000..4dff576 Binary files /dev/null and b/docs/screenshots/dashboard-dark-v3.0.3.png differ diff --git a/docs/screenshots/dashboard-light-v3.0.2.png b/docs/screenshots/dashboard-light-v3.0.2.png deleted file mode 100644 index 21ee7ba..0000000 Binary files a/docs/screenshots/dashboard-light-v3.0.2.png and /dev/null differ diff --git a/docs/screenshots/dashboard-light-v3.0.3.png b/docs/screenshots/dashboard-light-v3.0.3.png new file mode 100644 index 0000000..2ae13c4 Binary files /dev/null and b/docs/screenshots/dashboard-light-v3.0.3.png differ diff --git a/docs/screenshots/file-manager-dark-v3.0.2.png b/docs/screenshots/file-manager-dark-v3.0.2.png deleted file mode 100644 index edfe180..0000000 Binary files a/docs/screenshots/file-manager-dark-v3.0.2.png and /dev/null differ diff --git a/docs/screenshots/file-manager-dark-v3.0.3.png b/docs/screenshots/file-manager-dark-v3.0.3.png new file mode 100644 index 0000000..b0270b3 Binary files /dev/null and b/docs/screenshots/file-manager-dark-v3.0.3.png differ diff --git a/docs/screenshots/settings-dark-v3.0.2.png b/docs/screenshots/settings-dark-v3.0.2.png deleted file mode 100644 index 6e9cb59..0000000 Binary files a/docs/screenshots/settings-dark-v3.0.2.png and /dev/null differ diff --git a/docs/screenshots/settings-dark-v3.0.3.png b/docs/screenshots/settings-dark-v3.0.3.png new file mode 100644 index 0000000..2ef0e10 Binary files /dev/null and b/docs/screenshots/settings-dark-v3.0.3.png differ diff --git a/openadb/core/acbridge.py b/openadb/core/acbridge.py index e33059e..903932f 100644 --- a/openadb/core/acbridge.py +++ b/openadb/core/acbridge.py @@ -46,7 +46,7 @@ class ACBridgeClient: """ PACKAGE = ACBRIDGE_PACKAGE - ACTIVITY = f"{PACKAGE}/.MainActivity" + ACTIVITY = f"{PACKAGE}/.CommandActivity" VERSION_NAME = VERSION VERSION_CODE = ACBRIDGE_VERSION_CODE APK_FILENAME = ACBRIDGE_APK_FILENAME diff --git a/openadb/core/p2p_transfer_strategy.py b/openadb/core/p2p_transfer_strategy.py index 71da1af..96a755f 100644 --- a/openadb/core/p2p_transfer_strategy.py +++ b/openadb/core/p2p_transfer_strategy.py @@ -34,7 +34,16 @@ def _bridge_needs_storage_grant(self, result) -> bool: getattr(result, "stdout", ""), ] ).lower() - return "saf_permission_required" in text or "grant microsd/usb access" in text + direct_part_denied = ( + ".openadb-" in text + and ".part" in text + and ("eacces" in text or "permission denied" in text) + ) + return ( + "saf_permission_required" in text + or "grant microsd/usb access" in text + or direct_part_denied + ) def _run_p2p_push_transfer( self, diff --git a/openadb/resources/acbridge/ACBridge-3.0.2.apk b/openadb/resources/acbridge/ACBridge-3.0.2.apk deleted file mode 100644 index 48821c0..0000000 Binary files a/openadb/resources/acbridge/ACBridge-3.0.2.apk and /dev/null differ diff --git a/openadb/resources/acbridge/ACBridge-3.0.3.apk b/openadb/resources/acbridge/ACBridge-3.0.3.apk new file mode 100644 index 0000000..7e1cedc Binary files /dev/null and b/openadb/resources/acbridge/ACBridge-3.0.3.apk differ diff --git a/openadb/resources/acbridge/ACBridge.apk b/openadb/resources/acbridge/ACBridge.apk index 48821c0..7e1cedc 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 c47621e..e4a3122 100644 --- a/openadb/resources/acbridge/AndroidManifest.xml +++ b/openadb/resources/acbridge/AndroidManifest.xml @@ -1,7 +1,7 @@ + android:versionCode="30301" + android:versionName="3.0.3"> @@ -41,6 +41,10 @@ + 0 + && !acceptsBridgeCommands()) { + showStatus( + "OpenADB Bridge rejected an untrusted command request. Start storage operations from OpenADB on the connected PC.", + false + ); + return; + } if ("grantStorage".equalsIgnoreCase(intent.getStringExtra("operation"))) { requestStorageAccess(intent); return; @@ -99,6 +114,13 @@ private void exportAppData() { } Map settings = readSettings(new File(outputDir, "settings")); String operation = stringExtraOrSetting(intent, settings, "operation", "export"); + if (!acceptsBridgeCommands() && !"export".equalsIgnoreCase(operation)) { + showStatus( + "OpenADB Bridge rejected an untrusted command stored in public bridge settings.", + endExit + ); + return; + } if ("delete".equalsIgnoreCase(operation)) { deletePath(outputDir, intent, settings, endExit); return; @@ -405,6 +427,13 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { treeId = DocumentsContract.getTreeDocumentId(treeUri); } catch (Throwable ignored) { } + if (treeId.length() == 0) { + status.setText(storageGrantInstructionText( + "Android returned an invalid storage location. Select the MicroSD/USB folder again." + )); + status.requestFocus(); + return; + } if (!selectedTreeCoversPendingPath(treeId)) { status.setText(storageGrantInstructionText( "The selected storage does not match the target path. Select the MicroSD/USB root shown below." @@ -412,16 +441,40 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { status.requestFocus(); return; } - int flags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - if (flags == 0) { - flags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + int requiredFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + int flags = data.getFlags() & requiredFlags; + if ((flags & requiredFlags) != requiredFlags) { + requestAllFilesAccess(new SecurityException( + "The storage picker did not grant persistent read and write access" + )); + return; } try { getContentResolver().takePersistableUriPermission(treeUri, flags); - } catch (Throwable ignored) { + } catch (Throwable exc) { + requestAllFilesAccess(exc); + return; + } + if (!hasPersistedTreeAccess(treeUri)) { + requestAllFilesAccess(new SecurityException( + "Android did not retain the selected storage permission" + )); + return; } try { - getSharedPreferences(PREFS, MODE_PRIVATE).edit().putString(PREF_LAST_TREE_URI, treeUri.toString()).apply(); + SharedPreferences.Editor editor = getSharedPreferences(PREFS, MODE_PRIVATE) + .edit() + .putString(PREF_LAST_TREE_URI, treeUri.toString()); + String treeKey = storagePreferenceKey(PREF_TREE_URI_PREFIX, pendingGrantPath); + if (treeKey.length() > 0) { + editor.putString(treeKey, treeUri.toString()); + } + String directKey = storagePreferenceKey(PREF_DIRECT_VOLUME_PREFIX, pendingGrantPath); + if (directKey.length() > 0) { + editor.remove(directKey); + } + editor.apply(); } catch (Throwable ignored) { } String message = "SAF_PERMISSION_GRANTED\tStorage access granted for " + treeId + ". OpenADB can continue."; @@ -498,11 +551,32 @@ private void finishAllFilesAccessRequest() { File resultFile = new File(outputDir(), "delete_result.txt"); File appResultFile = new File(appOutputDir(), "delete_result.txt"); if (Build.VERSION.SDK_INT >= 30 && Environment.isExternalStorageManager()) { - String message = "ALL_FILES_PERMISSION_GRANTED\tAll files access is enabled for ACBridge. OpenADB can continue."; - writeDeleteResult(resultFile, appResultFile, true, message); - storageGrantPending = false; - pendingGrantPath = ""; - showStatus(message, pendingGrantEndExit); + if (directAccessProbeRunning) { + return; + } + directAccessProbeRunning = true; + final int probeGeneration = ++directAccessProbeGeneration; + final String destination = pendingGrantPath; + status.setText("OpenADB Bridge is verifying write access to the selected storage location.\n\n" + + "No file data will be transferred until this check succeeds.\n\n" + + destination); + Thread probeWorker = new Thread(new Runnable() { + @Override + public void run() { + final String failure = verifyDirectDestinationWritable(destination); + runOnUiThread(new Runnable() { + @Override + public void run() { + finishVerifiedAllFilesAccess( + destination, + failure, + probeGeneration + ); + } + }); + } + }, "OpenADB-Bridge-storage-probe"); + probeWorker.start(); return; } String message = "ALL_FILES_PERMISSION_DENIED\tAll files access is not enabled for ACBridge. OpenADB cannot access this selected storage path without Android storage permission."; @@ -511,10 +585,112 @@ private void finishAllFilesAccessRequest() { showStatus(message, pendingGrantEndExit); } + private void finishVerifiedAllFilesAccess( + String destination, + String directAccessFailure, + int probeGeneration + ) { + directAccessProbeRunning = false; + if (activityDestroyed + || isFinishing() + || probeGeneration != directAccessProbeGeneration + || !storageGrantPending + || !destination.equals(pendingGrantPath)) { + return; + } + File resultFile = new File(outputDir(), "delete_result.txt"); + File appResultFile = new File(appOutputDir(), "delete_result.txt"); + if (directAccessFailure.length() > 0) { + String message = "ALL_FILES_PERMISSION_FAILED\tAll files access is enabled, but Android still denied writes to the selected storage location: " + + directAccessFailure; + writeDeleteResult(resultFile, appResultFile, false, message); + storageGrantPending = false; + showStatus(message, pendingGrantEndExit); + return; + } + try { + String directKey = storagePreferenceKey( + PREF_DIRECT_VOLUME_PREFIX, + destination + ); + if (directKey.length() > 0) { + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit() + .putBoolean(directKey, true) + .apply(); + } + } catch (Throwable ignored) { + } + String message = "ALL_FILES_PERMISSION_GRANTED\tAll files access is enabled for ACBridge. OpenADB can continue."; + writeDeleteResult(resultFile, appResultFile, true, message); + storageGrantPending = false; + pendingGrantPath = ""; + showStatus(message, pendingGrantEndExit); + } + + private boolean hasPersistedTreeAccess(Uri expectedUri) { + if (expectedUri == null) { + return false; + } + try { + for (UriPermission permission : getContentResolver().getPersistedUriPermissions()) { + if (permission != null + && permission.isReadPermission() + && permission.isWritePermission() + && permission.getUri() != null + && expectedUri.toString().equals(permission.getUri().toString())) { + return true; + } + } + } catch (Throwable ignored) { + } + return false; + } + + private String verifyDirectDestinationWritable(String destination) { + String clean = normalizeStoragePath(destination); + if (!clean.startsWith("/storage/") || clean.indexOf('\0') >= 0) { + return "the requested destination is not a public Android storage path"; + } + File directory = new File(clean); + File probe = null; + FileOutputStream output = null; + try { + if ((!directory.exists() && !directory.mkdirs()) || !directory.isDirectory()) { + return "the destination directory could not be created"; + } + probe = File.createTempFile(".openadb-", ".probe", directory); + output = new FileOutputStream(probe); + output.close(); + output = null; + if (!probe.delete()) { + return "the destination write probe could not be removed"; + } + probe = null; + return ""; + } catch (Throwable exc) { + String detail = exc.getMessage(); + if (detail == null || detail.trim().length() == 0) { + detail = exc.getClass().getSimpleName(); + } + return detail.replace('\t', ' ').replace('\n', ' ').replace('\r', ' ').trim(); + } finally { + if (output != null) { + try { + output.close(); + } catch (Throwable ignored) { + } + } + if (probe != null && probe.exists()) { + probe.delete(); + } + } + } + private boolean selectedTreeCoversPendingPath(String treeId) { String storageId = storageIdFromPath(pendingGrantPath); if (storageId.length() == 0 || treeId == null || treeId.length() == 0) { - return true; + return false; } String treeVolume = volumeFromDocumentId(treeId); if (!storageMatchesTree(storageId, treeVolume)) { @@ -526,7 +702,7 @@ private boolean selectedTreeCoversPendingPath(String treeId) { } String relative = relativePathFromStoragePath(pendingGrantPath); if (relative.length() == 0) { - return true; + return false; } return relative.equals(treeRelative) || relative.startsWith(treeRelative + "/"); } @@ -674,29 +850,40 @@ private String findChildDocumentId(Uri treeUri, String parentDocumentId, String } private List persistedTreeUris() { - ArrayList uris = new ArrayList(); - try { - SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); - String last = prefs.getString(PREF_LAST_TREE_URI, ""); - if (last != null && last.length() > 0) { - uris.add(Uri.parse(last)); - } - } catch (Throwable ignored) { - } + ArrayList active = new ArrayList(); try { List permissions = getContentResolver().getPersistedUriPermissions(); for (UriPermission permission : permissions) { - if (permission == null || !permission.isWritePermission()) { + if (permission == null + || !permission.isReadPermission() + || !permission.isWritePermission()) { continue; } Uri uri = permission.getUri(); - if (uri != null && !containsUri(uris, uri)) { - uris.add(uri); + if (uri != null && !containsUri(active, uri)) { + active.add(uri); + } + } + } catch (Throwable ignored) { + } + ArrayList result = new ArrayList(); + try { + SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); + String last = prefs.getString(PREF_LAST_TREE_URI, ""); + if (last != null && last.length() > 0) { + Uri preferred = Uri.parse(last); + if (containsUri(active, preferred)) { + result.add(preferred); } } } catch (Throwable ignored) { } - return uris; + for (Uri uri : active) { + if (!containsUri(result, uri)) { + result.add(uri); + } + } + return result; } private boolean containsUri(List uris, Uri uri) { @@ -971,10 +1158,24 @@ private boolean storageMatchesTree(String storageId, String treeVolume) { } String compactLeft = storageId.replaceAll("[^0-9A-Fa-f]", "").toLowerCase(); String compactRight = treeVolume.replaceAll("[^0-9A-Fa-f]", "").toLowerCase(); - if (compactLeft.length() > 0 && compactRight.length() > 0) { - return compactLeft.startsWith(compactRight) || compactRight.startsWith(compactLeft); + if (compactLeft.length() == 0 || compactRight.length() == 0) { + return false; + } + if (compactLeft.equals(compactRight)) { + return true; + } + return (compactLeft.length() == 16 && compactRight.length() == 8 + && (compactLeft.startsWith(compactRight) || compactLeft.endsWith(compactRight))) + || (compactRight.length() == 16 && compactLeft.length() == 8 + && (compactRight.startsWith(compactLeft) || compactRight.endsWith(compactLeft))); + } + + private String storagePreferenceKey(String prefix, String path) { + String storageId = storageIdFromPath(path); + if (storageId.length() == 0) { + return ""; } - return !"primary".equals(right) && !"home".equals(right); + return prefix + storageId.toLowerCase(); } private List storageVariants(String storageId) { @@ -1071,6 +1272,31 @@ private String trimTrailingSlash(String path) { return clean; } + private String normalizeStoragePath(String path) { + String clean = trimTrailingSlash(path); + if (clean.equals("/sdcard") || clean.startsWith("/sdcard/")) { + return "/storage/emulated/0" + clean.substring("/sdcard".length()); + } + String selfPrimary = "/storage/self/primary"; + if (clean.equals(selfPrimary) || clean.startsWith(selfPrimary + "/")) { + return "/storage/emulated/0" + clean.substring(selfPrimary.length()); + } + return clean; + } + + protected boolean acceptsBridgeCommands() { + return false; + } + + @Override + protected void onDestroy() { + activityDestroyed = true; + directAccessProbeGeneration++; + directAccessProbeRunning = false; + storageGrantPending = false; + super.onDestroy(); + } + private String shellQuote(String value) { return "'" + String.valueOf(value).replace("'", "'\\''") + "'"; } diff --git a/openadb/resources/acbridge/src/com/communism420/acbridge/P2PTransferService.java b/openadb/resources/acbridge/src/com/communism420/acbridge/P2PTransferService.java index 00ce293..3bd00be 100644 --- a/openadb/resources/acbridge/src/com/communism420/acbridge/P2PTransferService.java +++ b/openadb/resources/acbridge/src/com/communism420/acbridge/P2PTransferService.java @@ -26,6 +26,7 @@ import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.File; +import java.io.FileNotFoundException; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; @@ -83,6 +84,8 @@ public final class P2PTransferService extends Service { 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 PREF_TREE_URI_PREFIX = "tree_uri_"; + private static final String PREF_DIRECT_VOLUME_PREFIX = "direct_volume_"; private static final String CONTROL_SOCKET_PREFIX = "openadb_p2p_control_"; private final Set activeServers = Collections.newSetFromMap( @@ -213,7 +216,10 @@ private void runSession(ControlSession controlSession) { // Do not open a socket or accept file bytes until Android has // granted ACBridge access to the requested storage tree. - waitForDestinationAccess(request.destination, controlSession); + DestinationAccess destinationAccess = waitForDestinationAccess( + request.destination, + controlSession + ); throwIfCancelled(controlSession); String token = randomHex(32); server = new ServerSocket(); @@ -251,7 +257,7 @@ private void runSession(ControlSession controlSession) { socket.setSoTimeout(request.timeoutSeconds * 1000); throwIfCancelled(controlSession); updateNotification("Receiving files from OpenADB"); - handleUpload(socket, token, request.destination); + handleUpload(socket, token, destinationAccess); } catch (Exception exc) { if (!controlSession.isCancelled()) { writeControlErrorSafely(controlSession, cleanMessage(exc)); @@ -271,7 +277,7 @@ private void runSession(ControlSession controlSession) { } } - private void waitForDestinationAccess( + private DestinationAccess waitForDestinationAccess( String destination, ControlSession controlSession ) throws Exception { @@ -280,12 +286,7 @@ private void waitForDestinationAccess( while (!stopping && System.currentTimeMillis() < deadline) { throwIfCancelled(controlSession); try { - if (hasAllFilesAccess()) { - resolveDirectDestinationDirectory(destination); - } else { - resolveDestinationDirectory(destination); - } - return; + return resolveDestinationAccess(destination); } catch (SecurityException permissionMissing) { if (!permissionPublished) { writeControlLine(controlSession, "PERMISSION_REQUIRED\t" + destination); @@ -304,6 +305,93 @@ private void waitForDestinationAccess( ); } + private DestinationAccess resolveDestinationAccess(String destination) throws Exception { + String clean = normalizeStoragePath(destination); + + // MANAGE_EXTERNAL_STORAGE is reliable for primary shared storage, but + // several Android TV firmwares expose removable volumes as readable + // while still rejecting FileOutputStream with EACCES. Prefer a + // persisted SAF tree for every MicroSD/USB destination, even when the + // global All files access switch is enabled. + if (isInternalSharedStoragePath(clean)) { + if (hasAllFilesAccess()) { + File directDirectory = resolveDirectDestinationDirectory(clean); + verifyDirectDestinationWritable(directDirectory); + return DestinationAccess.direct(directDirectory); + } + return DestinationAccess.saf(resolveDestinationDirectory(clean)); + } + + SecurityException missingSaf; + try { + return DestinationAccess.saf(resolveDestinationDirectory(clean)); + } catch (SecurityException exc) { + missingSaf = exc; + } + + // Keep compatibility with TV firmware that has no usable system tree + // picker. A global app-op alone is insufficient: MainActivity must + // first record that the permission flow approved this exact volume. + // The fallback is then accepted only after an actual create/delete + // probe succeeds before READY is published. + if (hasApprovedDirectFallback(clean) && hasAllFilesAccess()) { + try { + File directDirectory = resolveDirectDestinationDirectory(clean); + verifyDirectDestinationWritable(directDirectory); + return DestinationAccess.direct(directDirectory); + } catch (Exception directFailure) { + clearDirectFallbackApproval(clean); + missingSaf.addSuppressed(directFailure); + } + } + throw missingSaf; + } + + private void verifyDirectDestinationWritable(File directory) throws Exception { + File probe = new File(directory, ".openadb-" + randomHex(8) + ".probe"); + OutputStream output = null; + boolean deleted = false; + try { + output = new FileOutputStream(probe); + output.close(); + output = null; + deleted = probe.delete(); + if (!deleted) { + throw new java.io.IOException( + "Direct storage access could not remove its write probe" + ); + } + } finally { + closeQuietly(output); + if (!deleted && probe.exists()) { + probe.delete(); + } + } + } + + private boolean hasApprovedDirectFallback(String destination) { + String key = storagePreferenceKey(PREF_DIRECT_VOLUME_PREFIX, destination); + if (key.length() == 0) { + return false; + } + try { + return getSharedPreferences(PREFS, MODE_PRIVATE).getBoolean(key, false); + } catch (Throwable ignored) { + return false; + } + } + + private void clearDirectFallbackApproval(String destination) { + String key = storagePreferenceKey(PREF_DIRECT_VOLUME_PREFIX, destination); + if (key.length() == 0) { + return; + } + try { + getSharedPreferences(PREFS, MODE_PRIVATE).edit().remove(key).apply(); + } catch (Throwable ignored) { + } + } + private void throwIfCancelled(ControlSession controlSession) throws InterruptedException { if (stopping || controlSession.isCancelled()) { throw new InterruptedException("P2P transfer was cancelled"); @@ -545,7 +633,11 @@ private void joinThreadQuietly(Thread thread, long timeoutMillis) { } } - private void handleUpload(Socket socket, String token, String destination) throws Exception { + private void handleUpload( + Socket socket, + String token, + DestinationAccess destinationAccess + ) throws Exception { DataInputStream input = new DataInputStream(new BufferedInputStream(socket.getInputStream(), COPY_BUFFER_SIZE)); DataOutputStream output = new DataOutputStream(new BufferedOutputStream(socket.getOutputStream(), 65536)); byte[] magic = new byte[MAGIC.length]; @@ -568,9 +660,9 @@ private void handleUpload(Socket socket, String token, String destination) throw throw new IllegalArgumentException("Invalid transfer entry count: " + entryCount); } - boolean directAccess = hasAllFilesAccess(); - SafDirectory destinationDirectory = directAccess ? null : resolveDestinationDirectory(destination); - File directDestination = directAccess ? resolveDirectDestinationDirectory(destination) : null; + boolean directAccess = destinationAccess.directDirectory != null; + SafDirectory destinationDirectory = destinationAccess.safDirectory; + File directDestination = destinationAccess.directDirectory; int receivedEntries = 0; int receivedFiles = 0; long receivedBytes = 0L; @@ -927,14 +1019,14 @@ private void copyDocument(Uri source, SafDirectory parent, String fileName) thro private SafDirectory resolveDestinationDirectory(String destination) throws Exception { String clean = normalizeStoragePath(destination); - for (Uri treeUri : persistedTreeUris()) { + String storageId = storageIdFromPath(clean); + for (Uri treeUri : persistedTreeUris(clean)) { String treeId; try { treeId = DocumentsContract.getTreeDocumentId(treeUri); } catch (Throwable ignored) { continue; } - String storageId = storageIdFromPath(clean); String treeVolume = volumeFromDocumentId(treeId); if (!storageMatchesTree(storageId, treeVolume)) { continue; @@ -952,19 +1044,152 @@ private SafDirectory resolveDestinationDirectory(String destination) throws Exce relative = relative.substring(prefix.length()); } } + SafDirectory resolved; try { Uri rootUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, treeId); SafDirectory root = new SafDirectory(treeUri, treeId, rootUri); - return ensureDirectoryComponents(root, pathComponents(relative)); + resolved = ensureDirectoryComponents(root, pathComponents(relative)); } catch (SecurityException ignored) { // A stale persisted tree should lead to a fresh SAF grant. + discardStaleTreeGrant(treeUri); + continue; + } catch (FileNotFoundException ignored) { + // An unmounted or replaced provider tree is stale as well. + discardStaleTreeGrant(treeUri); + continue; } + try { + verifySafDestinationWritable(resolved); + } catch (SecurityException exc) { + discardStaleTreeGrant(treeUri); + continue; + } catch (FileNotFoundException exc) { + throw new IOException( + "SAF destination became unavailable while checking write access.", + exc + ); + } + return resolved; } throw new SecurityException( "SAF_PERMISSION_REQUIRED: grant ACBridge access to this MicroSD/USB location before using P2P: " + clean ); } + private void verifySafDestinationWritable(SafDirectory directory) throws Exception { + synchronized (directoryMutationLock) { + Uri probe = null; + OutputStream output = null; + boolean deleted = false; + try { + probe = DocumentsContract.createDocument( + getContentResolver(), + directory.documentUri, + "application/octet-stream", + ".openadb-" + randomHex(8) + ".probe" + ); + if (probe == null) { + throw new java.io.IOException( + "SAF provider could not create a write probe in the destination" + ); + } + output = getContentResolver().openOutputStream(probe, "w"); + if (output == null) { + throw new java.io.IOException( + "SAF provider could not open its destination write probe" + ); + } + output.close(); + output = null; + try { + deleted = DocumentsContract.deleteDocument(getContentResolver(), probe); + } catch (SecurityException exc) { + throw new java.io.IOException( + "SAF provider could not remove its destination write probe", + exc + ); + } + if (!deleted) { + throw new java.io.IOException( + "SAF provider could not remove its destination write probe" + ); + } + } finally { + closeQuietly(output); + if (probe != null && !deleted) { + try { + DocumentsContract.deleteDocument(getContentResolver(), probe); + } catch (Throwable ignored) { + } + } + } + } + } + + private void discardStaleTreeGrant(Uri treeUri) throws IOException { + Throwable releaseFailure = null; + try { + getContentResolver().releasePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ); + } catch (Throwable exc) { + releaseFailure = exc; + } + try { + SharedPreferences preferences = getSharedPreferences(PREFS, MODE_PRIVATE); + SharedPreferences.Editor editor = preferences.edit(); + java.util.Map values = preferences.getAll(); + String rawLast = preferences.getString(PREF_LAST_TREE_URI, ""); + if (treeUri.toString().equals(rawLast)) { + editor.remove(PREF_LAST_TREE_URI); + } + for (String key : values.keySet()) { + Object value = values.get(key); + if (key.startsWith(PREF_TREE_URI_PREFIX) + && value instanceof String + && treeUri.toString().equals(value)) { + editor.remove(key); + } + } + editor.apply(); + } catch (Throwable exc) { + throw new IOException( + "The persisted SAF location is unavailable and ACBridge could not clear its stale preference.", + exc + ); + } + if (hasActivePersistedTreeAccess(treeUri)) { + Throwable cause = releaseFailure != null + ? releaseFailure + : new SecurityException("Android retained the stale SAF permission"); + throw new IOException( + "The persisted SAF location is unavailable and ACBridge could not reset its stale permission.", + cause + ); + } + } + + private boolean hasActivePersistedTreeAccess(Uri expectedUri) { + if (expectedUri == null) { + return false; + } + try { + for (UriPermission permission : getContentResolver().getPersistedUriPermissions()) { + if (permission != null + && permission.isReadPermission() + && permission.isWritePermission() + && permission.getUri() != null + && expectedUri.toString().equals(permission.getUri().toString())) { + return true; + } + } + } catch (Throwable ignored) { + return true; + } + return false; + } + private SafDirectory ensureRelativeDirectory(SafDirectory base, String relativePath) throws Exception { return ensureDirectoryComponents(base, pathComponents(relativePath)); } @@ -997,7 +1222,7 @@ private SafDirectory ensureDirectoryComponents(SafDirectory base, List c } } - private ChildDocument findChild(SafDirectory parent, String name) { + private ChildDocument findChild(SafDirectory parent, String name) throws Exception { Cursor cursor = null; try { Uri children = DocumentsContract.buildChildDocumentsUriUsingTree(parent.treeUri, parent.documentId); @@ -1017,7 +1242,6 @@ private ChildDocument findChild(SafDirectory parent, String name) { } } } - } catch (Throwable ignored) { } finally { if (cursor != null) { cursor.close(); @@ -1026,30 +1250,61 @@ private ChildDocument findChild(SafDirectory parent, String name) { return null; } - private List persistedTreeUris() { - ArrayList result = new ArrayList(); - try { - SharedPreferences preferences = getSharedPreferences(PREFS, MODE_PRIVATE); - String last = preferences.getString(PREF_LAST_TREE_URI, ""); - if (last != null && last.length() > 0) { - result.add(Uri.parse(last)); - } - } catch (Throwable ignored) { - } + private List persistedTreeUris(String destination) { + ArrayList active = new ArrayList(); try { for (UriPermission permission : getContentResolver().getPersistedUriPermissions()) { if (permission != null + && permission.isReadPermission() && permission.isWritePermission() && permission.getUri() != null - && !containsUri(result, permission.getUri())) { - result.add(permission.getUri()); + && !containsUri(active, permission.getUri())) { + active.add(permission.getUri()); } } } catch (Throwable ignored) { } + + ArrayList result = new ArrayList(); + try { + SharedPreferences preferences = getSharedPreferences(PREFS, MODE_PRIVATE); + addActivePreferenceUri( + result, + active, + preferences.getString( + storagePreferenceKey(PREF_TREE_URI_PREFIX, destination), + "" + ) + ); + addActivePreferenceUri( + result, + active, + preferences.getString(PREF_LAST_TREE_URI, "") + ); + } catch (Throwable ignored) { + } + for (Uri treeUri : active) { + if (!containsUri(result, treeUri)) { + result.add(treeUri); + } + } return result; } + private void addActivePreferenceUri( + List result, + List active, + String rawUri + ) { + if (rawUri == null || rawUri.length() == 0) { + return; + } + Uri candidate = Uri.parse(rawUri); + if (containsUri(active, candidate) && !containsUri(result, candidate)) { + result.add(candidate); + } + } + private boolean containsUri(List uris, Uri candidate) { for (Uri uri : uris) { if (uri.toString().equals(candidate.toString())) { @@ -1219,6 +1474,14 @@ private String relativeFromDocumentId(String documentId) { return separator < 0 || separator + 1 >= documentId.length() ? "" : documentId.substring(separator + 1); } + private String storagePreferenceKey(String prefix, String path) { + String storageId = storageIdFromPath(normalizeStoragePath(path)); + if (storageId.length() == 0) { + return ""; + } + return prefix + storageId.toLowerCase(Locale.US); + } + private boolean storageMatchesTree(String storageId, String treeVolume) { if (storageId == null || treeVolume == null || storageId.length() == 0 || treeVolume.length() == 0) { return false; @@ -1234,10 +1497,13 @@ private boolean storageMatchesTree(String storageId, String treeVolume) { if (left.length() == 0 || right.length() == 0) { return false; } - return left.startsWith(right) - || right.startsWith(left) - || (left.length() == 16 && right.length() == 8 && left.endsWith(right)) - || (right.length() == 16 && left.length() == 8 && right.endsWith(left)); + if (left.equals(right)) { + return true; + } + return (left.length() == 16 && right.length() == 8 + && (left.startsWith(right) || left.endsWith(right))) + || (right.length() == 16 && left.length() == 8 + && (right.startsWith(left) || right.endsWith(left))); } private String trimTrailingSlash(String path) { @@ -1503,6 +1769,24 @@ private static final class SafDirectory { } } + private static final class DestinationAccess { + final SafDirectory safDirectory; + final File directDirectory; + + private DestinationAccess(SafDirectory safDirectory, File directDirectory) { + this.safDirectory = safDirectory; + this.directDirectory = directDirectory; + } + + static DestinationAccess saf(SafDirectory directory) { + return new DestinationAccess(directory, null); + } + + static DestinationAccess direct(File directory) { + return new DestinationAccess(null, directory); + } + } + private static final class ChildDocument { final String documentId; final Uri uri; diff --git a/openadb/version.py b/openadb/version.py index 301c068..cc93023 100644 --- a/openadb/version.py +++ b/openadb/version.py @@ -3,15 +3,15 @@ from __future__ import annotations -VERSION = "3.0.2" -VERSION_PARTS = (3, 0, 2) +VERSION = "3.0.3" +VERSION_PARTS = (3, 0, 3) # 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.2 starts -# its helper build sequence at 1 and therefore uses versionCode 30201. +# 20101 (2.0.1 build 1), and 30002 (3.0.0 build 2). Patch release 3.0.3 starts +# its helper build sequence at 1 and therefore uses versionCode 30301. ACBRIDGE_BUILD = 1 -ACBRIDGE_VERSION_CODE = 30201 +ACBRIDGE_VERSION_CODE = 30301 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 da66855..6bca6af 100644 --- a/tests/test_acbridge_p2p.py +++ b/tests/test_acbridge_p2p.py @@ -277,6 +277,201 @@ def test_android_bootstrap_separates_public_locator_from_secret(self) -> None: source.index("writeAuthenticatedResponse(", transcript_verified), ) + def test_android_removable_storage_prefers_and_pins_saf_access(self) -> None: + root = Path(__file__).resolve().parents[1] + source = ( + root + / "openadb/resources/acbridge/src/com/communism420/acbridge/" + "P2PTransferService.java" + ).read_text(encoding="utf-8") + + run_session = source[ + source.index("private void runSession(") : source.index( + "private DestinationAccess waitForDestinationAccess(" + ) + ] + wait_call = run_session.index( + "DestinationAccess destinationAccess = waitForDestinationAccess(" + ) + server_creation = run_session.index("server = new ServerSocket();") + upload_call = run_session.index( + "handleUpload(socket, token, destinationAccess);" + ) + self.assertLess(wait_call, server_creation) + self.assertLess(server_creation, upload_call) + + resolver = source[ + source.index("private DestinationAccess resolveDestinationAccess(") : source.index( + "private void verifyDirectDestinationWritable(" + ) + ] + removable_resolution = resolver.index("SecurityException missingSaf;") + saf_resolution = resolver.index( + "return DestinationAccess.saf(resolveDestinationDirectory(clean));", + removable_resolution, + ) + direct_fallback = resolver.index( + "if (hasApprovedDirectFallback(clean) && hasAllFilesAccess())", + saf_resolution, + ) + self.assertLess(saf_resolution, direct_fallback) + self.assertIn("verifyDirectDestinationWritable(directDirectory);", resolver) + self.assertIn("clearDirectFallbackApproval(clean);", resolver) + self.assertIn("verifySafDestinationWritable(resolved);", source) + self.assertIn("permission.isReadPermission()", source) + self.assertIn("permission.isWritePermission()", source) + self.assertIn("persistedTreeUris(clean)", source) + self.assertIn('PREF_DIRECT_VOLUME_PREFIX = "direct_volume_"', source) + self.assertNotIn("Environment.isExternalStorageManager(new File(", source) + + saf_resolution = source[ + source.index("private SafDirectory resolveDestinationDirectory(") : source.index( + "private void verifySafDestinationWritable(" + ) + ] + self.assertIn("discardStaleTreeGrant(treeUri);", saf_resolution) + self.assertIn("SAF destination became unavailable", saf_resolution) + probe_resolution = saf_resolution[ + saf_resolution.index("verifySafDestinationWritable(resolved);") : + ] + self.assertIn("catch (SecurityException exc)", probe_resolution) + self.assertIn("discardStaleTreeGrant(treeUri);", probe_resolution) + + child_lookup = source[ + source.index("private ChildDocument findChild(") : source.index( + "private List persistedTreeUris(" + ) + ] + self.assertIn("throws Exception", child_lookup.split("{", 1)[0]) + self.assertNotIn("catch (Throwable ignored)", child_lookup) + + upload = source[ + source.index("private void handleUpload(") : source.index( + "private void receiveFile(" + ) + ] + self.assertIn( + "boolean directAccess = destinationAccess.directDirectory != null;", upload + ) + self.assertNotIn("hasAllFilesAccess", upload) + self.assertNotIn("resolveDestinationDirectory", upload) + self.assertNotIn("boolean directAccess = hasAllFilesAccess();", source) + + def test_android_storage_picker_reports_success_only_for_verified_access(self) -> None: + root = Path(__file__).resolve().parents[1] + source = ( + root + / "openadb/resources/acbridge/src/com/communism420/acbridge/" + "MainActivity.java" + ).read_text(encoding="utf-8") + + result_handler = source[ + source.index("protected void onActivityResult(") : source.index( + "public void onBackPressed()" + ) + ] + require_flags = result_handler.index( + "if ((flags & requiredFlags) != requiredFlags)" + ) + reject_invalid_tree = result_handler.index("if (treeId.length() == 0)") + persist_grant = result_handler.index("takePersistableUriPermission(") + verify_grant = result_handler.index("if (!hasPersistedTreeAccess(treeUri))") + report_success = result_handler.index('"SAF_PERMISSION_GRANTED\\t') + self.assertLess(reject_invalid_tree, require_flags) + self.assertLess(require_flags, persist_grant) + self.assertLess(persist_grant, verify_grant) + self.assertLess(verify_grant, report_success) + self.assertIn("requestAllFilesAccess(exc);", result_handler) + self.assertNotIn("catch (Throwable ignored)", result_handler[persist_grant:verify_grant]) + + verifier = source[ + source.index("private boolean hasPersistedTreeAccess(") : source.index( + "private String verifyDirectDestinationWritable(" + ) + ] + self.assertIn("permission.isReadPermission()", verifier) + self.assertIn("permission.isWritePermission()", verifier) + + all_files = source[ + source.index("private void finishAllFilesAccessRequest(") : source.index( + "private boolean hasPersistedTreeAccess(" + ) + ] + direct_probe = all_files.index( + "verifyDirectDestinationWritable(pendingGrantPath)" + ) if "verifyDirectDestinationWritable(pendingGrantPath)" in all_files else all_files.index( + "verifyDirectDestinationWritable(destination)" + ) + direct_approval = all_files.index("PREF_DIRECT_VOLUME_PREFIX") + self.assertLess(direct_probe, direct_approval) + self.assertIn("ALL_FILES_PERMISSION_FAILED", all_files) + self.assertIn('"OpenADB-Bridge-storage-probe"', all_files) + self.assertLess(all_files.index("probeWorker.start();"), direct_approval) + self.assertIn("probeGeneration != directAccessProbeGeneration", all_files) + self.assertIn("activityDestroyed", all_files) + + tree_coverage = source[ + source.index("private boolean selectedTreeCoversPendingPath(") : source.index( + "private int deleteViaMediaStore(" + ) + ] + self.assertIn( + 'if (relative.length() == 0) {\n return false;', + tree_coverage, + ) + on_destroy = source[ + source.index("protected void onDestroy()") : source.index( + "private String shellQuote(" + ) + ] + self.assertIn("activityDestroyed = true;", on_destroy) + self.assertIn("directAccessProbeGeneration++;", on_destroy) + + manifest = ( + root / "openadb/resources/acbridge/AndroidManifest.xml" + ).read_text(encoding="utf-8") + self.assertEqual( + ACBridgeClient.ACTIVITY, + "com.communism420.acbridge/.CommandActivity", + ) + command_activity = manifest[ + manifest.index('android:name=".CommandActivity"') : manifest.index( + "/>\n None: + destination = "/storage/FE74697674693317/всё с сд карты" + root = Path(__file__).resolve().parents[1] + service_source = ( + root + / "openadb/resources/acbridge/src/com/communism420/acbridge/" + "P2PTransferService.java" + ).read_text(encoding="utf-8") + activity_source = ( + root + / "openadb/resources/acbridge/src/com/communism420/acbridge/" + "MainActivity.java" + ).read_text(encoding="utf-8") + + self.assertEqual( + ACBridgeP2PClient._normalize_destination(destination + "/"), + destination, + ) + for source in (service_source, activity_source): + self.assertIn('PREF_TREE_URI_PREFIX = "tree_uri_"', source) + self.assertIn("storagePreferenceKey(", source) + self.assertIn("startsWith", source) + self.assertIn("endsWith", source) + def test_ready_metadata_requires_the_bootstrap_hmac(self) -> None: bootstrap_secret = "b2" * 32 token = "c3" * 32 diff --git a/tests/test_device_lab_smoke.py b/tests/test_device_lab_smoke.py index 869e59f..8188396 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.2") + self.assertEqual(payload["product"]["version"], "3.0.3") 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_file_transfer_controller.py b/tests/test_file_transfer_controller.py index 97d5665..3a82478 100644 --- a/tests/test_file_transfer_controller.py +++ b/tests/test_file_transfer_controller.py @@ -615,6 +615,77 @@ def test_removable_storage_permission_is_granted_before_retry( ) self.assertEqual(sink.updates[-1]["done_bytes"], 5) + @patch("openadb.core.p2p_transfer_strategy.ACBridgeP2PClient") + @patch("openadb.core.p2p_transfer_strategy.ACBridgeClient") + def test_removable_part_eacces_requests_storage_once_before_retry( + self, + bridge_type: MagicMock, + p2p_type: MagicMock, + ) -> None: + bridge = bridge_type.return_value + bridge.grant_storage_access.return_value = SimpleNamespace( + success=True, + status="granted", + stderr="", + ) + client = p2p_type.return_value + client.upload.side_effect = [ + P2PTransferError( + "/storage/FE74697674693317/всё с сд карты/" + ".openadb-2496f4182a4fd62c.part: open failed: " + "EACCES (Permission denied)" + ), + SimpleNamespace( + success=True, + message="uploaded", + bytes_sent=5, + files_sent=1, + ), + ] + cancel_event = threading.Event() + + result = _P2PHost()._run_p2p_push_transfer( + MagicMock(), + ["C:/source.bin"], + "/storage/FE74697674693317/всё с сд карты", + cancel_event, + _SignalSink(), + temp_path=Path("C:/temp"), + ) + + self.assertTrue(result["success"]) + bridge.grant_storage_access.assert_called_once_with( + "/storage/FE74697674693317/всё с сд карты", + timeout=600, + cancel_event=cancel_event, + ) + self.assertEqual(client.upload.call_count, 2) + + @patch("openadb.core.p2p_transfer_strategy.ACBridgeP2PClient") + @patch("openadb.core.p2p_transfer_strategy.ACBridgeClient") + def test_internal_part_eacces_does_not_open_removable_storage_picker( + self, + bridge_type: MagicMock, + p2p_type: MagicMock, + ) -> None: + p2p_type.return_value.upload.side_effect = P2PTransferError( + "/storage/emulated/0/Android/data/.openadb-deadbeef.part: " + "open failed: EACCES (Permission denied)" + ) + + result = _P2PHost()._run_p2p_push_transfer( + MagicMock(), + ["C:/source.bin"], + "/storage/emulated/0/Android/data", + threading.Event(), + _SignalSink(), + temp_path=Path("C:/temp"), + ) + + self.assertFalse(result["success"]) + bridge_type.return_value.grant_storage_access.assert_not_called() + self.assertEqual(p2p_type.return_value.upload.call_count, 1) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_version_metadata.py b/tests/test_version_metadata.py index 5218513..ad6d5d3 100644 --- a/tests/test_version_metadata.py +++ b/tests/test_version_metadata.py @@ -41,25 +41,26 @@ class VersionMetadataTests(unittest.TestCase): def test_openadb_public_version_is_consistent(self) -> None: - self.assertEqual(VERSION, "3.0.2") + self.assertEqual(VERSION, "3.0.3") self.assertEqual(__version__, VERSION) - 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")) + self.assertEqual(RELEASE_EXE_FILENAME, "OpenADB-3.0.3.exe") + self.assertIn("Version: `3.0.3`", (ROOT / "README.md").read_text(encoding="utf-8")) + self.assertIn("## [3.0.3]", (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")) + self.assertIn("## [3.0.3]", (ROOT / "CHANGELOG_EN.md").read_text(encoding="utf-8")) + self.assertIn("OpenADB 3.0.3", (ROOT / "GUI_AUDIT.md").read_text(encoding="utf-8")) + self.assertIn("OpenADB 3.0.3", (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, 2)) + self.assertEqual(VERSION_PARTS, (3, 0, 3)) 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((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.assertEqual(android_version_code((3, 0, 2), 1), 30201) + self.assertEqual(android_version_code(VERSION_PARTS, ACBRIDGE_BUILD), 30301) + self.assertEqual(ACBRIDGE_VERSION_CODE, 30301) + self.assertGreater(ACBRIDGE_VERSION_CODE, 30201) self.assertRegex(ACBRIDGE_SIGNER_SHA256, r"^[0-9a-f]{64}$") def test_acbridge_source_metadata_matches_client(self) -> None: @@ -81,7 +82,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.assertFalse((BRIDGE_ROOT / "ACBridge-3.0.2.apk").exists()) self.assertGreater(versioned_apk.stat().st_size, 0) self.assertGreater(compatible_apk.stat().st_size, 0) self.assertEqual( @@ -107,21 +108,21 @@ 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-3.0.1.apk", spec) + self.assertNotIn("ACBridge-3.0.2.apk", spec) windows_metadata = (ROOT / "tools" / "openadb_version_info.txt").read_text(encoding="utf-8") - 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("filevers=(3, 0, 3, 0)", windows_metadata) + self.assertIn("prodvers=(3, 0, 3, 0)", windows_metadata) + self.assertIn("FileVersion', '3.0.3'", windows_metadata) self.assertIn(f"OriginalFilename', '{RELEASE_EXE_FILENAME}'", windows_metadata) - self.assertIn("ProductVersion', '3.0.2'", windows_metadata) + self.assertIn("ProductVersion', '3.0.3'", 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\.1\]", release_workflow) + self.assertNotIn(r"^## \[3\.0\.2\]", release_workflow) release_process = (ROOT / "docs" / "RELEASE_PROCESS.md").read_text( encoding="utf-8" @@ -132,11 +133,11 @@ def test_pyinstaller_and_windows_metadata_match_release_artifact(self) -> None: def test_release_screenshot_names_match_version(self) -> None: screenshots = ROOT / "docs" / "screenshots" 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} + prior_screenshots = {name.replace("v3.0.3", "v3.0.2") 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) + self.assertNotIn("-v3.0.2.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 39846c7..5bbb84f 100644 --- a/tools/openadb_version_info.txt +++ b/tools/openadb_version_info.txt @@ -1,7 +1,7 @@ VSVersionInfo( ffi=FixedFileInfo( - filevers=(3, 0, 2, 0), - prodvers=(3, 0, 2, 0), + filevers=(3, 0, 3, 0), + prodvers=(3, 0, 3, 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.2'), + StringStruct('FileVersion', '3.0.3'), StringStruct('InternalName', 'OpenADB'), StringStruct('LegalCopyright', 'OpenADB contributors'), - StringStruct('OriginalFilename', 'OpenADB-3.0.2.exe'), + StringStruct('OriginalFilename', 'OpenADB-3.0.3.exe'), StringStruct('ProductName', 'OpenADB'), - StringStruct('ProductVersion', '3.0.2') + StringStruct('ProductVersion', '3.0.3') ] ) ]),