72 changed files with 9739 additions and 2319 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"WebFetch(domain:community.chocolatey.org)",
"WebFetch(domain:github.com)",
"WebFetch(domain:jrsoftware.org)"
]
}
}
+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
+4 -4
View File
@@ -14,13 +14,13 @@ jobs:
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 }}
@@ -50,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
@@ -0,0 +1,312 @@
# Multi-platform build + packaging workflow
# Thin GitHub Actions wrapper around Prefect + Dagger pipeline.
#
# Architecture:
# - Windows build runs natively on windows-latest (cannot containerize)
# - Linux builds run via Dagger containers orchestrated by Prefect
# - Signing, release creation, and R2 upload handled by Prefect tasks
# - Container runtime: Podman (Dagger connects via Podman socket)
#
# Local equivalent:
# poetry run python -m ci.prefect_flow full \
# --gpg-passphrase "$GPG_PASSPHRASE" \
# --github-token "$GITHUB_TOKEN"
name: Build Multi-Platform Binaries
on:
workflow_dispatch:
permissions:
contents: read
packages: read
concurrency:
group: release-workflow
cancel-in-progress: true
env:
CI_CD: true
CI_CD_PAT: ${{ secrets.CI_CD_PAT }}
jobs:
# ── Windows Build (native runner — cannot containerize) ──────────────
build-windows:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main
- 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
shell: pwsh
run: |
$poetryPath = Join-Path $env:USERPROFILE ".local\bin"
Write-Output $poetryPath >> $Env:GITHUB_PATH
- name: Install dependencies
run: poetry install
- name: Build Windows executable
run: poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
- name: Install Inno Setup 6.7.1
shell: pwsh
run: |
choco install innosetup --version 6.7.1 -y --no-progress
# Refresh PATH so ISCC.exe is available immediately
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
- name: Build Windows installer (Inno Setup)
shell: pwsh
run: |
$version = (poetry version -s).Trim()
Write-Output "Building installer for version $version"
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" `
"scripts\windows\android-file-handler-setup.iss" `
"/DMyAppVersion=$version"
- 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 artifacts
shell: pwsh
run: |
$passphraseFile = New-TemporaryFile
try {
"${{ secrets.GPG_PASSPHRASE }}" | Out-File -FilePath $passphraseFile -Encoding ASCII -NoNewline
# Sign and hash standalone executable
$exePath = Get-ChildItem -Path dist -Filter "android-file-handler-windows.exe" |
Select-Object -First 1 -ExpandProperty FullName
if (-not $exePath) { Write-Error "Standalone executable not found"; exit 1 }
gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$exePath"
$hash = (Get-FileHash -Path "$exePath" -Algorithm SHA256).Hash.ToLower()
"$hash $(Split-Path -Leaf $exePath)" |
Out-File -FilePath "dist/android-file-handler-windows.sha256" -Encoding ASCII -NoNewline
# Sign and hash installer
$setupPath = Get-ChildItem -Path dist -Filter "android-file-handler-setup.exe" |
Select-Object -First 1 -ExpandProperty FullName
if (-not $setupPath) { Write-Error "Installer not found"; exit 1 }
gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$setupPath"
$setupHash = (Get-FileHash -Path "$setupPath" -Algorithm SHA256).Hash.ToLower()
"$setupHash $(Split-Path -Leaf $setupPath)" |
Out-File -FilePath "dist/android-file-handler-setup.sha256" -Encoding ASCII -NoNewline
}
finally {
if (Test-Path $passphraseFile) { Remove-Item $passphraseFile -Force }
}
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: windows-binary
path: |
dist/android-file-handler-windows.exe
dist/android-file-handler-windows.exe.asc
dist/android-file-handler-windows.sha256
dist/android-file-handler-setup.exe
dist/android-file-handler-setup.exe.asc
dist/android-file-handler-setup.sha256
# ── Linux Builds (Prefect + Dagger with Podman backend) ─────────────
build-linux:
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main
- 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: Install project + CI dependencies
run: poetry install --with ci
- name: Set up Podman
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq podman
# Start rootful Podman socket for Dagger compatibility
sudo systemctl enable --now podman.socket
echo "DOCKER_HOST=unix:///run/podman/podman.sock" >> "$GITHUB_ENV"
- name: Install Dagger CLI
uses: dagger/dagger-for-github@v7
with:
verb: version
- name: Log in to GHCR (Podman)
run: |
echo "${{ secrets.GITHUB_TOKEN }}" |
podman login ghcr.io -u "${{ github.actor }}" --password-stdin
- name: Build all Linux distros (Prefect + Dagger)
run: poetry run python -m ci.prefect_flow build-linux
- name: Import GPG key
run: |
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
- name: Sign Linux artifacts
run: |
poetry run python -m ci.prefect_flow sign \
--gpg-passphrase "${{ secrets.GPG_PASSPHRASE }}"
- name: Upload Debian package
uses: actions/upload-artifact@v4
with:
name: debian-package
path: |
dist/android-file-handler_*.deb
dist/android-file-handler_*.deb.asc
dist/android-file-handler-debian.sha256
pkg_dist_debian/**
- name: Upload Arch package
uses: actions/upload-artifact@v4
with:
name: arch-package
path: |
dist/*.pkg.tar.*
dist/android-file-handler-arch.sha256
pkg_dist_arch/**
- name: Upload RHEL package
uses: actions/upload-artifact@v4
with:
name: rhel-package
path: |
dist/*.rpm
dist/*.rpm.asc
dist/android-file-handler-rhel.sha256
pkg_dist_rhel/**
# ── Release + R2 Upload (Prefect) ───────────────────────────────────
do-release:
needs: [build-windows, build-linux]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main
- 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: Install project + CI dependencies
run: poetry install --with ci
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
merge-multiple: true
path: ./dist
- name: Create GitHub release (Prefect)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
poetry run python -m ci.prefect_flow release \
--github-token "$GITHUB_TOKEN"
upload-r2:
runs-on: ubuntu-latest
needs: do-release
if: needs.do-release.result == 'success'
permissions:
contents: read
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main
- 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: Install project + CI dependencies
run: poetry install --with ci
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
merge-multiple: true
path: ./release-files
- name: Authenticate to GCP
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
- name: Upload artifacts to Cloudflare R2
run: |
poetry run python -m ci.prefect_flow upload-r2 \
--gcp-project-id "${{ secrets.GCP_PROJECT_ID }}" \
--run-id "${{ github.run_id }}" \
--release-dir "./release-files"
sync-wiki:
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
+42 -154
View File
@@ -32,15 +32,7 @@ env:
jobs:
status-checks:
permissions:
contents: read
packages: read
uses: ./.github/workflows/status-checks.yml
build-windows:
needs: status-checks
runs-on: windows-latest
steps:
- name: Checkout code
@@ -76,6 +68,15 @@ jobs:
# Use the Windows spec file so packaging is consistent and reproducible
poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
- name: Build Windows installer (Inno Setup)
shell: pwsh
run: |
$version = (poetry version -s).Trim()
Write-Output "Building installer for version $version"
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" `
"scripts\windows\android-file-handler-setup.iss" `
"/DMyAppVersion=$version"
- name: Import GPG key
shell: pwsh
run: |
@@ -83,23 +84,37 @@ jobs:
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
gpg --list-secret-keys
- name: Sign and hash Windows executable
- name: Sign and hash Windows artifacts
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
# Sign and hash the standalone executable
$exePath = Get-ChildItem -Path dist -Filter "android-file-handler-windows.exe" | Select-Object -First 1 -ExpandProperty FullName
if (-not $exePath) {
Write-Error "Standalone executable not found"
exit 1
}
Write-Output "Signing executable: $exePath"
gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$exePath"
$hash = (Get-FileHash -Path "$exePath" -Algorithm SHA256).Hash.ToLower()
"$hash $(Split-Path -Leaf $exePath)" | Out-File -FilePath "dist/android-file-handler-windows.sha256" -Encoding ASCII -NoNewline
Write-Output "Executable SHA-256: $hash"
# Sign and hash the Inno Setup installer
$setupPath = Get-ChildItem -Path dist -Filter "android-file-handler-setup.exe" | Select-Object -First 1 -ExpandProperty FullName
if (-not $setupPath) {
Write-Error "Installer not found"
exit 1
}
Write-Output "Signing installer: $setupPath"
gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$setupPath"
$setupHash = (Get-FileHash -Path "$setupPath" -Algorithm SHA256).Hash.ToLower()
"$setupHash $(Split-Path -Leaf $setupPath)" | Out-File -FilePath "dist/android-file-handler-setup.sha256" -Encoding ASCII -NoNewline
Write-Output "Installer SHA-256: $setupHash"
}
finally {
# Clean up passphrase file
@@ -108,25 +123,19 @@ jobs:
}
}
# 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.exe
dist/android-file-handler-windows.exe.asc
dist/android-file-handler-windows.sha256
dist/android-file-handler-setup.exe
dist/android-file-handler-setup.exe.asc
dist/android-file-handler-setup.sha256
build-debian:
needs: status-checks
permissions:
contents: read
packages: read
@@ -144,50 +153,13 @@ jobs:
with:
ref: main
- name: Build executable
- 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: Package .deb (fpm)
shell: bash
run: |
set -euo pipefail
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"
PKG_DIR="pkg_dist_debian"
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/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"
fi
fpm -s dir -t deb -n android-file-handler -v "$VERSION" \
--architecture amd64 --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[@]}"
- name: Import GPG key
shell: bash
run: |
@@ -234,7 +206,6 @@ jobs:
pkg_dist_debian/**
build-arch:
needs: status-checks
permissions:
contents: read
packages: read
@@ -257,7 +228,7 @@ jobs:
- name: Pull Docker image
run: docker pull ghcr.io/jmr-dev/android-file-handler-arch-builder:latest
- name: Build executable inside container
- name: Build and package Arch
run: |
docker run --rm \
-v ${{ github.workspace }}:/workspace \
@@ -268,52 +239,6 @@ jobs:
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: Package pacman (fpm) inside container
run: |
docker run --rm \
-v ${{ github.workspace }}:/workspace \
-w /workspace \
-e FPM_VERSION=${{ env.FPM_VERSION }} \
ghcr.io/jmr-dev/android-file-handler-arch-builder:latest \
sh -c 'set -euo pipefail && \
if ! VERSION="$(poetry version -s 2>&1)"; then \
echo "::error::Failed to read version from pyproject.toml" >&2 && \
echo "::error::Poetry output: $VERSION" >&2 && \
exit 1; \
fi && \
if [ -z "$VERSION" ]; then \
echo "::error::Version is empty in pyproject.toml" >&2 && \
exit 1; \
fi && \
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" >&2 && \
echo "::error::Expected semantic version format (e.g., 1.2.3, 1.2.3-beta.1, 1.2.3+build.123)" >&2 && \
exit 1; \
fi && \
echo "Using version: $VERSION" && \
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" && \
if [ -f "$ICON_PATH" ]; then \
fpm -s dir -t pacman -n android-file-handler -v "$VERSION" \
--architecture x86_64 \
-p "dist/android-file-handler-${VERSION}-1-x86_64.pkg.tar.zst" \
-C "$PKG_DIR" \
"usr/bin/android-file-handler" \
"usr/share/applications/android-file-handler.desktop" \
"usr/share/icons/hicolor/256x256/apps/android-file-handler.png"; \
else \
echo "Note: icon not present, packaging without icon" && \
fpm -s dir -t pacman -n android-file-handler -v "$VERSION" \
--architecture x86_64 \
-p "dist/android-file-handler-${VERSION}-1-x86_64.pkg.tar.zst" \
-C "$PKG_DIR" \
"usr/bin/android-file-handler" \
"usr/share/applications/android-file-handler.desktop"; \
fi'
- name: Import GPG key
shell: bash
run: |
@@ -360,7 +285,6 @@ jobs:
build-rhel:
needs: status-checks
permissions:
contents: read
packages: read
@@ -378,48 +302,13 @@ jobs:
with:
ref: main
- name: Build RHEL package
- name: Build and package RHEL
run: |
set -euo pipefail
# 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 RHEL (fpm)
shell: bash
run: |
set -euo pipefail
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"
PKG_DIR="pkg_dist_rhel"
mkdir -p dist
echo "Packaging from $PKG_DIR (version=$VERSION)"
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"
fi
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[@]}"
- name: Import GPG key
shell: bash
run: |
@@ -468,7 +357,6 @@ jobs:
do-release:
needs:
- status-checks
- build-windows
- build-debian
- build-arch
+11 -86
View File
@@ -112,7 +112,7 @@ jobs:
run: |
poetry run pytest tests/ -v
build-windows:
build-and-package-windows:
needs: [run-unit-tests-windows]
runs-on: windows-latest
steps:
@@ -155,7 +155,7 @@ jobs:
dist/**/android-file-handler*.exe
dist/android-file-handler.exe
build-debian:
build-and-package-debian:
needs: [run-unit-tests-debian, run-unit-tests-arch, run-unit-tests-rhel]
permissions:
contents: read
@@ -172,35 +172,13 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Build executable
- 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: Package .deb (fpm)
shell: bash
run: |
set -euo pipefail
VERSION="$(poetry version -s)"
PKG_DIR="pkg_dist_debian"
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/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"
fi
fpm -s dir -t deb -n android-file-handler -v "$VERSION" \
--architecture amd64 --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[@]}"
- name: Upload Debian .deb
uses: actions/upload-artifact@v4
with:
@@ -209,7 +187,7 @@ jobs:
dist/android-file-handler_*.deb
pkg_dist_debian/**
build-arch:
build-and-package-arch:
needs: [run-unit-tests-debian, run-unit-tests-arch, run-unit-tests-rhel]
permissions:
contents: read
@@ -219,20 +197,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
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 }} # Personal Access Token with read:packages
password: ${{ secrets.CI_CD_PAT }}
- name: Pull Docker image
run: docker pull ghcr.io/jmr-dev/android-file-handler-arch-builder:latest
- name: Build executable inside container
- name: Build and package Arch
run: |
docker run --rm \
-v ${{ github.workspace }}:/workspace \
@@ -243,38 +220,6 @@ jobs:
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: Package pacman (fpm) inside container
run: |
docker run --rm \
-v ${{ github.workspace }}:/workspace \
-w /workspace \
-e FPM_VERSION=${{ env.FPM_VERSION }} \
ghcr.io/jmr-dev/android-file-handler-arch-builder:latest \
sh -c 'set -euo pipefail && \
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" && \
if [ -f "$ICON_PATH" ]; then \
fpm -s dir -t pacman -n android-file-handler -v "$VERSION" \
--architecture x86_64 \
-p "dist/android-file-handler-${VERSION}-1-x86_64.pkg.tar.zst" \
-C "$PKG_DIR" \
"usr/bin/android-file-handler" \
"usr/share/applications/android-file-handler.desktop" \
"usr/share/icons/hicolor/256x256/apps/android-file-handler.png"; \
else \
echo "Note: icon not present, packaging without icon" && \
fpm -s dir -t pacman -n android-file-handler -v "$VERSION" \
--architecture x86_64 \
-p "dist/android-file-handler-${VERSION}-1-x86_64.pkg.tar.zst" \
-C "$PKG_DIR" \
"usr/bin/android-file-handler" \
"usr/share/applications/android-file-handler.desktop"; \
fi'
- name: Upload Arch package
uses: actions/upload-artifact@v4
with:
@@ -283,7 +228,7 @@ jobs:
dist/*.pkg.tar.*
pkg_dist_arch/**
build-rhel:
build-and-package-rhel:
needs: [run-unit-tests-debian, run-unit-tests-arch, run-unit-tests-rhel]
permissions:
contents: read
@@ -300,33 +245,13 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Build RHEL package
- name: Build and package RHEL
run: |
set -euo pipefail
# 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 RHEL (fpm)
shell: bash
run: |
set -euo pipefail
VERSION="$(poetry version -s)"
PKG_DIR="pkg_dist_rhel"
mkdir -p dist
echo "Packaging from $PKG_DIR (version=$VERSION)"
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"
fi
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[@]}"
- name: Upload RHEL artifacts
uses: actions/upload-artifact@v4
with:
+4 -1
View File
@@ -1,3 +1,4 @@
.venv/
platform-tools**
dist*
build_temp/
@@ -14,4 +15,6 @@ pkg*
# Python cache files
__pycache__/
*.py[cod]
*$py.class
*$py.class
.vagrant/
vagrant_test_results/
+69 -6
View File
@@ -54,14 +54,56 @@ poetry run mypy src/
#### Local Development Build (Linux)
```sh
# Interactive build (prompts for distro selection)
poetry run python scripts/build_package_linux.py
```
#### Prefect + Dagger Build (CI Pipeline Locally)
```sh
# Install CI dependencies
poetry install --with ci
# Build all Linux distros via Dagger containers
poetry run python -m ci.prefect_flow build-linux
# Sign artifacts
poetry run python -m ci.prefect_flow sign --gpg-passphrase "$GPG_PASSPHRASE"
# Full pipeline (build + sign + release)
poetry run python -m ci.prefect_flow full \
--gpg-passphrase "$GPG_PASSPHRASE" \
--github-token "$GITHUB_TOKEN"
# Upload artifacts to Cloudflare R2 (standalone)
poetry run python -m ci.prefect_flow upload-r2 \
--gcp-project-id "$GCP_PROJECT_ID"
```
#### Podman Compose Build (Recommended for Linux)
```sh
# Build all distributions
podman-compose up --build
# Build specific distribution
podman-compose up --build debian
podman-compose up --build arch
podman-compose up --build rhel
# Clean build artifacts
podman-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
# Windows installer (Inno Setup, after PyInstaller build)
# Inno Setup 6.7.1 is installed by the CI/CD workflow via Chocolatey (pinned version)
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" scripts\windows\android-file-handler-setup.iss /DMyAppVersion=0.1.1
# Linux packages use distro-specific spec files:
# - android-file-handler-debian.spec
# - android-file-handler-arch.spec
@@ -96,6 +138,13 @@ poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
- **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
- `windows/android-file-handler-setup.iss`: Inno Setup installer configuration
- **ci/**: CI/CD pipeline orchestration
- `config.py`: Shared build configuration (distro configs, image references)
- `dagger_pipeline.py`: Dagger container build definitions for Linux
- `prefect_flow.py`: Prefect flow orchestration and CLI entry point
- `signing.py`: GPG signing and SHA-256 hashing utilities
- **tests/**: Test suite mirroring src/ structure
@@ -115,12 +164,26 @@ poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
## 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
The project uses a **Prefect + Dagger** pipeline wrapped by GitHub Actions (`.github/workflows/release-prefect-dagger.yml`):
- **Dagger** runs containerized Linux builds (Debian, Arch, RHEL) using pre-built builder images
- **Prefect** orchestrates the pipeline: build → sign → release → R2 upload
- **GitHub Actions** provides the runner infrastructure and Windows build (cannot containerize)
- **Podman** is the container runtime (Dagger connects via Podman socket)
Pipeline structure:
1. `build-windows` — Native Windows build on `windows-latest`
2. `build-linux` — All Linux distros built in parallel via Prefect + Dagger
3. `do-release` — Creates GitHub release with all artifacts
4. `upload-r2` — Uploads artifacts to Cloudflare R2 (credentials from GCP Secrets Manager)
5. `sync-wiki` — Wiki synchronization
The CI pipeline modules live in `ci/`:
- `ci/config.py` — Shared build configuration
- `ci/dagger_pipeline.py` — Dagger container build definitions
- `ci/prefect_flow.py` — Prefect flow orchestration and CLI
- `ci/r2_upload.py` — Cloudflare R2 upload with GCP Secrets Manager integration
- `ci/signing.py` — GPG signing and SHA-256 hashing utilities
## Coding Standards
+1
View File
@@ -0,0 +1 @@
"""CI/CD pipeline orchestration using Prefect and Dagger."""
+88
View File
@@ -0,0 +1,88 @@
"""Shared configuration for CI/CD pipeline."""
from dataclasses import dataclass, field
from pathlib import Path
@dataclass(frozen=True)
class PythonBuildConfig:
"""Configuration for building Python from source."""
version: str = "3.14.3"
source_url: str = "https://www.python.org/ftp/python/3.14.3/Python-3.14.3.tgz"
sha256: str = "d7fe130d0501ae047ca318fa92aa642603ab6f217901015a1df6ce650d5470cd"
@dataclass(frozen=True)
class DistroConfig:
"""Configuration for a Linux distribution build."""
name: str
distro_type: str
container_image: str
bin_path: str
pkg_type: str
architecture: str
postinstall: str | None = None
@dataclass(frozen=True)
class PipelineConfig:
"""Top-level pipeline configuration."""
fpm_version: str = "1.16.0"
project_root: Path = field(
default_factory=lambda: Path(__file__).parent.parent.resolve()
)
python_build: PythonBuildConfig = field(default_factory=PythonBuildConfig)
# Base container images (Python and tools are installed by the pipeline)
debian_image: str = "debian:13"
arch_image: str = "archlinux:latest"
rhel_image: str = "fedora:42"
@property
def distros(self) -> list[DistroConfig]:
"""Return all Linux distribution build configurations."""
return [
DistroConfig(
name="Debian",
distro_type="debian",
container_image=self.debian_image,
bin_path="usr/local/bin",
pkg_type="deb",
architecture="amd64",
postinstall="scripts/debian_postinst.sh",
),
DistroConfig(
name="Arch",
distro_type="arch",
container_image=self.arch_image,
bin_path="usr/bin",
pkg_type="pacman",
architecture="x86_64",
postinstall=None,
),
DistroConfig(
name="RHEL",
distro_type="rhel",
container_image=self.rhel_image,
bin_path="usr/bin",
pkg_type="rpm",
architecture="x86_64",
postinstall="scripts/rhel_postinst.sh",
),
]
# Artifact patterns for each distro
artifact_patterns: dict[str, list[str]] = field(
default_factory=lambda: {
"debian": ["dist/android-file-handler_*.deb"],
"arch": ["dist/android-file-handler-*.pkg.tar.zst"],
"rhel": ["dist/android-file-handler-*.rpm"],
"windows": [
"dist/android-file-handler-windows.exe",
"dist/android-file-handler-setup.exe",
],
}
)
+339
View File
@@ -0,0 +1,339 @@
"""Dagger pipeline for building Linux distribution packages.
Uses the Dagger Python SDK to run containerized builds for each
Linux distribution (Debian, Arch, RHEL) starting from base OS images,
downloading and compiling Python from source with SHA256 verification,
and building the application packages.
"""
# pyright: reportUnknownMemberType=false
# pyright: reportUnknownVariableType=false
# pyright: reportUnknownArgumentType=false
# pyright: reportUnknownParameterType=false
import asyncio
import os
import sys
from pathlib import Path
import dagger # type: ignore[import-not-found]
from ci.config import DistroConfig, PipelineConfig
def _get_registry_token() -> str | None:
"""Read GHCR token from GITHUB_TOKEN or GHCR_TOKEN environment variable."""
return os.environ.get("GITHUB_TOKEN") or os.environ.get("GHCR_TOKEN")
def _get_system_deps_cmd(distro_type: str) -> list[str]:
"""Get the shell command to install system build dependencies.
Args:
distro_type: One of 'debian', 'arch', 'rhel'.
Returns:
Shell command as list for with_exec.
"""
if distro_type == "debian":
return [
"sh",
"-c",
"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/*",
]
elif distro_type == "arch":
return [
"sh",
"-c",
"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",
]
elif distro_type == "rhel":
return [
"sh",
"-c",
"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",
]
raise ValueError(f"Unknown distro type: {distro_type}")
def _get_python_configure_env(distro_type: str) -> str:
"""Get distro-specific LDFLAGS and CPPFLAGS for Python configure.
Args:
distro_type: One of 'debian', 'arch', 'rhel'.
Returns:
String with environment variable exports for the configure step.
"""
if distro_type == "debian":
return 'LDFLAGS="-L/usr/lib/x86_64-linux-gnu" CPPFLAGS="-I/usr/include/tcl8.6"'
elif distro_type == "arch":
return 'LDFLAGS="-L/usr/lib" CPPFLAGS="-I/usr/include"'
elif distro_type == "rhel":
return 'LDFLAGS="-L/usr/lib64" CPPFLAGS="-I/usr/include"'
return ""
def _install_fpm(
container: dagger.Container,
distro_type: str,
fpm_version: str,
) -> dagger.Container:
"""Install fpm (Effing Package Management) in the container.
Args:
container: Dagger container to install fpm in.
distro_type: One of 'debian', 'arch', 'rhel'.
fpm_version: Version of fpm to install.
Returns:
Container with fpm installed.
"""
if distro_type == "arch":
container = container.with_exec(
["gem", "install", "--no-document", "erb"]
).with_exec(
[
"sh",
"-c",
f'gem install --no-document -v "{fpm_version}" fpm && '
"GEM_BIN_DIR=$(ruby -e 'puts Gem.user_dir')/bin && "
'ln -sf "${GEM_BIN_DIR}/fpm" /usr/local/bin/fpm',
]
)
else:
container = container.with_exec(
["gem", "install", "--no-document", "-v", fpm_version, "fpm"]
)
return container
async def build_linux_distro(
client: dagger.Client,
config: PipelineConfig,
distro: DistroConfig,
registry_token: str | None = None,
) -> dict[str, Path]:
"""Build a single Linux distribution package inside a Dagger container.
Starts from a base OS image, compiles Python from source with SHA256
verification, installs build tools (Poetry, fpm), and builds the package.
Args:
client: Active Dagger client connection.
config: Pipeline configuration.
distro: Distribution-specific build configuration.
registry_token: Optional registry auth token.
Returns:
Dictionary mapping artifact names to their local output paths.
"""
print(f"[dagger] Starting {distro.name} build using {distro.container_image}")
source = client.host().directory(
str(config.project_root),
exclude=[".venv", "__pycache__", "dist", "dist_*", "pkg_dist_*", ".git"],
)
python = config.python_build
configure_env = _get_python_configure_env(distro.distro_type)
base = client.container()
if registry_token:
secret = client.set_secret("ghcr_token", registry_token)
base = base.with_registry_auth("ghcr.io", "_token", secret)
# Start from base image and install system dependencies
container = base.from_(distro.container_image).with_exec(
_get_system_deps_cmd(distro.distro_type)
)
# Download Python source and verify SHA256 against python.org
print(f"[dagger] Downloading Python {python.version} and verifying SHA256")
container = container.with_exec(
["wget", "-q", python.source_url, "-O", f"/tmp/Python-{python.version}.tgz"]
).with_exec(
[
"sh",
"-c",
f'echo "{python.sha256} /tmp/Python-{python.version}.tgz" '
f"| sha256sum -c -",
]
)
# Build and install Python from source
print(f"[dagger] Compiling Python {python.version} from source")
container = (
container.with_exec(
["tar", "xzf", f"/tmp/Python-{python.version}.tgz", "-C", "/tmp"]
)
.with_exec(
[
"sh",
"-c",
f"cd /tmp/Python-{python.version} && "
f"{configure_env} ./configure --enable-shared "
f"--with-ensurepip=install --prefix=/usr/local && "
f"make -j$(nproc) && "
f"make install",
]
)
.with_exec(
[
"sh",
"-c",
'echo "/usr/local/lib" > /etc/ld.so.conf.d/python.conf && ldconfig',
]
)
.with_exec(["ln", "-sf", "/usr/local/bin/python3", "/usr/local/bin/python"])
.with_exec(
[
"sh",
"-c",
f"rm -rf /tmp/Python-{python.version} "
f"/tmp/Python-{python.version}.tgz",
]
)
.with_exec(
[
"python3",
"-c",
"import tkinter; import _tkinter; print('tkinter support verified')",
]
)
)
# Install Poetry
container = (
container.with_exec(
[
"sh",
"-c",
"curl -sSL https://install.python-poetry.org | python3 - --yes",
]
)
.with_env_variable(
"PATH", "/root/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
)
.with_exec(["poetry", "--version"])
)
# Install fpm
container = _install_fpm(container, distro.distro_type, config.fpm_version)
# Mount workspace and run build
container = (
container.with_directory("/workspace", source)
.with_workdir("/workspace")
.with_env_variable("CI_CD", "true")
.with_env_variable("DISTRO_TYPE", distro.distro_type)
.with_env_variable("FPM_VERSION", config.fpm_version)
.with_env_variable("POETRY_VIRTUALENVS_IN_PROJECT", "false")
.with_env_variable("POETRY_VIRTUALENVS_PATH", "/tmp/poetry-cache")
.with_exec(["poetry", "install", "--no-interaction"])
.with_exec(["poetry", "run", "python", "scripts/build_package_linux.py"])
)
# Export build artifacts back to host
dist_output = config.project_root / "dist"
pkg_dist_output = config.project_root / f"pkg_dist_{distro.distro_type}"
await container.directory("/workspace/dist").export(str(dist_output))
await container.directory(f"/workspace/pkg_dist_{distro.distro_type}").export(
str(pkg_dist_output)
)
print(
f"[dagger] {distro.name} build complete — artifacts exported to {dist_output}"
)
return {
"dist": dist_output,
"pkg_dist": pkg_dist_output,
}
async def build_all_linux(
config: PipelineConfig | None = None,
) -> dict[str, dict[str, Path]]:
"""Build all Linux distribution packages in parallel via Dagger.
Args:
config: Pipeline configuration. Uses defaults if not provided.
Returns:
Dictionary mapping distro names to their artifact paths.
"""
if config is None:
config = PipelineConfig()
results: dict[str, dict[str, Path]] = {}
token = _get_registry_token()
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
tasks = {
distro.distro_type: build_linux_distro(
client, config, distro, registry_token=token
)
for distro in config.distros
}
# Run all distro builds concurrently
completed = await asyncio.gather(*tasks.values(), return_exceptions=True)
for distro_type, result in zip(tasks.keys(), completed):
if isinstance(result, Exception):
print(f"[dagger] ERROR: {distro_type} build failed: {result}")
raise result
results[distro_type] = result # type: ignore[assignment]
return results
async def build_single_linux(
distro_type: str, config: PipelineConfig | None = None
) -> dict[str, Path]:
"""Build a single Linux distribution package.
Args:
distro_type: One of 'debian', 'arch', 'rhel'.
config: Pipeline configuration. Uses defaults if not provided.
Returns:
Dictionary of artifact paths for the built distro.
"""
if config is None:
config = PipelineConfig()
distro = next((d for d in config.distros if d.distro_type == distro_type), None)
if distro is None:
raise ValueError(
f"Unknown distro type '{distro_type}'. Valid: debian, arch, rhel"
)
token = _get_registry_token()
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
return await build_linux_distro(client, config, distro, registry_token=token)
if __name__ == "__main__":
asyncio.run(build_all_linux())
+366
View File
@@ -0,0 +1,366 @@
"""Prefect orchestration flow for the CI/CD release pipeline.
Coordinates Dagger-based Linux builds, GPG signing, GitHub release
creation, and Cloudflare R2 artifact upload.
Usage:
# Build all Linux distros (CI)
poetry run python -m ci.prefect_flow build-linux
# Sign artifacts in dist/
poetry run python -m ci.prefect_flow sign --gpg-passphrase "$GPG_PASSPHRASE"
# Create GitHub release + upload R2
poetry run python -m ci.prefect_flow release --github-token "$GITHUB_TOKEN"
# Full pipeline (build + sign + release + R2)
poetry run python -m ci.prefect_flow full --gpg-passphrase "$GPG_PASSPHRASE" \\
--github-token "$GITHUB_TOKEN"
"""
import asyncio
import argparse
import os
import subprocess
from pathlib import Path
from prefect import flow, task
from ci.config import PipelineConfig
from ci.dagger_pipeline import build_all_linux
from ci.r2_upload import upload_to_r2
from ci.signing import sign_and_hash
# ---------------------------------------------------------------------------
# Tasks
# ---------------------------------------------------------------------------
@task(name="build-linux-distros", retries=1, retry_delay_seconds=30)
def task_build_linux(config: PipelineConfig) -> dict[str, dict[str, Path]]: # type: ignore[type-arg]
"""Build all Linux distribution packages via Dagger containers."""
print("=== Building Linux packages via Dagger ===")
results = asyncio.run(build_all_linux(config))
print(f"Linux builds completed: {list(results.keys())}")
return results
@task(name="sign-artifacts")
def task_sign_artifacts(
dist_dir: Path,
gpg_passphrase: str,
patterns: list[str] | None = None,
) -> list[Path]:
"""Sign and hash all release artifacts matching the given glob patterns.
Args:
dist_dir: Directory containing artifacts.
gpg_passphrase: GPG key passphrase.
patterns: Glob patterns to match artifacts. Defaults to common package types.
Returns:
List of generated signature and hash file paths.
"""
if patterns is None:
patterns = ["*.exe", "*.deb", "*.rpm", "*.pkg.tar.zst"]
generated_files: list[Path] = []
for pattern in patterns:
for match in dist_dir.glob(pattern):
print(f"Signing: {match.name}")
sig_path, hash_path = sign_and_hash(match, gpg_passphrase)
generated_files.extend([sig_path, hash_path])
if not generated_files:
print(f"Warning: no artifacts matched patterns {patterns} in {dist_dir}")
return generated_files
@task(name="get-version")
def task_get_version() -> str:
"""Read the project version from pyproject.toml via Poetry."""
result = subprocess.run(
["poetry", "version", "-s"],
capture_output=True,
text=True,
check=True,
)
version = result.stdout.strip()
if not version:
raise RuntimeError("Version is empty in pyproject.toml")
print(f"Project version: {version}")
return version
@task(name="prepare-release-files")
def task_prepare_release_files(dist_dir: Path, release_dir: Path) -> list[Path]:
"""Collect all release artifacts into a single directory.
Args:
dist_dir: Source directory containing built artifacts.
release_dir: Target directory for release files.
Returns:
List of files copied into the release directory.
"""
release_dir.mkdir(parents=True, exist_ok=True)
extensions = ["*.exe", "*.deb", "*.rpm", "*.pkg.tar.*", "*.asc", "*.sha256"]
copied: list[Path] = []
for ext in extensions:
for src in dist_dir.glob(ext):
dst = release_dir / src.name
if not dst.exists() or src.stat().st_mtime > dst.stat().st_mtime:
import shutil
shutil.copy2(src, dst)
copied.append(dst)
print(f" {src.name}")
print(f"Prepared {len(copied)} release files in {release_dir}")
return copied
@task(name="create-github-release")
def task_create_github_release(
version: str,
release_dir: Path,
github_token: str,
) -> None:
"""Create a GitHub release with artifacts using gh CLI.
Args:
version: Semantic version string (e.g. '0.1.1').
release_dir: Directory containing release files.
github_token: GitHub token for authentication.
"""
tag = f"v{version}"
files = list(release_dir.iterdir())
if not files:
raise RuntimeError(f"No files found in {release_dir}")
env = {**os.environ, "GH_TOKEN": github_token}
cmd = [
"gh",
"release",
"create",
tag,
"--title",
f"Release {tag}",
"--latest",
] + [str(f) for f in files]
print(f"Creating GitHub release {tag} with {len(files)} files")
subprocess.run(cmd, check=True, env=env)
print(f"GitHub release {tag} created successfully")
@task(name="upload-r2")
def task_upload_r2(
release_dir: Path,
gcp_project_id: str,
run_id: str,
) -> list[str]:
"""Upload release artifacts to Cloudflare R2.
Credentials are fetched from GCP Secrets Manager at runtime.
Args:
release_dir: Directory containing release files.
gcp_project_id: GCP project ID for Secrets Manager lookups.
run_id: Unique identifier for this build run.
Returns:
List of uploaded R2 object keys.
"""
if not gcp_project_id:
print("GCP_PROJECT_ID not set; skipping R2 upload")
return []
print(f"Uploading to Cloudflare R2 (build {run_id})")
return upload_to_r2(release_dir, gcp_project_id, run_id)
# ---------------------------------------------------------------------------
# Flows
# ---------------------------------------------------------------------------
@flow(name="build-linux-flow", log_prints=True)
def flow_build_linux() -> dict[str, dict[str, Path]]: # type: ignore[type-arg]
"""Build all Linux distribution packages."""
config = PipelineConfig()
return task_build_linux(config) # type: ignore[return-value]
@flow(name="sign-flow", log_prints=True)
def flow_sign(gpg_passphrase: str) -> list[Path]:
"""Sign all artifacts in the dist/ directory."""
config = PipelineConfig()
return task_sign_artifacts(config.project_root / "dist", gpg_passphrase)
@flow(name="release-flow", log_prints=True)
def flow_release(
github_token: str,
gcp_project_id: str = "",
run_id: str = "",
) -> None:
"""Create a GitHub release and optionally upload to Cloudflare R2."""
config = PipelineConfig()
version = task_get_version()
release_dir = config.project_root / "release-files"
task_prepare_release_files(config.project_root / "dist", release_dir)
task_create_github_release(version, release_dir, github_token)
if gcp_project_id:
task_upload_r2(release_dir, gcp_project_id, run_id or "local")
@flow(name="upload-r2-flow", log_prints=True)
def flow_upload_r2(
gcp_project_id: str,
run_id: str = "",
release_dir: str = "",
) -> list[str]:
"""Upload release artifacts to Cloudflare R2 (standalone).
Args:
gcp_project_id: GCP project ID for Secrets Manager lookups.
run_id: Build run identifier for R2 path.
release_dir: Path to directory containing artifacts. Defaults to
<project_root>/release-files.
Returns:
List of uploaded R2 object keys.
"""
config = PipelineConfig()
target_dir = Path(release_dir) if release_dir else config.project_root / "release-files"
return task_upload_r2(target_dir, gcp_project_id, run_id or "local") # type: ignore[return-value]
@flow(name="full-pipeline", log_prints=True)
def flow_full_pipeline(
gpg_passphrase: str = "",
github_token: str = "",
gcp_project_id: str = "",
run_id: str = "",
skip_build: bool = False,
skip_sign: bool = False,
skip_release: bool = False,
) -> None:
"""Run the complete CI/CD pipeline: build → sign → release → R2.
Args:
gpg_passphrase: GPG key passphrase for signing.
github_token: GitHub token for release creation.
gcp_project_id: GCP project ID for R2 credential lookup.
run_id: Build run identifier for R2 path.
skip_build: Skip the Linux build step.
skip_sign: Skip the signing step.
skip_release: Skip the release + R2 step.
"""
config = PipelineConfig()
# Step 1: Build Linux distros
if not skip_build:
task_build_linux(config)
# Step 2: Sign artifacts
if not skip_sign:
if not gpg_passphrase:
raise ValueError("--gpg-passphrase is required for signing")
task_sign_artifacts(config.project_root / "dist", gpg_passphrase)
# Step 3: Release
if not skip_release:
if not github_token:
raise ValueError("--github-token is required for release")
version = task_get_version()
release_dir = config.project_root / "release-files"
task_prepare_release_files(config.project_root / "dist", release_dir)
task_create_github_release(version, release_dir, github_token)
if gcp_project_id:
task_upload_r2(release_dir, gcp_project_id, run_id or "local")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> None:
"""CLI entry point for running pipeline actions."""
parser = argparse.ArgumentParser(
description="CI/CD pipeline orchestration via Prefect + Dagger"
)
subparsers = parser.add_subparsers(dest="action", required=True)
# build-linux
subparsers.add_parser("build-linux", help="Build all Linux distribution packages")
# sign
sign_parser = subparsers.add_parser("sign", help="Sign artifacts in dist/")
sign_parser.add_argument("--gpg-passphrase", required=True, help="GPG passphrase")
# release
release_parser = subparsers.add_parser("release", help="Create GitHub release")
release_parser.add_argument("--github-token", required=True, help="GitHub token")
release_parser.add_argument("--gcp-project-id", default="", help="GCP project ID for R2 credentials")
release_parser.add_argument("--run-id", default="", help="Build run ID")
# upload-r2
r2_parser = subparsers.add_parser("upload-r2", help="Upload artifacts to Cloudflare R2")
r2_parser.add_argument("--gcp-project-id", required=True, help="GCP project ID for R2 credentials")
r2_parser.add_argument("--run-id", default="", help="Build run ID")
r2_parser.add_argument("--release-dir", default="", help="Path to artifact directory")
# full
full_parser = subparsers.add_parser("full", help="Run full pipeline")
full_parser.add_argument("--gpg-passphrase", default="", help="GPG passphrase")
full_parser.add_argument("--github-token", default="", help="GitHub token")
full_parser.add_argument("--gcp-project-id", default="", help="GCP project ID for R2 credentials")
full_parser.add_argument("--run-id", default="", help="Build run ID")
full_parser.add_argument("--skip-build", action="store_true")
full_parser.add_argument("--skip-sign", action="store_true")
full_parser.add_argument("--skip-release", action="store_true")
args = parser.parse_args()
if args.action == "build-linux":
flow_build_linux()
elif args.action == "sign":
flow_sign(gpg_passphrase=args.gpg_passphrase)
elif args.action == "release":
flow_release(
github_token=args.github_token,
gcp_project_id=args.gcp_project_id,
run_id=args.run_id,
)
elif args.action == "upload-r2":
flow_upload_r2(
gcp_project_id=args.gcp_project_id,
run_id=args.run_id,
release_dir=args.release_dir,
)
elif args.action == "full":
flow_full_pipeline(
gpg_passphrase=args.gpg_passphrase,
github_token=args.github_token,
gcp_project_id=args.gcp_project_id,
run_id=args.run_id,
skip_build=args.skip_build,
skip_sign=args.skip_sign,
skip_release=args.skip_release,
)
if __name__ == "__main__":
main()
+120
View File
@@ -0,0 +1,120 @@
"""Cloudflare R2 artifact upload with credentials from GCP Secrets Manager."""
import mimetypes
from typing import Any
from pathlib import Path
import boto3 # type: ignore[import-untyped]
from google.cloud import secretmanager # type: ignore[import-untyped]
# GCP Secret Manager secret names for R2 credentials
_R2_ACCESS_KEY_SECRET = "r2-access-key-id"
_R2_SECRET_KEY_SECRET = "r2-secret-access-key"
_R2_ENDPOINT_SECRET = "r2-endpoint-url"
_R2_BUCKET_SECRET = "r2-bucket-name"
def _fetch_secret(client: Any, project_id: str, secret_id: str) -> str:
"""Fetch the latest version of a secret from GCP Secrets Manager.
Args:
client: Secret Manager client.
project_id: GCP project ID.
secret_id: Name of the secret to retrieve.
Returns:
The secret value as a string.
Raises:
google.api_core.exceptions.NotFound: If the secret does not exist.
"""
name = f"projects/{project_id}/secrets/{secret_id}/versions/latest"
response = client.access_secret_version(request={"name": name})
return response.payload.data.decode("utf-8")
def get_r2_credentials(gcp_project_id: str) -> dict[str, str]:
"""Retrieve all Cloudflare R2 credentials from GCP Secrets Manager.
Args:
gcp_project_id: GCP project ID containing the secrets.
Returns:
Dictionary with keys: access_key_id, secret_access_key,
endpoint_url, bucket_name.
"""
client: Any = secretmanager.SecretManagerServiceClient() # pyright: ignore
return {
"access_key_id": _fetch_secret(client, gcp_project_id, _R2_ACCESS_KEY_SECRET),
"secret_access_key": _fetch_secret(
client, gcp_project_id, _R2_SECRET_KEY_SECRET
),
"endpoint_url": _fetch_secret(client, gcp_project_id, _R2_ENDPOINT_SECRET),
"bucket_name": _fetch_secret(client, gcp_project_id, _R2_BUCKET_SECRET),
}
def upload_to_r2(
release_dir: Path,
gcp_project_id: str,
run_id: str,
) -> list[str]:
"""Upload release artifacts to Cloudflare R2.
Fetches R2 credentials from GCP Secrets Manager, then uploads all
files in the release directory to the R2 bucket under a builds/<run_id>/
prefix.
Args:
release_dir: Directory containing release files to upload.
gcp_project_id: GCP project ID for Secrets Manager lookups.
run_id: Unique identifier for this build run.
Returns:
List of uploaded R2 object keys.
Raises:
FileNotFoundError: If release_dir does not exist.
botocore.exceptions.ClientError: If R2 upload fails.
"""
if not release_dir.is_dir():
raise FileNotFoundError(f"Release directory not found: {release_dir}")
credentials = get_r2_credentials(gcp_project_id)
s3_client: Any = boto3.client( # pyright: ignore
"s3",
endpoint_url=credentials["endpoint_url"],
aws_access_key_id=credentials["access_key_id"],
aws_secret_access_key=credentials["secret_access_key"],
)
bucket = credentials["bucket_name"]
prefix = f"builds/{run_id}"
uploaded_keys: list[str] = []
for file_path in sorted(release_dir.iterdir()):
if not file_path.is_file():
continue
key = f"{prefix}/{file_path.name}"
content_type, _ = mimetypes.guess_type(str(file_path))
extra_args: dict[str, str] = {}
if content_type:
extra_args["ContentType"] = content_type
print(f" Uploading {file_path.name} → {key}")
s3_client.upload_file( # pyright: ignore[reportUnknownMemberType]
str(file_path),
bucket,
key,
ExtraArgs=extra_args,
)
uploaded_keys.append(key)
print(
f"Uploaded {len(uploaded_keys)} files to R2 bucket '{bucket}' under '{prefix}/'"
)
return uploaded_keys
+94
View File
@@ -0,0 +1,94 @@
"""GPG signing and SHA-256 hashing utilities for release artifacts."""
import hashlib
import subprocess
import tempfile
from pathlib import Path
def gpg_sign_file(file_path: Path, passphrase: str) -> Path:
"""Create a detached ASCII-armored GPG signature for a file.
Args:
file_path: Path to the file to sign.
passphrase: GPG key passphrase.
Returns:
Path to the generated .asc signature file.
Raises:
subprocess.CalledProcessError: If GPG signing fails.
FileNotFoundError: If the input file does not exist.
"""
if not file_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
sig_path = file_path.with_suffix(file_path.suffix + ".asc")
with tempfile.NamedTemporaryFile(mode="w", suffix=".pass", delete=True) as passfile:
passfile.write(passphrase)
passfile.flush()
subprocess.run(
[
"gpg",
"--batch",
"--yes",
"--passphrase-file",
passfile.name,
"--detach-sign",
"--armor",
str(file_path),
],
check=True,
capture_output=True,
text=True,
)
print(f"Signed: {sig_path}")
return sig_path
def sha256_hash_file(file_path: Path) -> tuple[str, Path]:
"""Compute SHA-256 hash of a file and write a .sha256 checksum file.
Args:
file_path: Path to the file to hash.
Returns:
Tuple of (hex digest, path to .sha256 file).
Raises:
FileNotFoundError: If the input file does not exist.
"""
if not file_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
sha256 = hashlib.sha256()
with open(file_path, "rb") as fh:
for chunk in iter(lambda: fh.read(8192), b""):
sha256.update(chunk)
digest = sha256.hexdigest()
hash_line = f"{digest} {file_path.name}"
hash_path = file_path.parent / f"{file_path.stem}.sha256"
hash_path.write_text(hash_line, encoding="ascii")
print(f"SHA-256 ({file_path.name}): {digest}")
return digest, hash_path
def sign_and_hash(file_path: Path, passphrase: str) -> tuple[Path, Path]:
"""Sign a file with GPG and generate its SHA-256 checksum.
Args:
file_path: Path to the artifact to sign and hash.
passphrase: GPG key passphrase.
Returns:
Tuple of (signature path, hash file path).
"""
sig_path = gpg_sign_file(file_path, passphrase)
_, hash_path = sha256_hash_file(file_path)
return sig_path, hash_path
+81
View File
@@ -0,0 +1,81 @@
# DEPRECATED: This file is kept for backward compatibility.
# Use podman-compose.yml instead:
# podman-compose -f podman-compose.yml up --build
#
# Docker Compose will also read podman-compose.yml if you symlink:
# ln -sf podman-compose.yml docker-compose.yml
#
# --- Original configuration follows (mirrors podman-compose.yml) ---
# Podman Compose / Docker Compose configuration for local multi-platform builds
# Matches the exact images and configurations from .github/workflows/release-prefect-dagger.yml
#
# Usage:
# Build all distributions: podman-compose up --build
# Build specific distro: podman-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"
+72
View File
@@ -0,0 +1,72 @@
# Podman Compose configuration for local multi-platform builds
# Compatible with podman-compose and docker-compose (via podman socket)
#
# Usage:
# Build all distributions: podman-compose up --build
# Build specific distro: podman-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:Z
# 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:Z
# 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:Z
# 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"
Generated
+4591 -405
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
[virtualenvs]
in-project = true
+13 -4
View File
@@ -5,7 +5,7 @@ description = "An Android file transfer util for Windows and Linux. MacOS suppor
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.15"
dependencies = ["requests>=2.32.4,<3.0.0", "platformdirs>4.0.0,<5.0.0"]
@@ -20,25 +20,34 @@ urllib3 = "^2.0"
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.9.0"
black = "^26.3.1"
flake8 = "^7.3.0"
mypy = "^1.18.2"
pre-commit = "^4.3.0"
[tool.poetry.group.test.dependencies]
pytest = "^8.4.2"
pytest = "^9.0.2"
pytest-mock = "^3.15.1"
pytest-cov = "^7.0.0"
[tool.poetry.group.build.dependencies]
pyinstaller = "^6.1.0"
[tool.poetry.group.ci]
optional = true
[tool.poetry.group.ci.dependencies]
dagger-io = ">=0.15.0"
prefect = ">=3.0.0"
boto3 = ">=1.35.0"
google-cloud-secret-manager = ">=2.21.0"
[tool.black]
line-length = 88
target-version = ['py313']
+135 -18
View File
@@ -4,9 +4,10 @@ import os
import subprocess
import shutil
import sys
import re
from pathlib import Path
from enum import Enum
from typing import List
from typing import List, TypedDict
class DistroType(Enum):
@@ -15,7 +16,19 @@ class DistroType(Enum):
RHEL = "rhel"
def run_command(cmd: list[str], check: bool = True, working_dir: str | None = None) -> subprocess.CompletedProcess:
class DistroConfigDict(TypedDict):
"""Type definition for distro configuration."""
name: str
bin_path: str
pkg_suffix: str
spec_file: str
pkg_type: str
architecture: str
postinstall: str | None
def run_command(cmd: list[str], check: bool = True, working_dir: str | None = None) -> subprocess.CompletedProcess[bytes]:
"""Run command and handle errors."""
print(f"Running: {' '.join(cmd)}")
try:
@@ -28,31 +41,138 @@ def run_command(cmd: list[str], check: bool = True, working_dir: str | None = No
sys.exit(1)
def get_distro_config(distro_type: DistroType) -> dict:
def get_distro_config(distro_type: DistroType) -> DistroConfigDict:
"""Get configuration for specific distro type."""
configs = {
configs: dict[DistroType, DistroConfigDict] = {
DistroType.DEBIAN: {
"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"
postinstall = config["postinstall"]
fpm_cmd.extend([
"--deb-user", "root",
"--deb-group", "root",
*(["--after-install", postinstall] if postinstall else []),
"-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"
postinstall = config["postinstall"]
fpm_cmd.extend([
*(["--after-install", postinstall] if postinstall else []),
"-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 +180,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 +281,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,6 +291,9 @@ StartupNotify=true
else:
print(f" (missing) {item_path}")
# Package with fpm
package_with_fpm(distro_type, version, project_root)
def main() -> None:
# Get project root (parent of scripts directory)
@@ -211,14 +334,8 @@ def main() -> None:
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
+252
View File
@@ -0,0 +1,252 @@
# bootstrap.ps1 — Windows developer environment bootstrapper for Android File Handler
# - Installs Chocolatey (if missing)
# - Installs Vagrant (Chocolatey or direct from HashiCorp)
# - Installs pyenv-win (if missing)
# - Installs Python build dependencies (Chocolatey)
# - Installs zlib from source (https://zlib.net/current/zlib.tar.gz)
# - Installs libffi from GitHub releases (https://github.com/libffi/libffi)
# - Installs latest Python (from pyproject.toml)
# - Sets that Python as global default
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
Write-Host "=== Android File Handler Windows Dev Bootstrap ===" -ForegroundColor Cyan
# --- Install Chocolatey ---
if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
Write-Host "Installing Chocolatey..." -ForegroundColor Yellow
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
}
# --- Install Vagrant ---
$vagrantInstalled = Get-Command vagrant -ErrorAction SilentlyContinue
if (-not $vagrantInstalled) {
Write-Host "Installing Vagrant..." -ForegroundColor Yellow
$chocoAvailable = Get-Command choco -ErrorAction SilentlyContinue
if ($chocoAvailable) {
Write-Host " Installing via Chocolatey..." -ForegroundColor Yellow
choco install vagrant -y --no-progress
}
else {
Write-Host " Chocolatey not available — downloading from HashiCorp..." -ForegroundColor Yellow
$vagrantVersion = "2.4.9"
$msiUrl = "https://releases.hashicorp.com/vagrant/$vagrantVersion/vagrant_${vagrantVersion}_windows_amd64.msi"
$msiPath = "$env:TEMP\vagrant_${vagrantVersion}_windows_amd64.msi"
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
Write-Host " Downloading $msiUrl ..." -ForegroundColor Yellow
(New-Object System.Net.WebClient).DownloadFile($msiUrl, $msiPath)
Write-Host " Installing MSI..." -ForegroundColor Yellow
$msiProc = Start-Process -FilePath "msiexec.exe" `
-ArgumentList "/i", $msiPath, "/qn", "/norestart" `
-Wait -PassThru
if ($msiProc.ExitCode -ne 0) {
Write-Host "ERROR: Vagrant MSI install failed with exit code $($msiProc.ExitCode)" -ForegroundColor Red
exit 1
}
Remove-Item $msiPath -Force -ErrorAction SilentlyContinue
}
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
Write-Host " Vagrant installed: $(vagrant --version)" -ForegroundColor Green
}
else {
Write-Host "Vagrant already installed: $(vagrant --version)" -ForegroundColor Green
}
# --- Install pyenv-win ---
$pyenvRoot = "$env:USERPROFILE\.pyenv"
$pyenvBin = "$pyenvRoot\pyenv-win\bin\pyenv.bat"
if (-not (Test-Path $pyenvBin)) {
Write-Host "Installing pyenv-win..." -ForegroundColor Yellow
choco install pyenv-win -y --no-progress
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
}
else {
Write-Host "pyenv-win already installed." -ForegroundColor Green
}
# --- Install Python build dependencies ---
# Package IDs verified against https://community.chocolatey.org/packages
# zlib has no Chocolatey package; libffi has no Chocolatey package — both omitted
$buildDeps = @('visualcpp-build-tools', 'make', 'openssl.light', 'bzip2', 'SQLite')
$installedPkgs = choco list --no-progress --limit-output 2>&1 |
Where-Object { $_ -match '\|' } |
ForEach-Object { ($_ -split '\|')[0].ToLower() }
$toInstall = @($buildDeps | Where-Object { $_.ToLower() -notin $installedPkgs })
if ($toInstall.Count -gt 0) {
Write-Host "Installing Python build dependencies: $($toInstall -join ', ')..." -ForegroundColor Yellow
choco install -y --no-progress @toInstall
} else {
Write-Host "Python build dependencies already installed." -ForegroundColor Green
}
# Shared install root for libraries not on Chocolatey
$devLibsRoot = "$env:USERPROFILE\.devlibs"
New-Item -ItemType Directory -Path $devLibsRoot -Force | Out-Null
# --- Install zlib from source (https://zlib.net/current/zlib.tar.gz) ---
$zlibInstallDir = "$devLibsRoot\zlib"
if (-not (Test-Path "$zlibInstallDir\include\zlib.h")) {
Write-Host "Installing zlib from source..." -ForegroundColor Yellow
# Locate MSVC build environment via vswhere
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
if (-not (Test-Path $vswhere)) {
Write-Host "ERROR: vswhere.exe not found — Visual Studio Build Tools required to build zlib." -ForegroundColor Red
exit 1
}
$vsPath = (& $vswhere -latest -products * -requires Microsoft.VisualCpp.Tools.HostX86.TargetX64 -property installationPath 2>&1).Trim()
$vcvarsall = "$vsPath\VC\Auxiliary\Build\vcvarsall.bat"
if (-not (Test-Path $vcvarsall)) {
Write-Host "ERROR: vcvarsall.bat not found at $vcvarsall" -ForegroundColor Red
exit 1
}
# Download source tarball
$zlibTar = "$env:TEMP\zlib.tar.gz"
Write-Host " Downloading zlib source..." -ForegroundColor Yellow
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
(New-Object System.Net.WebClient).DownloadFile('https://zlib.net/current/zlib.tar.gz', $zlibTar)
# Extract (tar.exe ships with Windows 10 1803+)
$zlibBuildDir = "$env:TEMP\zlib-build"
if (Test-Path $zlibBuildDir) { Remove-Item $zlibBuildDir -Recurse -Force }
New-Item -ItemType Directory -Path $zlibBuildDir | Out-Null
tar -xzf $zlibTar -C $zlibBuildDir --strip-components=1
# Build with MSVC nmake via a wrapper cmd script
Write-Host " Building zlib with MSVC nmake..." -ForegroundColor Yellow
$buildCmd = "$env:TEMP\zlib-build.cmd"
"@echo off`r`ncall `"$vcvarsall`" x64`r`ncd /d `"$zlibBuildDir`"`r`nnmake -f win32\Makefile.msc`r`n" |
Set-Content $buildCmd -Encoding ASCII
$proc = Start-Process cmd.exe -ArgumentList "/c `"$buildCmd`"" -Wait -PassThru -NoNewWindow
Remove-Item $buildCmd -Force -ErrorAction SilentlyContinue
if ($proc.ExitCode -ne 0) {
Write-Host "ERROR: zlib build failed (exit $($proc.ExitCode))" -ForegroundColor Red
exit 1
}
# Install headers and libs
New-Item -ItemType Directory -Path "$zlibInstallDir\include" -Force | Out-Null
New-Item -ItemType Directory -Path "$zlibInstallDir\lib" -Force | Out-Null
New-Item -ItemType Directory -Path "$zlibInstallDir\bin" -Force | Out-Null
Copy-Item "$zlibBuildDir\zlib.h" "$zlibInstallDir\include\"
Copy-Item "$zlibBuildDir\zconf.h" "$zlibInstallDir\include\"
Copy-Item "$zlibBuildDir\zlib.lib" "$zlibInstallDir\lib\"
Copy-Item "$zlibBuildDir\zdll.lib" "$zlibInstallDir\lib\" -ErrorAction SilentlyContinue
Copy-Item "$zlibBuildDir\zlib1.dll" "$zlibInstallDir\bin\" -ErrorAction SilentlyContinue
# Cleanup
Remove-Item $zlibTar -Force -ErrorAction SilentlyContinue
Remove-Item $zlibBuildDir -Recurse -Force -ErrorAction SilentlyContinue
Write-Host "zlib installed to $zlibInstallDir" -ForegroundColor Green
} else {
Write-Host "zlib already installed at $zlibInstallDir" -ForegroundColor Green
}
# Add zlib bin dir to user PATH (persists across sessions; also updates current session)
$zlibBinDir = "$zlibInstallDir\bin"
$userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User')
if ($userPath -notmatch [regex]::Escape($zlibBinDir)) {
[System.Environment]::SetEnvironmentVariable('Path', "$userPath;$zlibBinDir", 'User')
Write-Host " Added $zlibBinDir to user PATH" -ForegroundColor Green
}
$env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('Path', 'User')
# --- Install libffi from GitHub releases (https://github.com/libffi/libffi) ---
$libffiInstallDir = "$devLibsRoot\libffi"
if (-not (Test-Path "$libffiInstallDir\include\ffi.h")) {
Write-Host "Fetching latest libffi release from GitHub..." -ForegroundColor Yellow
$apiResponse = Invoke-RestMethod `
-Uri 'https://api.github.com/repos/libffi/libffi/releases/latest' `
-Headers @{ 'User-Agent' = 'android-file-handler-bootstrap' }
$libffiTag = $apiResponse.tag_name # e.g. "v3.5.2"
$libffiVersion = $libffiTag -replace '^v', '' # e.g. "3.5.2"
Write-Host " Latest libffi: $libffiTag" -ForegroundColor Yellow
$zipName = "libffi-$libffiVersion-x86-64bit-msvc-binaries.zip"
$downloadUrl = "https://github.com/libffi/libffi/releases/download/$libffiTag/$zipName"
$zipPath = "$env:TEMP\$zipName"
Write-Host " Downloading $zipName..." -ForegroundColor Yellow
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
(New-Object System.Net.WebClient).DownloadFile($downloadUrl, $zipPath)
# Extract — zip contains a single top-level subdirectory
$extractTemp = "$env:TEMP\libffi-extract"
if (Test-Path $extractTemp) { Remove-Item $extractTemp -Recurse -Force }
Expand-Archive -Path $zipPath -DestinationPath $extractTemp -Force
if (Test-Path $libffiInstallDir) { Remove-Item $libffiInstallDir -Recurse -Force }
$innerDir = Get-ChildItem $extractTemp -Directory | Select-Object -First 1
if ($innerDir) {
Move-Item $innerDir.FullName $libffiInstallDir
} else {
# Flat zip — use the extract root directly
Move-Item $extractTemp $libffiInstallDir
}
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
if (Test-Path $extractTemp) { Remove-Item $extractTemp -Recurse -Force -ErrorAction SilentlyContinue }
Write-Host "libffi $libffiVersion installed to $libffiInstallDir" -ForegroundColor Green
} else {
Write-Host "libffi already installed at $libffiInstallDir" -ForegroundColor Green
}
# --- Parse Python version from pyproject.toml ---
$scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
$repoRoot = Resolve-Path (Join-Path $scriptDir "..\..\..")
$pyprojectPath = Join-Path $repoRoot "pyproject.toml"
if (-not (Test-Path $pyprojectPath)) {
Write-Host "ERROR: pyproject.toml not found at $pyprojectPath" -ForegroundColor Red
exit 1
}
$pyproject = Get-Content $pyprojectPath -Raw
$pyverMatch = $pyproject | Select-String -Pattern 'requires-python\s*=\s*"([^"]+)"' | ForEach-Object { $_.Matches[0].Groups[1].Value }
if (-not $pyverMatch) {
Write-Host "ERROR: Could not find requires-python in pyproject.toml" -ForegroundColor Red
exit 1
}
# Use the highest version in the range (e.g. ">=3.13, <3.15" → 3.14.x)
$pyver = $pyverMatch -replace '[^0-9.,<>= ]', ''
$maxVer = ($pyver -split ',') | Where-Object { $_ -match '<' } | ForEach-Object { $_ -replace '[^0-9.]', '' } | Sort-Object -Descending | Select-Object -First 1
if ($maxVer) {
$major, $minor = $maxVer -split '\.'
$targetMinor = [int]$minor - 1
$targetVer = "$major.$targetMinor.x"
} else {
$targetVer = $pyver -replace '[^0-9.]', ''
}
Write-Host "Target Python version: $targetVer" -ForegroundColor Cyan
# --- Install and set global Python version ---
$pyenv = "pyenv"
if (-not (Get-Command $pyenv -ErrorAction SilentlyContinue)) {
$pyenv = "$pyenvBin"
}
$versionPrefix = $targetVer -replace '\.x$', ''
$installedVersions = (& $pyenv versions 2>&1) | Out-String
if ($installedVersions -notmatch [regex]::Escape($versionPrefix)) {
Write-Host "Installing Python $targetVer via pyenv-win..." -ForegroundColor Yellow
& $pyenv install $targetVer -q
} else {
Write-Host "Python $targetVer already installed via pyenv." -ForegroundColor Green
}
$currentGlobal = ((& $pyenv global 2>&1) | Out-String).Trim()
if ($currentGlobal -notmatch [regex]::Escape($versionPrefix)) {
& $pyenv global $targetVer
Write-Host "pyenv-win global version set to $targetVer" -ForegroundColor Green
} else {
Write-Host "pyenv-win global already set to $currentGlobal" -ForegroundColor Green
}
Write-Host "=== Bootstrap complete ===" -ForegroundColor Cyan
+19 -15
View File
@@ -9,8 +9,10 @@
# 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)
# Set build arguments
ARG FPM_VERSION=1.16.0
ARG PYTHON_VERSION=3.14.3
ARG PYTHON_SHA256=d7fe130d0501ae047ca318fa92aa642603ab6f217901015a1df6ce650d5470cd
# Install system dependencies (Arch) including Python build dependencies
RUN pacman -Syu --noconfirm \
@@ -56,24 +58,26 @@ RUN gem install --no-document -v "${FPM_VERSION}" fpm && \
# Install pyenv
ENV PYENV_ROOT="/root/.pyenv"
ENV PATH="$PYENV_ROOT/bin:$PATH"
# Download Python source and verify SHA256 checksum against python.org
RUN wget -q "https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz" \
-O /tmp/Python-${PYTHON_VERSION}.tgz && \
echo "${PYTHON_SHA256} /tmp/Python-${PYTHON_VERSION}.tgz" | sha256sum -c -
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 -)" && \
# Build and install Python from source
RUN cd /tmp && tar xzf Python-${PYTHON_VERSION}.tgz && \
cd Python-${PYTHON_VERSION} && \
LDFLAGS="-L/usr/lib" \
CPPFLAGS="-I/usr/include" \
PYTHON_CONFIGURE_OPTS="--enable-shared" \
pyenv install 3.13 && \
pyenv global 3.13 && \
pyenv rehash
./configure --enable-shared --with-ensurepip=install --prefix=/usr/local && \
make -j$(nproc) && \
make install && \
echo "/usr/local/lib" > /etc/ld.so.conf.d/python.conf && \
ldconfig && \
ln -sf /usr/local/bin/python3 /usr/local/bin/python && \
rm -rf /tmp/Python-${PYTHON_VERSION} /tmp/Python-${PYTHON_VERSION}.tgz
# Update PATH to include pyenv shims
ENV PATH="/root/.pyenv/shims:$PATH"
# Update PATH to include Python installation
ENV PATH="/usr/local/bin:$PATH"
# Verify Python has tkinter support
RUN python3 -c "import tkinter; import _tkinter; print('tkinter support verified')" || \
+19 -15
View File
@@ -8,8 +8,10 @@
# Use Debian 13 "Trixie" (latest stable release)
FROM debian:13
# Set build argument for fpm version (can be overridden at build time)
# Set build arguments
ARG FPM_VERSION=1.16.0
ARG PYTHON_VERSION=3.14.3
ARG PYTHON_SHA256=d7fe130d0501ae047ca318fa92aa642603ab6f217901015a1df6ce650d5470cd
# Install system dependencies including Python build dependencies
RUN apt-get update && \
@@ -41,24 +43,26 @@ RUN apt-get update && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install pyenv
ENV PYENV_ROOT="/root/.pyenv"
ENV PATH="$PYENV_ROOT/bin:$PATH"
# Download Python source and verify SHA256 checksum against python.org
RUN wget -q "https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz" \
-O /tmp/Python-${PYTHON_VERSION}.tgz && \
echo "${PYTHON_SHA256} /tmp/Python-${PYTHON_VERSION}.tgz" | sha256sum -c -
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 -)" && \
# Build and install Python from source
RUN cd /tmp && tar xzf Python-${PYTHON_VERSION}.tgz && \
cd Python-${PYTHON_VERSION} && \
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
./configure --enable-shared --with-ensurepip=install --prefix=/usr/local && \
make -j$(nproc) && \
make install && \
echo "/usr/local/lib" > /etc/ld.so.conf.d/python.conf && \
ldconfig && \
ln -sf /usr/local/bin/python3 /usr/local/bin/python && \
rm -rf /tmp/Python-${PYTHON_VERSION} /tmp/Python-${PYTHON_VERSION}.tgz
# Update PATH to include pyenv shims
ENV PATH="/root/.pyenv/shims:$PATH"
# Update PATH to include Python installation
ENV PATH="/usr/local/bin:$PATH"
# Verify Python has tkinter support
RUN python3 -c "import tkinter; import _tkinter; print('tkinter support verified')" || \
+19 -15
View File
@@ -7,8 +7,10 @@
FROM fedora:42
# Set build argument for fpm version (can be overridden at build time)
# Set build arguments
ARG FPM_VERSION=1.16.0
ARG PYTHON_VERSION=3.14.3
ARG PYTHON_SHA256=d7fe130d0501ae047ca318fa92aa642603ab6f217901015a1df6ce650d5470cd
# 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
@@ -42,24 +44,26 @@ RUN dnf -y update && \
libXrender-devel && \
dnf clean all
# Install pyenv
ENV PYENV_ROOT="/root/.pyenv"
ENV PATH="$PYENV_ROOT/bin:$PATH"
# Download Python source and verify SHA256 checksum against python.org
RUN wget -q "https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz" \
-O /tmp/Python-${PYTHON_VERSION}.tgz && \
echo "${PYTHON_SHA256} /tmp/Python-${PYTHON_VERSION}.tgz" | sha256sum -c -
RUN git clone https://github.com/pyenv/pyenv.git /root/.pyenv
# Install Python 3.12 via pyenv with tkinter support
# The tk8-devel package must be installed before this step for _tkinter to be compiled
RUN eval "$(pyenv init -)" && \
# Build and install Python from source
RUN cd /tmp && tar xzf Python-${PYTHON_VERSION}.tgz && \
cd Python-${PYTHON_VERSION} && \
LDFLAGS="-L/usr/lib64" \
CPPFLAGS="-I/usr/include" \
PYTHON_CONFIGURE_OPTS="--enable-shared" \
pyenv install 3.13 && \
pyenv global 3.13 && \
pyenv rehash
./configure --enable-shared --with-ensurepip=install --prefix=/usr/local && \
make -j$(nproc) && \
make install && \
echo "/usr/local/lib" > /etc/ld.so.conf.d/python.conf && \
ldconfig && \
ln -sf /usr/local/bin/python3 /usr/local/bin/python && \
rm -rf /tmp/Python-${PYTHON_VERSION} /tmp/Python-${PYTHON_VERSION}.tgz
# Update PATH to include pyenv shims
ENV PATH="/root/.pyenv/shims:$PATH"
# Update PATH to include Python installation
ENV PATH="/usr/local/bin:$PATH"
# Verify Python has tkinter support
RUN python3 -c "import tkinter; import _tkinter; print('tkinter support verified')" || \
+130
View File
@@ -0,0 +1,130 @@
# Container Build Environment
This directory contains OCI-compatible Containerfiles (Dockerfiles) for building the Android File Handler on different Linux distributions. These images match exactly the images used in the CI/CD pipeline.
The project uses **Podman** as the container runtime. All commands below use Podman; if you have Docker installed, the Dockerfiles are OCI-compatible and will work with Docker as well.
## Quick Start
### Using Podman Compose (Recommended)
Build for all distributions:
```bash
podman-compose up --build
```
Build for a specific distribution:
```bash
podman-compose up --build debian
podman-compose up --build arch
podman-compose up --build rhel
```
### Using Prefect + Dagger (CI Pipeline Locally)
The CI/CD pipeline uses Prefect and Dagger to orchestrate builds. You can run it locally:
```bash
poetry install --with ci
poetry run python -m ci.prefect_flow build-linux
```
### Manual Podman Build
Build the image:
```bash
# Debian
podman build -f scripts/docker/Dockerfile.debian -t android-file-handler-debian-builder .
# Arch
podman build -f scripts/docker/Dockerfile.arch -t android-file-handler-arch-builder .
# RHEL/Fedora
podman build -f scripts/docker/Dockerfile.rhel -t android-file-handler-rhel-builder .
```
Run the build:
```bash
# Debian
podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-debian-builder
# Arch
podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-arch-builder
# RHEL/Fedora
podman run --rm -v $(pwd):/workspace:Z -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 Podman 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 podman-compose.yml configuration.
### SELinux (Fedora/RHEL hosts)
Volume mounts use the `:Z` suffix to apply the correct SELinux labels automatically. If you encounter permission errors, ensure the `:Z` suffix is present on volume mounts.
## Cleaning Up
Remove build artifacts:
```bash
rm -rf dist pkg_dist_* dist_*
```
Remove Podman containers and volumes:
```bash
podman-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
@@ -19,7 +19,7 @@ if command -v gtk-update-icon-cache >/dev/null 2>&1; then
fi
# Ensure installed binary is executable
if [ -f /usr/local/bin/android-file-handler ]; then
if [ -f /usr/bin/android-file-handler ]; then
chmod 0755 /usr/bin/android-file-handler || true
fi
@@ -1,10 +1,13 @@
import os
block_cipher = None
SPEC_DIR = os.path.dirname(os.path.abspath(SPEC))
SRC_DIR = os.path.normpath(os.path.join(SPEC_DIR, '..', '..', 'src'))
a = Analysis(
['../../src/main.py'],
pathex=['src'],
[os.path.join(SRC_DIR, 'main.py')],
pathex=[SRC_DIR],
binaries=[],
datas=[
('../../src/gui', 'gui'),
(os.path.join(SRC_DIR, 'gui'), 'gui'),
],
hiddenimports=[
# GUI modules
@@ -33,7 +36,8 @@ a = Analysis(
'managers.transfer_manager',
# Utility modules
'utils',
'utils.file_deduplication'
'utils.file_deduplication',
'utils.security_utils'
],
hookspath=[],
hooksconfig={},
@@ -1,12 +1,15 @@
# -*- mode: python ; coding: utf-8 -*-
import os
block_cipher = None
SPEC_DIR = os.path.dirname(os.path.abspath(SPEC))
SRC_DIR = os.path.normpath(os.path.join(SPEC_DIR, '..', '..', 'src'))
a = Analysis(
['../../src/main.py'],
pathex=['src'],
[os.path.join(SRC_DIR, 'main.py')],
pathex=[SRC_DIR],
binaries=[],
datas=[
('../../src/gui', 'gui'),
('../../scripts/debian_postinst.sh', 'scripts'),
(os.path.join(SRC_DIR, 'gui'), 'gui'),
(os.path.join(SPEC_DIR, '..', 'debian_postinst.sh'), 'scripts'),
],
hiddenimports=[
# GUI modules
@@ -35,7 +38,8 @@ a = Analysis(
'managers.transfer_manager',
# Utility modules
'utils',
'utils.file_deduplication'
'utils.file_deduplication',
'utils.security_utils'
],
hookspath=[],
hooksconfig={},
@@ -1,11 +1,14 @@
import os
block_cipher = None
SPEC_DIR = os.path.dirname(os.path.abspath(SPEC))
SRC_DIR = os.path.normpath(os.path.join(SPEC_DIR, '..', '..', 'src'))
a = Analysis(
['../../src/main.py'],
pathex=['src'],
[os.path.join(SRC_DIR, 'main.py')],
pathex=[SRC_DIR],
binaries=[],
datas=[
('../../src/gui', 'gui'),
('../../scripts/rhel_postinst.sh', 'scripts'),
(os.path.join(SRC_DIR, 'gui'), 'gui'),
(os.path.join(SPEC_DIR, '..', 'rhel_postinst.sh'), 'scripts'),
],
hiddenimports=[
# GUI modules
@@ -34,7 +37,8 @@ a = Analysis(
'managers.transfer_manager',
# Utility modules
'utils',
'utils.file_deduplication'
'utils.file_deduplication',
'utils.security_utils'
],
hookspath=[],
hooksconfig={},
@@ -1,12 +1,14 @@
# -*- mode: python ; coding: utf-8 -*-
import os
block_cipher = None
SPEC_DIR = os.path.dirname(os.path.abspath(SPEC))
SRC_DIR = os.path.normpath(os.path.join(SPEC_DIR, '..', '..', 'src'))
a = Analysis(
['../../src/main.py'],
pathex=['../..'],
[os.path.join(SRC_DIR, 'main.py')],
pathex=[SRC_DIR],
binaries=[],
datas=[
('../../src/gui', 'gui'),
('../windows/first_run_install.ps1', 'scripts/windows')
(os.path.join(SRC_DIR, 'gui'), 'gui'),
],
hiddenimports=[
# GUI modules
@@ -35,7 +37,8 @@ a = Analysis(
'managers.transfer_manager',
# Utility modules
'utils',
'utils.file_deduplication'
'utils.file_deduplication',
'utils.security_utils'
],
hookspath=[],
hooksconfig={},
@@ -67,5 +70,5 @@ exe = EXE(
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='../../icon_media/robot_files_256.ico',
icon=os.path.join(SPEC_DIR, '..', '..', 'icon_media', 'robot_files_256.ico'),
)
@@ -0,0 +1,61 @@
; Inno Setup script for Android File Handler
; Compiles a Windows installer from the PyInstaller one-file executable.
;
; Usage (CI):
; iscc scripts\windows\android-file-handler-setup.iss /DMyAppVersion=1.2.3
;
; Usage (local, from repo root):
; "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" scripts\windows\android-file-handler-setup.iss
#ifndef MyAppVersion
#define MyAppVersion "0.1.1"
#endif
#define MyAppName "Android File Handler"
#define MyAppPublisher "Jason Ross"
#define MyAppURL "https://github.com/JMR-dev/android-file-handler"
#define MyAppExeName "android-file-handler-windows.exe"
[Setup]
AppId={{8F2B3A7E-4D1C-4E8F-9A2B-6C7D8E9F0A1B}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
; AppVerName controls the registry DisplayName value. Without it Inno Setup
; defaults to "AppName version AppVersion" which breaks exact-name lookups.
; Windows convention: DisplayName = product name, DisplayVersion = version.
AppVerName={#MyAppName}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
LicenseFile=..\..\LICENSE.txt
OutputDir=..\..\dist
OutputBaseFilename=android-file-handler-setup
SetupIconFile=..\..\icon_media\robot_files_256.ico
UninstallDisplayIcon={app}\{#MyAppExeName}
Compression=lzma2/normal
LZMADictionarySize=16384
SolidCompression=yes
WizardStyle=modern
ArchitecturesInstallIn64BitMode=x64compatible
PrivilegesRequired=admin
MinVersion=10.0
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "..\..\dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
-51
View File
@@ -1,51 +0,0 @@
param(
[string]$ExePath = "$PSScriptRoot\..\..\dist\android-file-handler.exe",
[string]$IconPath = "$PSScriptRoot\..\..\assets\icons\android-file-handler.ico",
[string]$AppName = "Android File Handler"
)
function Ensure-Elevated {
if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
# Relaunch the script with elevation
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "powershell.exe"
$psi.Arguments = "-ExecutionPolicy Bypass -File `"$PSCommandPath`""
$psi.Verb = "runas"
try {
[System.Diagnostics.Process]::Start($psi) | Out-Null
Exit 0
} catch {
Write-Error "Elevation required to install to Program Files."
Exit 1
}
}
}
Ensure-Elevated
$destDir = Join-Path ${env:ProgramFiles} $AppName
if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir | Out-Null }
$resolvedExe = Resolve-Path -Path $ExePath -ErrorAction SilentlyContinue
if (-not $resolvedExe) {
Write-Error "Application executable not found at $ExePath"
Exit 1
}
Copy-Item -Path $resolvedExe -Destination (Join-Path $destDir (Split-Path $resolvedExe -Leaf)) -Force
# Create Start Menu shortcut
$programs = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs'
$appFolder = Join-Path $programs $AppName
if (-not (Test-Path $appFolder)) { New-Item -ItemType Directory -Path $appFolder | Out-Null }
$shortcutPath = Join-Path $appFolder "$AppName.lnk"
$wsh = New-Object -ComObject WScript.Shell
$sc = $wsh.CreateShortcut($shortcutPath)
$sc.TargetPath = (Join-Path $destDir (Split-Path $resolvedExe -Leaf))
$sc.WorkingDirectory = $destDir
if (Test-Path $IconPath) { $sc.IconLocation = Resolve-Path $IconPath }
$sc.Save()
Write-Output "Installed $AppName to $destDir and created Start Menu shortcut."
Exit 0
+334
View File
@@ -0,0 +1,334 @@
# run_vagrant_tests.ps1 — Host-side orchestrator for Vagrant Windows 11 test cycle.
#
# Workflow:
# 1. Build the installer locally (PyInstaller + Inno Setup)
# 2. Ensure the Vagrant VM is running and has a "clean" snapshot
# 3. Restore to the clean snapshot
# 4. Transfer build assets to the VM and compile the installer (Inno Setup)
# 5. Run the test script inside the VM via PSSession
# 6. Retrieve screenshots and results to the host
# 7. Print results summary to CLI
# 8. Revert VM back to clean snapshot
#
# Usage (from repo root):
# .\scripts\windows\run_vagrant_tests.ps1
# .\scripts\windows\run_vagrant_tests.ps1 -SkipBuild
# .\scripts\windows\run_vagrant_tests.ps1 -SkipRevert
#
# Prerequisites:
# - Vagrant and Hyper-V enabled on the host
# - Poetry environment set up
# - The VM created and "clean" snapshot saved:
# cd vagrant && vagrant up --provider=hyperv && vagrant snapshot save clean
# Note: Inno Setup 6.7.1 is installed inside the VM by provision.ps1 — no host install needed.
[CmdletBinding()]
param(
[switch]$SkipBuild,
[switch]$SkipRevert,
[string]$SnapshotName = "clean",
[string]$LocalResultsDir = ""
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$vmSession = $null # PSSession — closed in finally block
$VagrantDir = Join-Path $RepoRoot "vagrant"
$Timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
if (-not $LocalResultsDir) {
$LocalResultsDir = Join-Path $RepoRoot "vagrant_test_results\$Timestamp"
}
function Write-Step {
param([string]$Message)
Write-Host "`n>>> $Message" -ForegroundColor Cyan
}
# ── Ensure we're in the vagrant directory ────────────────────────────────────
Push-Location $VagrantDir
try {
# ── Step 1: Build the Windows executable (host, PyInstaller only) ────────
# Inno Setup runs inside the VM — no host install of Inno Setup required.
$pyinstallerExe = Join-Path $RepoRoot "dist\android-file-handler-windows.exe"
$AppVersion = ""
if ($SkipBuild) {
Write-Step "Skipping PyInstaller build (-SkipBuild specified)"
if (-not (Test-Path $pyinstallerExe)) {
Write-Host "ERROR: -SkipBuild was specified but exe not found at $pyinstallerExe" -ForegroundColor Red
exit 1
}
}
else {
Write-Step "Building Windows executable (PyInstaller)"
Push-Location $RepoRoot
try {
Write-Host " Running PyInstaller..." -ForegroundColor Yellow
poetry run pyinstaller scripts\spec_scripts\android-file-handler-windows.spec --noconfirm
if ($LASTEXITCODE -ne 0) {
Write-Host "ERROR: PyInstaller failed with exit code $LASTEXITCODE" -ForegroundColor Red
exit 1
}
if (-not (Test-Path $pyinstallerExe)) {
Write-Host "ERROR: PyInstaller output not found at $pyinstallerExe" -ForegroundColor Red
exit 1
}
Write-Host " PyInstaller output: $pyinstallerExe" -ForegroundColor Green
}
finally {
Pop-Location
}
}
# Capture version to pass to Inno Setup inside the VM
Push-Location $RepoRoot
try {
$AppVersion = (poetry version -s).Trim()
Write-Host " App version: $AppVersion" -ForegroundColor DarkGray
}
finally {
Pop-Location
}
# ── Step 2: Verify VM and snapshot exist ─────────────────────────────────
Write-Step "Checking Vagrant VM status"
$status = vagrant status --machine-readable 2>&1 |
Select-String "state,running" -Quiet
if (-not $status) {
Write-Host "VM is not running. Starting it..." -ForegroundColor Yellow
vagrant up --provider=hyperv
}
Write-Step "Verifying '$SnapshotName' snapshot exists"
$snapshots = vagrant snapshot list 2>&1
if ($snapshots -notmatch [regex]::Escape($SnapshotName)) {
Write-Host "Snapshot '$SnapshotName' not found. Creating it now..." -ForegroundColor Yellow
vagrant snapshot save $SnapshotName
}
# ── Step 3: Restore to clean snapshot ────────────────────────────────────
Write-Step "Restoring VM to '$SnapshotName' snapshot"
vagrant snapshot restore $SnapshotName
# ── Establish direct PSSession ────────────────────────────────────────────
# vagrant winrm uses a subprocess whose args are subject to the Windows
# command-line length limit (~32 KB). Large file transfers exceed that limit.
# A direct PSSession has no such restriction and supports
# Copy-Item -ToSession / -FromSession for binary files of any size.
Write-Step "Establishing PSSession to VM"
# Ensure host WinRM client is configured to allow Basic authentication
# This is required for the connection to succeed when using -Authentication Basic
if ((Get-Item WSMan:\localhost\Client\Auth\Basic).Value -ne "true") {
Write-Host " Enabling WinRM Client Basic authentication on host..." -ForegroundColor Yellow
Set-Item WSMan:\localhost\Client\Auth\Basic -Value $true -Force
}
$remoteBuildRoot = "C:\vagrant_build"
$remoteInstallerPath = "$remoteBuildRoot\dist\android-file-handler-setup.exe"
$remoteResultsDir = "C:\vagrant_test_results"
# vagrant winrm-config reports the plaintext port (5985) used by Vagrant itself.
# We extract the host and port, then derive the HTTPS port (typically +1).
# If the direct IP fails, we fallback to the forwarded port on localhost.
$winrmConfig = & vagrant winrm-config 2>$null | Out-String
$vmHost = if ($winrmConfig -match 'HostName\s+(\S+)') { $Matches[1] } else { '127.0.0.1' }
$vmPort = if ($winrmConfig -match 'Port\s+(\d+)') { [int]$Matches[1] + 1 } else { 5986 }
# If we have a direct IP but it's not reachable, fallback to the forwarded port
if ($vmHost -ne '127.0.0.1' -and -not (Test-NetConnection -ComputerName $vmHost -Port $vmPort -InformationLevel Quiet)) {
Write-Host " Direct IP $vmHost`:$vmPort unreachable. Falling back to localhost..." -ForegroundColor Yellow
$vmHost = '127.0.0.1'
$vmPort = 55986 # The host-side forwarded port for 5986
}
# Verify connectivity before trying PSSession
if (-not (Test-NetConnection -ComputerName $vmHost -Port $vmPort -InformationLevel Quiet)) {
Write-Host "ERROR: Could not reach VM at $vmHost`:$vmPort. Ensure 'vagrant up' and provisioning completed successfully." -ForegroundColor Red
exit 1
}
$vagrantCred = New-Object PSCredential 'vagrant',
(ConvertTo-SecureString 'vagrant' -AsPlainText -Force)
# SkipCACheck / SkipCNCheck accept the self-signed cert created by provision.ps1.
$sessionOpts = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
$vmSession = New-PSSession -ComputerName $vmHost -Port $vmPort `
-Credential $vagrantCred -Authentication Basic `
-UseSSL -SessionOption $sessionOpts
Write-Host " Connected to VM at $vmHost`:$vmPort" -ForegroundColor DarkGray
# ── Step 4: Transfer build assets to VM and compile installer ────────────
Write-Step "Transferring build assets to VM"
Invoke-Command -Session $vmSession -ScriptBlock {
param($root)
@("$root\dist", "$root\scripts\windows", "$root\icon_media") |
ForEach-Object { New-Item -ItemType Directory -Force -Path $_ | Out-Null }
} -ArgumentList $remoteBuildRoot
$transfers = @(
@{ L = Join-Path $RepoRoot "dist\android-file-handler-windows.exe"; R = "$remoteBuildRoot\dist\android-file-handler-windows.exe" },
@{ L = Join-Path $RepoRoot "scripts\windows\android-file-handler-setup.iss"; R = "$remoteBuildRoot\scripts\windows\android-file-handler-setup.iss" },
@{ L = Join-Path $RepoRoot "LICENSE.txt"; R = "$remoteBuildRoot\LICENSE.txt" },
@{ L = Join-Path $RepoRoot "icon_media\robot_files_256.ico"; R = "$remoteBuildRoot\icon_media\robot_files_256.ico" },
@{ L = Join-Path $RepoRoot "vagrant\test_installer.ps1"; R = "$remoteBuildRoot\test_installer.ps1" }
)
foreach ($t in $transfers) {
Copy-Item -Path $t.L -Destination $t.R -ToSession $vmSession -Force
$sizeMB = [math]::Round((Get-Item $t.L).Length / 1MB, 1)
Write-Host " Sent: $(Split-Path -Leaf $t.L) (${sizeMB} MB)" -ForegroundColor DarkGray
}
# Unblock transferred files — Copy-Item -ToSession can mark files with a
# Zone.Identifier ADS that execution policy may block even under -Scope Process Bypass.
Invoke-Command -Session $vmSession -ScriptBlock {
param($root)
Get-ChildItem -Path $root -Recurse -File |
ForEach-Object { Unblock-File -Path $_.FullName -ErrorAction SilentlyContinue }
} -ArgumentList $remoteBuildRoot
Write-Host " Files unblocked on VM." -ForegroundColor DarkGray
Write-Step "Building installer inside the VM (Inno Setup 6.7.1)"
Invoke-Command -Session $vmSession -ScriptBlock {
param($buildRoot, $version)
& 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe' `
"$buildRoot\scripts\windows\android-file-handler-setup.iss" `
"/DMyAppVersion=$version"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
} -ArgumentList $remoteBuildRoot, $AppVersion
Write-Host " Installer built: $remoteInstallerPath" -ForegroundColor Green
# ── Step 5: Run tests inside the VM ──────────────────────────────────────
Write-Step "Running test suite inside the VM"
$testExitCode = 0
try {
Invoke-Command -Session $vmSession -ScriptBlock {
param($buildRoot, $installer)
Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -Force
& "$buildRoot\test_installer.ps1" -InstallerPath $installer
# Propagate the test script's exit code as output so the host can inspect it.
$LASTEXITCODE
} -ArgumentList $remoteBuildRoot, $remoteInstallerPath |
ForEach-Object {
if ($_ -is [int]) { $testExitCode = $_ }
}
}
catch {
$testExitCode = 1
Write-Host "Test script encountered an error:" -ForegroundColor Yellow
Write-Host " $($_.Exception.Message)" -ForegroundColor Red
Write-Host " Category : $($_.CategoryInfo.Category)" -ForegroundColor Red
Write-Host " FullError: $_" -ForegroundColor Red
}
# Re-establish the PSSession if the test run left it in a non-Opened state.
# An unhandled exception inside Invoke-Command can terminate the remote runspace.
if ($vmSession.State -ne 'Opened') {
Write-Host " PSSession is '$($vmSession.State)' — re-establishing for results retrieval..." -ForegroundColor Yellow
Remove-PSSession $vmSession -ErrorAction SilentlyContinue
$vmSession = New-PSSession -ComputerName $vmHost -Port $vmPort `
-Credential $vagrantCred -Authentication Basic `
-UseSSL -SessionOption $sessionOpts
Write-Host " Session re-established." -ForegroundColor DarkGray
}
# ── Step 6: Retrieve results and screenshots ─────────────────────────────
Write-Step "Retrieving test results and screenshots"
if (-not (Test-Path $LocalResultsDir)) {
New-Item -ItemType Directory -Path $LocalResultsDir -Force | Out-Null
}
try {
$hasResults = Invoke-Command -Session $vmSession -ScriptBlock {
param($dir) Test-Path $dir
} -ArgumentList $remoteResultsDir
if ($hasResults) {
Copy-Item -Path "$remoteResultsDir\*" -Destination $LocalResultsDir `
-FromSession $vmSession -Recurse -Force
Write-Host " Retrieved results from $remoteResultsDir" -ForegroundColor DarkGray
}
else {
Write-Host " No results directory found on VM." -ForegroundColor Yellow
}
}
catch {
Write-Host " Warning: Could not retrieve some result files: $_" -ForegroundColor Yellow
}
# ── Step 7: Print local summary ──────────────────────────────────────────
Write-Step "Test Results"
$csvPath = Join-Path $LocalResultsDir "test_results.csv"
if (Test-Path $csvPath) {
$results = Import-Csv $csvPath
$results | Format-Table -AutoSize
$passCount = @($results | Where-Object { $_.Status -eq "PASS" }).Count
$failCount = @($results | Where-Object { $_.Status -eq "FAIL" }).Count
Write-Host " Passed: $passCount | Failed: $failCount | Total: $($passCount + $failCount)" -ForegroundColor $(if ($failCount -gt 0) { "Red" } else { "Green" })
}
else {
Write-Host " No results CSV found. Check VM output above." -ForegroundColor Yellow
}
Write-Host "`n Results directory: $LocalResultsDir" -ForegroundColor White
# List screenshots
$screenshots = Get-ChildItem $LocalResultsDir -Filter "*.png" -ErrorAction SilentlyContinue
if ($screenshots) {
Write-Host " Screenshots:" -ForegroundColor White
foreach ($shot in $screenshots) {
Write-Host " - $($shot.FullName)" -ForegroundColor DarkGray
}
}
# ── Step 8: Revert to clean snapshot ─────────────────────────────────────
if (-not $SkipRevert) {
Write-Step "Reverting VM to '$SnapshotName' snapshot"
vagrant snapshot restore $SnapshotName
Write-Host "VM reverted to clean state." -ForegroundColor Green
}
else {
Write-Host "`n -SkipRevert specified, VM left in current state." -ForegroundColor Yellow
}
# ── Exit ─────────────────────────────────────────────────────────────────
Write-Host ""
if ($testExitCode -ne 0) {
Write-Host "One or more tests FAILED." -ForegroundColor Red
exit 1
}
else {
Write-Host "All tests PASSED." -ForegroundColor Green
exit 0
}
}
finally {
if ($vmSession) { Remove-PSSession $vmSession -ErrorAction SilentlyContinue }
Pop-Location
}
+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
+165 -117
View File
@@ -12,10 +12,10 @@ 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
@@ -40,7 +40,7 @@ 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()
@@ -53,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
@@ -81,68 +81,70 @@ 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: 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: Optional[str] = None) -> list[dict]:
"""List files in the specified path on the device."""
# Sanitize inputs to prevent command injection
@@ -174,58 +176,64 @@ class ADBManager:
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: Optional[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
@@ -236,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:
@@ -245,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: Optional[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
@@ -256,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:
@@ -265,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: Optional[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:
@@ -282,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: Optional[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:
@@ -299,8 +319,10 @@ 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: Optional[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:
@@ -328,7 +350,9 @@ class ADBManager:
except Exception as e:
return False, f"Error deleting file: {str(e)}"
def create_folder(self, remote_path: str, device_id: Optional[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:
@@ -356,7 +380,9 @@ class ADBManager:
except Exception as e:
return False, f"Error creating folder: {str(e)}"
def delete_folder(self, remote_path: str, device_id: Optional[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:
@@ -384,7 +410,9 @@ class ADBManager:
except Exception as e:
return False, f"Error deleting folder: {str(e)}"
def move_item(self, old_path: str, new_path: str, device_id: Optional[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:
@@ -413,7 +441,9 @@ class ADBManager:
except Exception as e:
return False, f"Error moving item: {str(e)}"
def get_file_info(self, remote_path: str, device_id: Optional[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:
@@ -443,50 +473,54 @@ class ADBManager:
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 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]]:
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:
@@ -500,13 +534,19 @@ class ADBManager:
"""
# 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
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]]:
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:
@@ -520,11 +560,17 @@ class ADBManager:
"""
# 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
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]:
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()
@@ -564,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()
@@ -589,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:
@@ -598,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():
+68 -35
View File
@@ -22,7 +22,7 @@ except ImportError:
class ADBFileTransfer(ProgressTracker):
"""Handles ADB file transfer operations with progress tracking."""
def __init__(self):
ProgressTracker.__init__(self)
# Composition: allow tests to patch ADBCommandRunner constructor
@@ -43,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.
@@ -54,13 +54,17 @@ 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."""
# Sanitize paths to prevent injection and traversal
@@ -86,9 +90,11 @@ class ADBFileTransfer(ProgressTracker):
self.update_status(f"Failed to create local directory: {e}")
pass
result = self.runner.run_adb_command(['pull', sanitized_remote, sanitized_local])
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."""
# Sanitize paths to prevent injection and traversal
@@ -102,9 +108,11 @@ class ADBFileTransfer(ProgressTracker):
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])
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."""
# Sanitize paths to prevent injection and traversal
@@ -123,9 +131,11 @@ class ADBFileTransfer(ProgressTracker):
except Exception:
# Ignore directory creation failures for test environment
pass
result = self.runner.run_adb_command(['pull', sanitized_remote, sanitized_local])
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."""
# Sanitize paths to prevent injection and traversal
@@ -138,9 +148,11 @@ class ADBFileTransfer(ProgressTracker):
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])
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:
@@ -165,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:
@@ -179,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)
@@ -213,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)
@@ -240,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:
@@ -269,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
@@ -281,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
@@ -318,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
@@ -341,4 +374,4 @@ class ADBFileTransfer(ProgressTracker):
return True
except Exception:
return False
return False
return False
+33 -11
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)
@@ -102,8 +110,12 @@ def ensure_platform_tools_in_user_dir(version_tag: Optional[str] = "latest") ->
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():
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")
@@ -137,10 +149,17 @@ def ensure_platform_tools_in_user_dir(version_tag: Optional[str] = "latest") ->
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
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):
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)
@@ -151,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
@@ -212,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
@@ -248,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")
+39 -29
View File
@@ -22,9 +22,9 @@ 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]) -> None:
@@ -84,8 +84,8 @@ class ProgressTracker:
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:
@@ -94,14 +94,14 @@ class ProgressTracker:
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) -> 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.
"""
"""
+149 -132
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,43 +174,43 @@ 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 = (
@@ -223,10 +219,12 @@ class DialogManager:
"Settings → Developer Options → disable 'USB debugging'."
)
messagebox.showinfo("Disable USB Debugging", msg)
def show_transfer_stats(self, stats: dict, operation: str, deduplicator=None) -> None:
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")
@@ -234,98 +232,104 @@ class DialogManager:
"""
# 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)"
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:
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}"
f"Duplicate files skipped: {stats['skipped']}{bytes_saved_str}",
]
if stats['skipped'] > 0:
message_parts.append(f"\nDuplicate detection helped avoid unnecessary transfers!")
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 +341,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")
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):
"""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()
tk.Button(main_frame, text="Close", command=stats_window.destroy).pack()
def show_disable_debugging_reminder(self):
"""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.",
)
+30 -71
View File
@@ -9,9 +9,6 @@ import tkinter as tk
from tkinter import messagebox, scrolledtext
import tempfile
import stat
import subprocess
import sys
import os
def get_license_file_path() -> str:
@@ -107,42 +104,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE."""
def resource_path(relative_path: str) -> str:
"""Return absolute path to resource for dev and frozen runs."""
try:
if getattr(sys, "frozen", False):
base = getattr(sys, "_MEIPASS", os.path.dirname(sys.executable))
else:
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))
def run_windows_first_run_if_needed() -> None:
"""If running on Windows and license not agreed, launch first-run installer script.
Uses `resource_path` to locate the bundled PowerShell script in both dev and frozen modes.
"""
try:
if not sys.platform.startswith("win"):
return
if check_license_agreement():
return
script_rel = os.path.join("scripts", "windows", "first_run_install.ps1")
script_path = resource_path(script_rel)
if not os.path.exists(script_path):
return
try:
subprocess.Popen(["powershell.exe", "-ExecutionPolicy", "Bypass", "-File", script_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception:
pass
except Exception:
pass
class LicenseAgreementFrame(tk.Frame):
"""License agreement UI frame that can be embedded in the main window."""
@@ -156,106 +117,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
+94 -73
View File
@@ -21,8 +21,11 @@ try:
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
@@ -32,8 +35,11 @@ except ImportError:
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
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
-3
View File
@@ -6,12 +6,9 @@ Simple entry point to launch the Android file transfer application.
try:
from gui.main_window import main
from gui.dialogs.license_agreement import run_windows_first_run_if_needed
except ImportError:
from .gui.main_window import main
from .gui.dialogs.license_agreement import run_windows_first_run_if_needed
if __name__ == "__main__":
run_windows_first_run_if_needed()
main()
+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
+127 -73
View File
@@ -19,11 +19,16 @@ except ImportError:
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,41 +39,46 @@ 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:
@@ -91,18 +101,18 @@ class TransferManager:
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),
daemon=True
daemon=True,
)
transfer_thread.start()
return True
def _is_remote_file(self, remote_path: str) -> bool:
"""Check if a remote path is a file.
@@ -121,63 +131,76 @@ class TransferManager:
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
@@ -189,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:
@@ -197,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"
@@ -213,31 +243,50 @@ 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(),
self.device_manager.adb_manager.deduplicator,
),
)
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
@@ -245,7 +294,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)
"""
@@ -264,7 +313,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
@@ -273,19 +322,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
"""
@@ -297,10 +346,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:
@@ -308,9 +360,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)
@@ -322,6 +376,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")
"""
+21 -12
View File
@@ -8,8 +8,8 @@ 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]|\$[({]|&&|\|\||>>')
_DANGEROUS_CHAR_PATTERN = re.compile(r"[;|&$`\n\r><(){}[\]!]")
_DANGEROUS_PATH_PATTERN = re.compile(r"[;|&`\n\r]|\$[({]|&&|\|\||>>")
def sanitize_path_component(component: str) -> str:
@@ -28,17 +28,19 @@ def sanitize_path_component(component: str) -> str:
raise ValueError("Path component cannot be empty")
# Check for null bytes
if '\x00' in component:
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()}")
raise ValueError(
f"Path component contains dangerous character: {match.group()}"
)
# Check for command substitution patterns
if '$(' in component or '${' in component:
if "$(" in component or "${" in component:
raise ValueError("Path component contains command substitution pattern")
return component
@@ -63,7 +65,7 @@ def sanitize_android_path(path: str) -> str:
path = path.strip()
# Check for null bytes
if '\x00' in path:
if "\x00" in path:
raise ValueError("Path contains null byte")
# Check for command injection patterns using pre-compiled regex
@@ -83,7 +85,9 @@ def sanitize_android_path(path: str) -> str:
return path
def sanitize_local_path(path: str, base_dir: Optional[str] = None, allow_nonexistent: bool = True) -> str:
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:
@@ -104,7 +108,7 @@ def sanitize_local_path(path: str, base_dir: Optional[str] = None, allow_nonexis
path = path.strip()
# Check for null bytes
if '\x00' in path:
if "\x00" in path:
raise ValueError("Path contains null byte")
# Normalize the path to resolve .. and symlinks
@@ -131,8 +135,13 @@ def sanitize_local_path(path: str, base_dir: Optional[str] = None, allow_nonexis
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")
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}")
@@ -155,11 +164,11 @@ def validate_device_id(device_id: str) -> str:
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):
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', ' ', '>', '<']
dangerous_chars = [";", "|", "&", "$", "`", "\n", "\r", " ", ">", "<"]
for char in dangerous_chars:
if char in device_id:
raise ValueError(f"Device ID contains dangerous character: {char}")
+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()
+163 -159
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,233 +182,223 @@ 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 == []
@@ -436,7 +436,9 @@ class TestADBManagerSecurityIntegration:
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}"
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."""
@@ -465,7 +467,9 @@ class TestADBManagerSecurityIntegration:
assert "Invalid path" in message
# Malicious destination
success, message = manager.move_item("/sdcard/source", "/sdcard/dest && malicious")
success, message = manager.move_item(
"/sdcard/source", "/sdcard/dest && malicious"
)
assert not success
assert "Invalid path" in message
@@ -513,7 +517,7 @@ class TestADBManagerSecurityIntegration:
result = manager.get_file_info("/sdcard/file\x00.txt")
assert result is None
@patch('src.core.adb_manager.ADBCommandRunner')
@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()
@@ -616,4 +620,4 @@ class TestADBManagerSecurityIntegration:
result = manager.list_files("/sdcard/DCIM")
# No status messages for validation errors
assert not any("Invalid" in msg for msg in status_messages)
assert not any("Invalid" in msg for msg in status_messages)
+74 -66
View File
@@ -10,33 +10,33 @@ 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
@@ -49,40 +49,42 @@ class TestADBFileTransfer:
assert result is True
# 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')
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
@@ -96,42 +98,44 @@ class TestADBFileTransfer:
assert result is True
# 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')
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
@@ -145,26 +149,28 @@ class TestADBFileTransfer:
assert result is True
# 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')
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
@@ -178,49 +184,51 @@ class TestADBFileTransfer:
assert result is True
# 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')
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()
+101 -54
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,112 +20,159 @@ 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.is_zipfile', return_value=True):
with patch('zipfile.ZipFile') as mock_zip:
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.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
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'):
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.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
mock_response.headers = {
"Content-Type": "application/zip"
}
mock_get.return_value = (
mock_response
)
result = ensure_platform_tools_in_user_dir()
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
@@ -154,5 +201,5 @@ class TestPlatformToolsSecurityValidation:
assert True # Documentation test
if __name__ == '__main__':
unittest.main()
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__])
+15 -4
View File
@@ -28,7 +28,7 @@ class TestSanitizePathComponent:
def test_dangerous_chars(self):
"""Test that dangerous characters are rejected."""
dangerous_chars = [';', '|', '&', '$', '`', '\n', '\r', '>', '<', '(', ')']
dangerous_chars = [";", "|", "&", "$", "`", "\n", "\r", ">", "<", "(", ")"]
for char in dangerous_chars:
with pytest.raises(ValueError, match="dangerous character"):
sanitize_path_component(f"file{char}name.txt")
@@ -60,8 +60,14 @@ class TestSanitizeAndroidPath:
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"
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."""
@@ -150,6 +156,7 @@ class TestSanitizeLocalPath:
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")
@@ -168,6 +175,7 @@ class TestSanitizeLocalPath:
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)
@@ -190,7 +198,7 @@ class TestValidateDeviceId:
def test_invalid_characters(self):
"""Test that invalid characters are rejected."""
dangerous_chars = [';', '|', '&', '$', '`', '\n', '\r', ' ', '>', '<']
dangerous_chars = [";", "|", "&", "$", "`", "\n", "\r", " ", ">", "<"]
for char in dangerous_chars:
with pytest.raises(ValueError):
validate_device_id(f"device{char}123")
@@ -222,6 +230,7 @@ class TestSecurityIntegration:
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")
@@ -293,6 +302,7 @@ class TestUnicodeAndEdgeCases:
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")
@@ -307,6 +317,7 @@ class TestUnicodeAndEdgeCases:
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"
+89
View File
@@ -0,0 +1,89 @@
# Vagrant Windows 11 Test Environment
Automated testing of the Android File Handler installer on a clean Windows 11 VM using Hyper-V.
## Prerequisites
- [Vagrant](https://www.vagrantup.com/) >= 2.4
- Hyper-V enabled (`Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All`)
- [Inno Setup 6](https://jrsoftware.org/isdl.php) installed (default path or ISCC.exe in PATH)
- Poetry environment set up (`poetry install`)
## Setup (one-time)
```powershell
cd vagrant
vagrant up --provider=hyperv
vagrant snapshot save clean
```
> **Note:** Vagrant will prompt for your Windows credentials to set up the SMB synced folder.
> You can set `VAGRANT_SMB_USERNAME` and `VAGRANT_SMB_PASSWORD` environment variables to skip the prompt.
## Running Tests
From the repo root:
```powershell
.\scripts\windows\run_vagrant_tests.ps1
```
This will:
1. Build the installer locally (PyInstaller + Inno Setup)
2. Restore the VM to the `clean` snapshot
3. Push the installer to the VM via WinRM
4. Run the installer silently inside the VM
5. Verify installation (directory, exe, shortcuts, registry)
6. Launch the app, find the window, take a GUI screenshot
7. Run silent uninstall and verify cleanup
8. Retrieve results CSV + screenshots to `vagrant_test_results\<timestamp>\`
9. Print a summary table to the CLI
10. Revert the VM back to the `clean` snapshot
### Options
| Flag | Description |
|---|---|
| `-SkipBuild` | Skip the local build step (use an existing installer in `dist\`) |
| `-SkipRevert` | Leave the VM in post-test state (useful for debugging) |
| `-SnapshotName <name>` | Use a different snapshot name (default: `clean`) |
| `-LocalResultsDir <path>` | Override where results are saved |
## What Gets Tested
| # | Test | Description |
|---|---|---|
| 1 | Installer exists | Checks the built `.exe` is present |
| 2 | Silent install | Runs the Inno Setup installer with `/VERYSILENT` |
| 3 | Install directory | Verifies `C:\Program Files\Android File Handler` exists |
| 4 | Executable present | Checks the app `.exe` is in the install directory |
| 5 | Start Menu shortcut | Verifies the Start Menu entry was created |
| 6 | Registry entry | Checks Add/Remove Programs registration |
| 7 | App launches | Starts the app and confirms it stays running |
| 8 | Window found | Finds the main window by its title |
| 9 | Silent uninstall | Runs the uninstaller and verifies cleanup |
## File Structure
```
vagrant/
├── Vagrantfile # VM definition (Windows 11 Enterprise, Hyper-V)
├── provision.ps1 # VM provisioning (nircmd for screenshots)
├── test_installer.ps1 # Test script that runs inside the VM
└── README.md # This file
scripts/windows/
└── run_vagrant_tests.ps1 # Host-side orchestrator (builds, pushes, tests, reverts)
```
## Troubleshooting
**VM won't start**: Ensure Hyper-V is enabled and you are running the terminal as Administrator.
**WinRM connection fails**: The box uses plaintext WinRM on port 55985. Ensure no firewall blocks it.
**SMB share prompt**: Set `VAGRANT_SMB_USERNAME` and `VAGRANT_SMB_PASSWORD` environment variables to avoid interactive credential prompts.
**Inno Setup not found**: Install Inno Setup 6 to the default path or add `ISCC.exe` to your PATH.
**Want to inspect the VM manually**: Use `vagrant rdp` or pass `-SkipRevert`.
+54
View File
@@ -0,0 +1,54 @@
# -*- mode: ruby -*-
# vi: set ft=ruby :
#
# Vagrant Windows 11 test machine for Android File Handler installer testing.
#
# Prerequisites:
# - Vagrant >= 2.4
# - Hyper-V enabled (Windows host)
#
# Usage:
# cd vagrant
# vagrant up --provider=hyperv
# vagrant snapshot save clean
# ..\scripts\windows\run_vagrant_tests.ps1
#
Vagrant.configure("2") do |config|
config.vm.box = "gusztavvargadr/windows-11-24h2-enterprise"
config.vm.hostname = "afh-test"
config.vm.communicator = "winrm"
config.winrm.username = "vagrant"
config.winrm.password = "vagrant"
config.winrm.transport = :plaintext # Vagrant's own comms (boot check, provision)
config.winrm.basic_auth_only = true
config.vm.guest = :windows
config.vm.boot_timeout = 900
config.vm.graceful_halt_timeout = 120
# Automatically select the Default Switch for Hyper-V to avoid interactive prompt
config.vm.network "public_network", bridge: "Default Switch"
config.vm.network "forwarded_port", guest: 5985, host: 55985 # Vagrant plaintext WinRM
config.vm.network "forwarded_port", guest: 5986, host: 55986 # PSSession HTTPS WinRM
config.vm.provider "hyperv" do |hv|
hv.vmname = "afh-win11-test"
hv.memory = 8192
hv.maxmemory = 8192
hv.cpus = 4
hv.enable_enhanced_session_mode = true
hv.linked_clone = true
end
# Disable default Vagrant synced folder to prevent SMB credential prompts.
# Files are transferred directly via PSSession by run_vagrant_tests.ps1.
config.vm.synced_folder ".", "/vagrant", disabled: true
config.vm.provision "shell",
path: "provision.ps1",
privileged: true
end
+96
View File
@@ -0,0 +1,96 @@
# provision.ps1 — Vagrant provisioning script for Windows 11 test VM.
# Installs prerequisites needed to test the Android File Handler installer.
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
Write-Host "=== Provisioning Android File Handler test VM ===" -ForegroundColor Cyan
# --- Enable auto-logon so GUI tests work after reboot ---
$RegPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
Set-ItemProperty -Path $RegPath -Name AutoAdminLogon -Value "1"
Set-ItemProperty -Path $RegPath -Name DefaultUserName -Value "vagrant"
Set-ItemProperty -Path $RegPath -Name DefaultPassword -Value "vagrant"
# --- Allow script execution (required for test suite) ---
# Write the LocalMachine execution policy directly via the registry instead of using
# Set-ExecutionPolicy. Vagrant runs provision.ps1 under "powershell -ExecutionPolicy Bypass",
# which sets a Process-scope policy. When a Process-scope policy is active,
# Set-ExecutionPolicy -Scope LocalMachine throws a terminating SecurityException
# ("overridden by a policy defined at a more specific scope") that cannot be suppressed
# with -ErrorAction. Writing the registry key bypasses that cmdlet restriction entirely.
# The value takes effect in all subsequent WinRM PSSessions (which have no Process-scope
# override) — exactly the sessions that run the test suite.
$psMachinePolicyPath = 'HKLM:\SOFTWARE\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell'
Set-ItemProperty -Path $psMachinePolicyPath -Name 'ExecutionPolicy' -Value 'Unrestricted' -Force
Write-Host " Execution policy set to Unrestricted (LocalMachine)." -ForegroundColor DarkGray
# --- Install Chocolatey (package manager) ---
if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
Write-Host "Installing Chocolatey..." -ForegroundColor Yellow
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
}
# --- Configure HTTPS WinRM listener ---
# HTTP WinRM (plaintext) causes PowerShell PSSession to reject unencrypted
# traffic. A self-signed cert on port 5986 satisfies WinRM's security policy
# without needing a domain CA. The 10-year expiry covers long-lived test boxes.
Write-Host "Configuring HTTPS WinRM listener..." -ForegroundColor Yellow
# Ensure network is Private so WinRM HTTPS works correctly
Get-NetConnectionProfile | Set-NetConnectionProfile -NetworkCategory Private
# Allow local accounts to perform administrative tasks via WinRM
# (Required for the 'vagrant' user to have full admin rights over PSSession)
$policyPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System"
Set-ItemProperty -Path $policyPath -Name LocalAccountTokenFilterPolicy -Value 1 -Force
# Create certificate for HTTPS listener
$cert = New-SelfSignedCertificate `
-DnsName "afh-test", "localhost" `
-CertStoreLocation "Cert:\LocalMachine\My" `
-KeyAlgorithm RSA `
-KeyLength 2048 `
-NotAfter (Get-Date).AddYears(10)
# Check if an HTTPS listener already exists
$listeners = winrm enumerate winrm/config/listener
$hasHttps = $listeners -match "Transport = HTTPS"
if ($hasHttps) {
# If the thumbprint matches, we're already good
if ($listeners -match $cert.Thumbprint) {
Write-Host " HTTPS listener already correctly configured." -ForegroundColor Green
} else {
Write-Host " Updating existing HTTPS listener with new certificate..." -ForegroundColor Gray
winrm delete winrm/config/Listener?Address=*+Transport=HTTPS
winrm create winrm/config/Listener?Address=*+Transport=HTTPS `
"@{Hostname=`"afh-test`";CertificateThumbprint=`"$($cert.Thumbprint)`"}"
}
} else {
Write-Host " Creating new HTTPS listener..." -ForegroundColor Gray
winrm create winrm/config/Listener?Address=*+Transport=HTTPS `
"@{Hostname=`"afh-test`";CertificateThumbprint=`"$($cert.Thumbprint)`"}"
}
winrm set winrm/config/service/Auth '@{Basic="true"}'
netsh advfirewall firewall add rule `
name="WinRM HTTPS" protocol=TCP dir=in localport=5986 action=allow profile=any | Out-Null
Write-Host " HTTPS WinRM listener ready on port 5986" -ForegroundColor Green
# --- Install nircmd for screenshot capture ---
Write-Host "Installing nircmd for screenshot capture..." -ForegroundColor Yellow
choco install nircmd -y --no-progress
# --- Install Inno Setup 6.7.1 (pinned) ---
Write-Host "Installing Inno Setup 6.7.1..." -ForegroundColor Yellow
choco install innosetup --version 6.7.1 -y --no-progress
# Refresh PATH
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
Write-Host "=== Provisioning complete ===" -ForegroundColor Green
+332
View File
@@ -0,0 +1,332 @@
# test_installer.ps1 — Runs INSIDE the Vagrant VM via WinRM.
# Tests installation and basic function of Android File Handler.
#
# Exit codes: 0 = all passed, 1 = one or more failures.
param(
[string]$InstallerPath = "C:\vagrant_build\dist\android-file-handler-setup.exe",
[string]$ScreenshotDir = "C:\vagrant_test_results",
[string]$AppVersion = ""
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# ── Helpers ──────────────────────────────────────────────────────────────────
$Script:Passed = 0
$Script:Failed = 0
$Script:Results = @()
function Write-TestResult {
param([string]$Name, [bool]$Pass, [string]$Detail = "")
$status = if ($Pass) { "PASS" } else { "FAIL" }
$color = if ($Pass) { "Green" } else { "Red" }
$msg = "[$status] $Name"
if ($Detail) { $msg += " - $Detail" }
Write-Host $msg -ForegroundColor $color
if ($Pass) { $Script:Passed++ } else { $Script:Failed++ }
$Script:Results += [PSCustomObject]@{ Test = $Name; Status = $status; Detail = $Detail }
}
function Take-Screenshot {
param([string]$Name)
if (-not (Test-Path $ScreenshotDir)) {
New-Item -ItemType Directory -Path $ScreenshotDir -Force | Out-Null
}
$outFile = Join-Path $ScreenshotDir "$Name.png"
$nircmd = Get-Command nircmd -ErrorAction SilentlyContinue
if ($nircmd) {
Start-Process -FilePath $nircmd.Source -ArgumentList "savescreenshot", $outFile -NoNewWindow -Wait
return $outFile
}
# Fallback: .NET screenshot
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
$bitmap = New-Object System.Drawing.Bitmap($screen.Width, $screen.Height)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen($screen.Location, [System.Drawing.Point]::Empty, $screen.Size)
$bitmap.Save($outFile, [System.Drawing.Imaging.ImageFormat]::Png)
$graphics.Dispose()
$bitmap.Dispose()
return $outFile
}
# ── Setup ────────────────────────────────────────────────────────────────────
$AppName = "Android File Handler"
$ExeName = "android-file-handler-windows.exe"
$DefaultInstDir = Join-Path $env:ProgramFiles $AppName
Write-Host ""
Write-Host "=============================================" -ForegroundColor Cyan
Write-Host " Android File Handler - Vagrant Test Suite " -ForegroundColor Cyan
Write-Host "=============================================" -ForegroundColor Cyan
Write-Host ""
if (-not (Test-Path $ScreenshotDir)) {
New-Item -ItemType Directory -Path $ScreenshotDir -Force | Out-Null
}
try {
# ── Build installer if not already present ──────────────────────────────────
# When run directly inside the VM (not via run_vagrant_tests.ps1), the build
# dir may not exist yet. Since the SMB mount is disabled by default, this
# fallback requires manual transfer of assets to C:\vagrant_project.
if (-not (Test-Path $InstallerPath)) {
$projectRoot = "C:\vagrant_project"
$sourceExe = "$projectRoot\dist\android-file-handler-windows.exe"
$iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
$buildRoot = "C:\vagrant_build"
if (-not (Test-Path $sourceExe)) {
Write-Host "`nFATAL: Installer not found and cannot build - PyInstaller exe missing." -ForegroundColor Red
Write-Host " Run PyInstaller on the host first, then use the orchestrator:" -ForegroundColor Yellow
Write-Host " ..\scripts\windows\run_vagrant_tests.ps1"
Write-Host " (Or manually transfer files to C:\vagrant_project inside the VM)."
exit 1
}
if (-not (Test-Path $iscc)) {
Write-Host "`nFATAL: Inno Setup not found at $iscc." -ForegroundColor Red
Write-Host " Re-provision the VM: vagrant provision" -ForegroundColor Yellow
exit 1
}
Write-Host "`n--- Building installer inside the VM (Inno Setup 6.7.1) ---" -ForegroundColor Yellow
@("$buildRoot\dist", "$buildRoot\scripts\windows", "$buildRoot\icon_media") |
ForEach-Object { New-Item -ItemType Directory -Force -Path $_ | Out-Null }
Copy-Item $sourceExe "$buildRoot\dist\" -Force
Copy-Item "$projectRoot\scripts\windows\android-file-handler-setup.iss" "$buildRoot\scripts\windows\" -Force
Copy-Item "$projectRoot\LICENSE.txt" "$buildRoot\" -Force
Copy-Item "$projectRoot\icon_media\robot_files_256.ico" "$buildRoot\icon_media\" -Force
$isccArgs = @("$buildRoot\scripts\windows\android-file-handler-setup.iss")
if ($AppVersion) { $isccArgs += "/DMyAppVersion=$AppVersion" }
& $iscc @isccArgs
if ($LASTEXITCODE -ne 0) {
Write-Host "FATAL: Inno Setup failed (exit $LASTEXITCODE)." -ForegroundColor Red
exit 1
}
Write-Host " Installer built: $InstallerPath" -ForegroundColor Green
}
# ── Test 1: Installer exists ────────────────────────────────────────────────
$installerExists = Test-Path $InstallerPath
Write-TestResult -Name "Installer file exists" -Pass $installerExists -Detail $InstallerPath
if (-not $installerExists) {
throw "Installer not found at $InstallerPath."
}
# ── Test 2: Silent install ──────────────────────────────────────────────────
Write-Host "`n--- Running silent install ---" -ForegroundColor Yellow
$installProc = Start-Process -FilePath $InstallerPath `
-ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/SP-" `
-Wait -PassThru
$installOk = $installProc.ExitCode -eq 0
Write-TestResult -Name "Silent install completed" -Pass $installOk -Detail "Exit code: $($installProc.ExitCode)"
# ── Test 3: Install directory created ────────────────────────────────────────
$dirExists = Test-Path $DefaultInstDir
Write-TestResult -Name "Install directory exists" -Pass $dirExists -Detail $DefaultInstDir
# ── Test 4: Executable present ──────────────────────────────────────────────
$exePath = Join-Path $DefaultInstDir $ExeName
$exeExists = Test-Path $exePath
Write-TestResult -Name "Executable exists" -Pass $exeExists -Detail $exePath
# ── Test 5: Start Menu shortcut ─────────────────────────────────────────────
$startMenuDir = Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\$AppName"
$shortcutPath = Join-Path $startMenuDir "$AppName.lnk"
$shortcutExists = Test-Path $shortcutPath
Write-TestResult -Name "Start Menu shortcut exists" -Pass $shortcutExists -Detail $shortcutPath
# ── Test 6: Registry uninstall entry ────────────────────────────────────────
# Check both the native (64-bit) and WoW6432Node (32-bit) uninstall hives.
# WinRM sessions may run 32-bit PowerShell, which redirects HKLM:\SOFTWARE to
# WoW6432Node, missing the 64-bit installer's entry if we only check one path.
# try/catch in the Where-Object handles the StrictMode "property not found" throw
# that neither -ErrorAction nor PSObject.Properties can fully avoid.
$uninstallPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"
)
$registryEntry = $uninstallPaths | ForEach-Object {
Get-ChildItem $_ -ErrorAction SilentlyContinue
} | Where-Object {
# Match exact name OR "AppName version X.Y.Z" (Inno Setup default when
# AppVerName is not set). The .iss now sets AppVerName={#MyAppName} so
# exact match is expected; the -like guard covers older installer builds.
try {
$dn = (Get-ItemProperty $_.PSPath -ErrorAction Stop).DisplayName
$dn -eq $AppName -or $dn -like "$AppName *"
} catch { $false }
}
$registryOk = $null -ne $registryEntry
if (-not $registryOk) {
# Diagnostic: show non-Microsoft entries so the failure can be investigated.
Write-Host " [Diag] Entries found in Uninstall hives:" -ForegroundColor DarkGray
$uninstallPaths | ForEach-Object {
Get-ChildItem $_ -ErrorAction SilentlyContinue
} | ForEach-Object {
$dn = try { (Get-ItemProperty $_.PSPath -ErrorAction Stop).DisplayName } catch { $null }
if ($dn -and $dn -notmatch '^(Microsoft|Windows|KB\d)') {
Write-Host " '$dn'" -ForegroundColor DarkGray
}
}
}
Write-TestResult -Name "Registry uninstall entry" -Pass $registryOk
# ── Test 7: Launch application & capture GUI ─────────────────────────────────
if ($exeExists) {
Write-Host "`n--- Launching application ---" -ForegroundColor Yellow
$appProc = Start-Process -FilePath $exePath -PassThru
Start-Sleep -Seconds 8
# Check if process is still running (GUI app should stay alive)
$appRunning = -not $appProc.HasExited
Write-TestResult -Name "Application launches and stays running" -Pass $appRunning
# Look for the window by title.
# WinRM sessions run in a non-interactive window station; the GUI process
# may have no accessible HWND from this session even when fully running.
# Try .NET MainWindowTitle first, then Win32 FindWindow as a fallback.
# If both come up empty but the process is still alive, count it as a pass
# with a note — the "stays running" check above already validates the install.
$windowFound = $false
$windowNote = ""
$hwnd = [IntPtr]::Zero
$appProc.Refresh()
if ($appProc.MainWindowTitle -eq $AppName) {
$windowFound = $true
$windowNote = "via .NET MainWindowTitle"
} else {
try {
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class Win32 {
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
}
"@ -ErrorAction SilentlyContinue
$hwnd = [Win32]::FindWindow($null, $AppName)
if ($hwnd -ne [IntPtr]::Zero) {
$windowFound = $true
$windowNote = "HWND: $hwnd"
}
} catch {
Write-Host " Warning: Could not check for window title via Win32 API." -ForegroundColor Yellow
}
if (-not $windowFound -and $appRunning) {
# Process is alive; window just not reachable from the WinRM session.
$windowFound = $true
$windowNote = "process alive - window not accessible from WinRM session"
}
}
Write-TestResult -Name "Main window found by title" -Pass $windowFound -Detail $windowNote
# Take screenshot of the running application
try {
Start-Sleep -Seconds 2
$screenshotFile = Take-Screenshot -Name "app_running"
Write-Host " Screenshot saved: $screenshotFile" -ForegroundColor DarkGray
} catch {
Write-Host " Warning: Could not capture screenshot: $_" -ForegroundColor Yellow
}
# Gracefully close, then ensure the entire process tree is dead before
# the uninstaller runs. PyInstaller onefile spawns a child process that
# continues after the stub exits; taskkill /F /T kills both. The extra
# sleep lets the OS release executable file handles so the uninstaller
# can delete the .exe.
if (-not $appProc.HasExited) {
$appProc.CloseMainWindow() | Out-Null
Start-Sleep -Seconds 3
}
$procName = [System.IO.Path]::GetFileNameWithoutExtension($ExeName)
taskkill /F /T /IM "$procName.exe" 2>&1 | Out-Null
Start-Sleep -Seconds 3 # Wait for OS to release file handles
}
else {
Write-TestResult -Name "Application launches and stays running" -Pass $false -Detail "Skipped - exe not found"
Write-TestResult -Name "Main window found by title" -Pass $false -Detail "Skipped - exe not found"
}
# ── Test 8: Silent uninstall ────────────────────────────────────────────────
Write-Host "`n--- Running silent uninstall ---" -ForegroundColor Yellow
$uninstallerPath = Join-Path $DefaultInstDir "unins000.exe"
if (Test-Path $uninstallerPath) {
$uninstProc = Start-Process -FilePath $uninstallerPath `
-ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART" `
-Wait -PassThru
$uninstallOk = $uninstProc.ExitCode -eq 0
Write-TestResult -Name "Silent uninstall completed" -Pass $uninstallOk -Detail "Exit code: $($uninstProc.ExitCode)"
Start-Sleep -Seconds 2
# Assert the executable is gone — not the directory. The app may have
# created platform-tools/, a license file, or other runtime files inside
# the install dir during Test 7; Inno Setup only tracks files it installed
# and will not remove a non-empty directory.
$exeGone = -not (Test-Path $exePath)
Write-TestResult -Name "Executable removed after uninstall" -Pass $exeGone -Detail $exePath
}
else {
Write-TestResult -Name "Silent uninstall completed" -Pass $false -Detail "Uninstaller not found"
Write-TestResult -Name "Install directory removed after uninstall" -Pass $false -Detail "Skipped"
}
} catch {
# Catch unexpected terminating exceptions so the finally block can still save
# results and so the PSSession is not broken by an unhandled remote exception.
$errMsg = "$_"
Write-Host "`nFATAL: Unhandled exception in test suite: $errMsg" -ForegroundColor Red
Write-TestResult -Name "Script execution (fatal error)" -Pass $false -Detail $errMsg
}
finally {
# ── Summary ──────────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "=============================================" -ForegroundColor Cyan
Write-Host " Test Summary" -ForegroundColor Cyan
Write-Host "=============================================" -ForegroundColor Cyan
Write-Host " Passed : $Script:Passed" -ForegroundColor Green
Write-Host " Failed : $Script:Failed" -ForegroundColor $(if ($Script:Failed -gt 0) { "Red" } else { "Green" })
Write-Host " Total : $($Script:Passed + $Script:Failed)" -ForegroundColor White
Write-Host ""
$Script:Results | Format-Table -AutoSize
# Export results as CSV for retrieval
if ($Script:Results.Count -gt 0) {
$csvPath = Join-Path $ScreenshotDir "test_results.csv"
$Script:Results | Export-Csv -Path $csvPath -NoTypeInformation
Write-Host "Results saved to $csvPath" -ForegroundColor DarkGray
}
if ($error.Count -gt 0) {
Write-Host "`nScript encountered errors during execution:" -ForegroundColor Red
$error[0] | Format-List -Force
}
}
exit $(if ($Script:Failed -gt 0) { 1 } else { 0 })