From a6c7b3dd26c49fb2ef4582a7bd499cbf350104ce Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Tue, 26 Aug 2025 16:16:42 -0500 Subject: [PATCH] updated logic to put license agreeement and platform-tools in user writable folders --- poetry.lock | 10 +- pyproject.toml | 3 +- scripts/build_package_linux.sh | 2 +- .../android-file-handler-linux.spec | 4 +- src/adb_manager.py | 323 +++++++++++------- src/gui/license_agreement.py | 69 ++-- 6 files changed, 257 insertions(+), 154 deletions(-) diff --git a/poetry.lock b/poetry.lock index 37769b6..d365585 100644 --- a/poetry.lock +++ b/poetry.lock @@ -533,14 +533,14 @@ files = [ [[package]] name = "platformdirs" -version = "4.3.8" +version = "4.4.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ - {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, - {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, + {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, + {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, ] [package.extras] @@ -909,4 +909,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = "<3.13,>=3.12" -content-hash = "dfb6152cb0fb34dcda59387fbaef4f445b0f38437bd7bdfe25b38b62b14d527e" +content-hash = "2e5b4bc609ab9efe7fe2f83c7c475b1189133759463452d0ed664870cea7ed08" diff --git a/pyproject.toml b/pyproject.toml index 413aaea..325dbff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,11 +8,12 @@ authors = [ license = { text = "MIT" } readme = "README.md" requires-python = "<3.13,>=3.12" -dependencies = ["requests>=2.32.4,<3.0.0"] +dependencies = ["requests>=2.32.4,<3.0.0", "platformdirs>4.0.0,<5.0.0"] [tool.poetry.dependencies] python = "<3.13,>=3.12" requests = ">=2.32.4,<3.0.0" +platformdirs = ">4.0.0,<5.0.0" [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] diff --git a/scripts/build_package_linux.sh b/scripts/build_package_linux.sh index 7f3a985..b4443dc 100755 --- a/scripts/build_package_linux.sh +++ b/scripts/build_package_linux.sh @@ -5,7 +5,7 @@ poetry lock poetry install # Build using the Linux spec so the build is reproducible and uses the project spec -poetry run pyinstaller scripts/spec_scripts/android-file-handler-linux.spec --distpath dist +poetry run pyinstaller /home/jasonross/workspace/android_file_handler_adb/scripts/spec_scripts/android-file-handler-linux.spec --distpath dist # Package into a Debian .deb using fpm export PATH="$HOME/.local/bin:/usr/local/bin:$PATH" diff --git a/scripts/spec_scripts/android-file-handler-linux.spec b/scripts/spec_scripts/android-file-handler-linux.spec index 50671f7..a5819d3 100644 --- a/scripts/spec_scripts/android-file-handler-linux.spec +++ b/scripts/spec_scripts/android-file-handler-linux.spec @@ -1,10 +1,10 @@ # -*- mode: python ; coding: utf-8 -*- block_cipher = None a = Analysis( - ['src/main.py'], + ['../../src/main.py'], pathex=['src'], binaries=[], - datas=[('src/gui', 'gui')], + datas=[('../../src/gui', 'gui')], hiddenimports=[ 'gui', 'gui.file_browser', diff --git a/src/adb_manager.py b/src/adb_manager.py index 76c61ad..42615c1 100644 --- a/src/adb_manager.py +++ b/src/adb_manager.py @@ -14,6 +14,9 @@ import glob import time import re from typing import Optional, Tuple, Callable +import tempfile +from typing import Optional +import hashlib def get_executable_directory() -> str: """Get the directory containing the executable or script.""" @@ -23,6 +26,140 @@ def get_executable_directory() -> str: else: # Running as script - use the script's directory return os.path.dirname(os.path.abspath(__file__)) + +def ensure_platform_tools_in_user_dir(version_tag: Optional[str] = "latest") -> str: + """Ensure platform-tools installed in a per-user data dir and return adb path. + + Behavior: + - Uses platformdirs.user_data_dir('android-file-handler') if available, + else falls back to ~/.local/share/android-file-handler (POSIX) or + %LOCALAPPDATA% on Windows via expanduser. + - Installs into /platform-tools// and creates/update + a symlink /platform-tools/current -> . + - Downloads into a temp dir and moves atomically to avoid partial installs. + - Sets executable permissions on adb binary. + - Returns absolute path to adb binary (no PATH modification required). + """ + try: + from platformdirs import user_data_dir # type: ignore + except Exception: + user_data_dir = None + + # Determine base data dir + if user_data_dir: + 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") + + os.makedirs(data_root, exist_ok=True) + + target_version = version_tag or "latest" + target_dir = os.path.join(data_root, target_version) + current_link = os.path.join(data_root, "current") + + # If current symlink exists and points to a valid adb, return it + if os.path.islink(current_link): + try: + resolved = os.path.realpath(current_link) + adb_name = "adb.exe" if sys.platform.startswith("win") else "adb" + candidate = os.path.join(resolved, adb_name) + if os.path.isfile(candidate): + return candidate + except Exception: + pass + + # If requested version already installed, point current there + if os.path.isdir(target_dir) and os.path.isfile(os.path.join(target_dir, "adb" if not sys.platform.startswith("win") else "adb.exe")): + # update symlink atomically + if os.path.islink(current_link) or os.path.exists(current_link): + try: + os.remove(current_link) + except Exception: + pass + try: + os.symlink(target_dir, current_link) + except Exception: + # best-effort, ignore if unable to create symlink + pass + return os.path.join(target_dir, "adb.exe" if sys.platform.startswith("win") else "adb") + + # Download into temp location and extract + tmp_dir = tempfile.mkdtemp(prefix="platform-tools-") + try: + # choose URL + if sys.platform.startswith("linux"): + url = ADB_LINUX_ZIP_URL + elif sys.platform.startswith("win"): + url = ADB_WIN_ZIP_URL + else: + raise RuntimeError("Unsupported platform for platform-tools download") + + # download in streaming fashion to avoid memory pressure + resp = requests.get(url, stream=True, timeout=30) + resp.raise_for_status() + + zip_path = os.path.join(tmp_dir, "platform-tools.zip") + with open(zip_path, "wb") as fh: + for chunk in resp.iter_content(chunk_size=8192): + if chunk: + fh.write(chunk) + + # extract + with zipfile.ZipFile(zip_path, "r") as zf: + zf.extractall(tmp_dir) + + # the zip contains a top-level platform-tools directory; move that into target_dir + extracted_dir = os.path.join(tmp_dir, "platform-tools") + if not os.path.isdir(extracted_dir): + # 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"): + extracted_dir = candidate + break + + if not os.path.isdir(extracted_dir): + raise RuntimeError("Platform-tools not found in archive") + + # Atomic install: move extracted_dir -> target_dir (remove existing backup first) + if os.path.isdir(target_dir): + backup = f"{target_dir}.bak" + shutil.rmtree(backup, ignore_errors=True) + shutil.move(target_dir, backup) + shutil.move(extracted_dir, target_dir) + + # Ensure adb executable perms on POSIX + adb_name = "adb.exe" if sys.platform.startswith("win") else "adb" + adb_path = os.path.join(target_dir, adb_name) + if os.path.isfile(adb_path) and os.name == "posix": + os.chmod(adb_path, 0o755) + + # Atomically update 'current' symlink + tmp_link = f"{current_link}.tmp" + try: + if os.path.exists(tmp_link): + os.remove(tmp_link) + os.symlink(target_dir, tmp_link) + os.replace(tmp_link, current_link) + except OSError: + # fallback: remove and recreate + try: + if os.path.exists(current_link): + os.remove(current_link) + os.symlink(target_dir, current_link) + except Exception: + pass + + return adb_path + finally: + # Clean temp dir + try: + if os.path.exists(tmp_dir): + shutil.rmtree(tmp_dir) + except Exception: + pass def get_platform_tools_directory() -> str: """Get platform-tools directory.""" @@ -60,7 +197,6 @@ ADB_WIN_ZIP_URL = ( ADB_LINUX_ZIP_URL = ( "https://dl.google.com/android/repository/platform-tools-latest-linux.zip" ) -LOCAL_ADB_FOLDER = get_platform_tools_directory() OS_TYPE = sys.platform # Determine ADB binary name based on platform @@ -71,7 +207,28 @@ elif OS_TYPE.startswith("win"): else: ADB_BINARY_NAME = "adb" -ADB_BINARY_PATH = os.path.join(LOCAL_ADB_FOLDER, ADB_BINARY_NAME) +# ADB binary path is resolved at runtime via get_adb_binary_path() to avoid +# duplicate logic and to centralize platform-tools installation behavior. + + +def get_adb_binary_path() -> str: + """Return the path to the adb binary, installing platform-tools if needed. + + This central helper ensures a consistent location across the codebase. + """ + adb_name = "adb.exe" if sys.platform.startswith("win") else "adb" + try: + # If platform-tools are installed in the user data dir, prefer that + adb_path = ensure_platform_tools_in_user_dir() + if adb_path and os.path.isfile(adb_path): + return adb_path + except Exception: + pass + + # Fallback: look for an executable next to the project or installed path + local_folder = get_platform_tools_directory() + candidate = os.path.join(local_folder, adb_name) + return candidate class ADBManager: @@ -103,60 +260,39 @@ class ADBManager: def check_local_disk_space(self) -> bool: """Check if there's enough disk space for ADB download.""" try: - free_space = shutil.disk_usage(LOCAL_ADB_FOLDER)[2] + free_space = shutil.disk_usage(get_platform_tools_directory())[2] if free_space < 50 * 1024 * 1024: # 50MB minimum raise Exception("Insufficient disk space") return True except OSError: # Create directory if it doesn't exist - os.makedirs(LOCAL_ADB_FOLDER, exist_ok=True) + os.makedirs(get_platform_tools_directory(), exist_ok=True) return True def download_and_extract_adb(self) -> bool: """Download and extract ADB tools if not present.""" - if os.path.isfile(ADB_BINARY_PATH): - return True - + # Use the centralized installer which will return the adb path (and + # perform a download if needed). If it returns a valid path, report + # success; otherwise return False. try: - # Determine the correct URL based on platform - if OS_TYPE.startswith("linux"): - adb_zip_url = ADB_LINUX_ZIP_URL - elif OS_TYPE.startswith("win"): - adb_zip_url = ADB_WIN_ZIP_URL - else: - print("Unsupported platform") - return False - - if not self.check_local_disk_space(): - return False - - self._update_status("Downloading platform-tools (ADB)...") - - response = requests.get(adb_zip_url, stream=True, timeout=30) - response.raise_for_status() - - binary_archive = zipfile.ZipFile(io.BytesIO(response.content)) - - # Extract to parent directory since the zip contains platform-tools/ folder - extract_to = os.path.dirname(LOCAL_ADB_FOLDER) - os.makedirs(extract_to, exist_ok=True) - binary_archive.extractall(extract_to) - - if OS_TYPE.startswith("linux"): - if os.path.exists(ADB_BINARY_PATH): - os.chmod(ADB_BINARY_PATH, 0o755) - else: - raise Exception("ADB binary not found after extraction") - - self._update_status("Downloaded and extracted platform-tools.") - return True + adb_path = ensure_platform_tools_in_user_dir() + if adb_path and os.path.isfile(adb_path): + # Ensure executable permissions on POSIX + if os.name == "posix": + try: + os.chmod(adb_path, 0o755) + except Exception: + pass + self._update_status("ADB available at: " + adb_path) + return True + return False except Exception as e: - print(f"Failed to download ADB: {e}") + self._update_status(f"Failed to ensure platform-tools: {e}") return False def run_adb_command(self, args: list, capture_output: bool = True): """Run an ADB command and return output.""" - cmd = [ADB_BINARY_PATH] + args + cmd = [get_adb_binary_path()] + args try: if capture_output: p = subprocess.run(cmd, capture_output=True, text=True, timeout=15) @@ -223,35 +359,29 @@ class ADBManager: def pull_folder(self, remote_path: str, local_path: str) -> bool: """Pull files from Android device to local machine.""" - # Normalize paths for better compatibility + # Normalize paths and prepare local_path = os.path.normpath(local_path) remote_path = remote_path.strip() - # Ensure local directory exists try: os.makedirs(local_path, exist_ok=True) except Exception as e: self._update_status(f"Failed to create local directory: {e}") return False - # For Windows root drives, ensure proper formatting + # Warn if writing to root drive on Windows if os.name == "nt": - # Check if this is a root drive (like C:\, D:\, etc.) normalized_path = os.path.abspath(local_path) drive_root = os.path.splitdrive(normalized_path)[0] + os.sep if normalized_path == drive_root: - # Root drive path like C:\ - this might cause issues with ADB self._update_status( "Warning: Transferring to root drive. Consider using a subfolder." ) - cmd = [ADB_BINARY_PATH, "pull", remote_path, local_path] - - # Debug output for troubleshooting + cmd = [get_adb_binary_path(), "pull", remote_path, local_path] self._update_status(f"Command: adb pull '{remote_path}' '{local_path}'") try: - # Start with initial progress self._update_progress(0) self._update_status("Starting transfer...") @@ -276,45 +406,29 @@ class ADBManager: pct = self.parse_progress(line) if pct is not None: - # Use explicit progress when available self._update_progress(pct) last_progress = pct last_update_time = current_time else: - # Improved progress estimation for large transfers elapsed_time = current_time - start_time time_since_last_update = current_time - last_update_time - # Calculate progress based on multiple factors should_update = False new_progress = last_progress - # Time-based progress (update every 2 seconds) if time_since_last_update >= 2.0 and last_progress < 95: - # Estimate progress based on activity and time if line_count > 100: - # For large transfers, use a logarithmic approach - activity_factor = min( - line_count / 1000, 50 - ) # Max 50% from activity - time_factor = min( - elapsed_time / 60, 40 - ) # Max 40% from time (assumes 1-2 min transfers) + activity_factor = min(line_count / 1000, 50) + time_factor = min(elapsed_time / 60, 40) new_progress = min(activity_factor + time_factor, 95) else: - # For smaller transfers, use the original approach new_progress = min(last_progress + 10, 95) - should_update = True - - # Line-based progress (for very active transfers) elif line_count % 50 == 0 and last_progress < 90: - # More conservative line-based updates increment = max(1, min(5, 90 // (line_count // 50 + 1))) new_progress = min(last_progress + increment, 90) should_update = True - # Update progress if needed if should_update and new_progress > last_progress: self._update_progress(int(new_progress)) last_progress = new_progress @@ -329,19 +443,17 @@ class ADBManager: self.current_process = None return True else: - # Capture error output for better debugging error_msg = f"Transfer failed with code {proc.returncode}" if hasattr(proc, "stderr") and proc.stderr: try: stderr_output = proc.stderr.read() if stderr_output: error_msg += f". Error: {stderr_output}" - except: + except Exception: pass self._update_status(error_msg) self.current_process = None return False - except Exception as e: self._update_status(f"Transfer error: {e}") self.current_process = None @@ -349,33 +461,25 @@ class ADBManager: def push_folder(self, local_path: str, remote_path: str) -> bool: """Push files from local machine to Android device.""" - # Normalize paths for better compatibility local_path = os.path.normpath(local_path) remote_path = remote_path.strip() - # Validate local path exists if not os.path.exists(local_path): self._update_status(f"Local path does not exist: {local_path}") return False - # For Windows root drives, ensure proper formatting if os.name == "nt": - # Check if this is a root drive (like C:\, D:\, etc.) normalized_path = os.path.abspath(local_path) drive_root = os.path.splitdrive(normalized_path)[0] + os.sep if normalized_path == drive_root: - # Root drive path like C:\ - this might cause issues with ADB self._update_status( "Warning: Pushing from root drive. Consider using a subfolder." ) - cmd = [ADB_BINARY_PATH, "push", local_path, remote_path] - - # Debug output for troubleshooting + cmd = [get_adb_binary_path(), "push", local_path, remote_path] self._update_status(f"Command: adb push '{local_path}' '{remote_path}'") try: - # Start with initial progress self._update_progress(0) self._update_status("Starting transfer...") @@ -403,46 +507,13 @@ class ADBManager: pct = self.parse_progress(line) if pct is not None: - # Use explicit progress when available self._update_progress(pct) last_progress = pct last_update_time = current_time else: - # Improved progress estimation for large transfers elapsed_time = current_time - start_time - time_since_last_update = current_time - last_update_time - - # Calculate progress based on multiple factors - should_update = False - new_progress = last_progress - - # Time-based progress (update every 2 seconds) - if time_since_last_update >= 2.0 and last_progress < 95: - # Estimate progress based on activity and time - if line_count > 100: - # For large transfers, use a logarithmic approach - activity_factor = min( - line_count / 1000, 50 - ) # Max 50% from activity - time_factor = min( - elapsed_time / 60, 40 - ) # Max 40% from time (assumes 1-2 min transfers) - new_progress = min(activity_factor + time_factor, 95) - else: - # For smaller transfers, use the original approach - new_progress = min(last_progress + 10, 95) - - should_update = True - - # Line-based progress (for very active transfers) - elif line_count % 50 == 0 and last_progress < 90: - # More conservative line-based updates - increment = max(1, min(5, 90 // (line_count // 50 + 1))) - new_progress = min(last_progress + increment, 90) - should_update = True - - # Update progress if needed - if should_update and new_progress > last_progress: + if elapsed_time >= 1.0 and last_progress < 90: + new_progress = min(last_progress + 20, 90) self._update_progress(int(new_progress)) last_progress = new_progress last_update_time = current_time @@ -456,14 +527,13 @@ class ADBManager: self.current_process = None return True else: - # Capture error output for better debugging error_msg = f"Push failed with code {proc.returncode}" if hasattr(proc, "stderr") and proc.stderr: try: stderr_output = proc.stderr.read() if stderr_output: error_msg += f". Error: {stderr_output}" - except: + except Exception: pass self._update_status(error_msg) self.current_process = None @@ -495,7 +565,7 @@ class ADBManager: "Warning: Transferring to root drive. Consider using a subfolder." ) - cmd = [ADB_BINARY_PATH, "pull", remote_file_path, local_file_path] + cmd = [get_adb_binary_path(), "pull", remote_file_path, local_file_path] # Debug output for troubleshooting self._update_status(f"Command: adb pull '{remote_file_path}' '{local_file_path}'") @@ -589,7 +659,7 @@ class ADBManager: "Warning: Pushing from root drive. Consider using a subfolder." ) - cmd = [ADB_BINARY_PATH, "push", local_file_path, remote_file_path] + cmd = [get_adb_binary_path(), "push", local_file_path, remote_file_path] # Debug output for troubleshooting self._update_status(f"Command: adb push '{local_file_path}' '{remote_file_path}'") @@ -681,6 +751,8 @@ class ADBManager: return False return False + # (Method intentionally removed - use top-level ensure_platform_tools_in_user_dir) + class LinuxMTPManager: """Manages MTP operations on Linux systems.""" @@ -808,14 +880,13 @@ class LinuxMTPManager: # Helper functions for standalone usage -def get_adb_binary_path() -> str: - """Get the path to the ADB binary.""" - return ADB_BINARY_PATH - - def is_adb_available() -> bool: - """Check if ADB binary is available.""" - return os.path.isfile(ADB_BINARY_PATH) + """Check if ADB binary is available using the centralized resolver.""" + try: + adb_path = get_adb_binary_path() + return os.path.isfile(adb_path) + except Exception: + return False def get_platform_type() -> str: diff --git a/src/gui/license_agreement.py b/src/gui/license_agreement.py index 06227a0..98390fd 100644 --- a/src/gui/license_agreement.py +++ b/src/gui/license_agreement.py @@ -7,42 +7,73 @@ import os import sys import tkinter as tk from tkinter import messagebox, scrolledtext +import tempfile +import stat -def get_license_file_path(): - """Get the path to the license agreement file.""" - # Get the directory where the application is running from - if getattr(sys, 'frozen', False): - # Running as executable - app_dir = os.path.dirname(sys.executable) +def get_license_file_path() -> str: + """Return the per-user license-agreed file path. + + Uses platform-appropriate user config directory when possible: + - On Linux/macOS this will typically use ~/.config or ~/.android-file-handler fallback. + - On Windows it prefers %APPDATA% (via platformdirs) and falls back to ~. + """ + try: + from platformdirs import user_config_dir + except Exception: + user_config_dir = None + + if getattr(sys, "frozen", False): + # Running as executable: prefer platform dirs + if user_config_dir: + config_dir = os.path.join(user_config_dir("android-file-handler"), "") + else: + config_dir = os.path.expanduser("~/.android-file-handler") + os.makedirs(config_dir, exist_ok=True) + return os.path.join(config_dir, "license_agreed.ini") else: - # Running as script - use directory containing the main script + # Dev mode: store beside project tree app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - return os.path.join(app_dir, "license_agreed.ini") + return os.path.join(app_dir, "dev-mode_license_agreed.ini") -def check_license_agreement(): +def check_license_agreement() -> bool: """Check if user has already agreed to the license.""" license_file = get_license_file_path() try: if os.path.exists(license_file): - with open(license_file, 'r') as f: - content = f.read().strip() - return content == "1" + with open(license_file, "r") as f: + return f.read().strip() == "1" return False except Exception: return False -def save_license_agreement(): - """Save that the user has agreed to the license.""" +def save_license_agreement() -> bool: + """Persist that the user agreed to the license. + + Writes atomically and sets secure file permissions on POSIX. + """ license_file = get_license_file_path() try: - # Create directory if it doesn't exist - os.makedirs(os.path.dirname(license_file), exist_ok=True) - with open(license_file, 'w') as f: - f.write("1") + parent_dir = os.path.dirname(license_file) + os.makedirs(parent_dir, exist_ok=True) + + # Atomic write to temporary file then rename + fd, tmp_path = tempfile.mkstemp(dir=parent_dir) + try: + with os.fdopen(fd, "w") as fh: + fh.write("1") + # Set owner-only permissions on POSIX + if os.name == "posix": + os.chmod(tmp_path, 0o600) + os.replace(tmp_path, license_file) + finally: + if os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except Exception: + pass return True except Exception: return False