""" Android File Handler - Business Logic Module Handles ADB operations, device management, and file transfers. """ import os import sys import subprocess import requests import zipfile import io import shutil import glob import time import re from typing import Optional, Tuple, Callable def get_executable_directory() -> str: """Get the directory containing the executable or script.""" if getattr(sys, 'frozen', False): # Running as executable (PyInstaller, cx_Freeze, etc.) return os.path.dirname(sys.executable) else: # Running as script - use the script's directory return os.path.dirname(os.path.abspath(__file__)) 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): # Running as script - check if we're in src/ directory or subdirectory 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']: # 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") else: # Not in src structure - assume we need to find/create src directory # This handles cases where the script might be run from project root current_dir = base_dir src_dir = os.path.join(current_dir, "src") if os.path.exists(src_dir): return os.path.join(src_dir, "platform-tools") 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") # Constants ADB_WIN_ZIP_URL = ( "https://dl.google.com/android/repository/platform-tools-latest-windows.zip" ) 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 if OS_TYPE.startswith("linux"): ADB_BINARY_NAME = "adb" elif OS_TYPE.startswith("win"): ADB_BINARY_NAME = "adb.exe" else: ADB_BINARY_NAME = "adb" ADB_BINARY_PATH = os.path.join(LOCAL_ADB_FOLDER, ADB_BINARY_NAME) class ADBManager: """Manages ADB operations and Android device communication.""" def __init__(self): self.progress_callback: Optional[Callable[[int], None]] = None self.status_callback: Optional[Callable[[str], None]] = None self.current_process: Optional[subprocess.Popen] = None def set_progress_callback(self, callback: Callable[[int], None]): """Set callback function for progress updates.""" self.progress_callback = callback def set_status_callback(self, callback: Callable[[str], None]): """Set callback function for status updates.""" self.status_callback = callback def _update_progress(self, percentage: int): """Internal method to update progress.""" if self.progress_callback: self.progress_callback(percentage) def _update_status(self, message: str): """Internal method to update status.""" if self.status_callback: self.status_callback(message) 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] 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) 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 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 except Exception as e: print(f"Failed to download ADB: {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 try: if capture_output: p = subprocess.run(cmd, capture_output=True, text=True, timeout=15) return p.stdout.strip(), p.stderr.strip(), p.returncode else: p = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True ) return p except Exception as e: if capture_output: 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) if isinstance(result, tuple) and len(result) == 3: out, err, rc = result if rc != 0 or not out: return None for line in out.splitlines(): 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 with enhanced patterns for large transfers.""" # Original pattern: (XX%) m = re.search(r"\((\d{1,3})%\)", text_line) if m: pct = int(m.group(1)) if 0 <= pct <= 100: return pct # Additional patterns for large transfers # Pattern: XX% complete m = re.search(r"(\d{1,3})%\s+complete", text_line, re.IGNORECASE) if m: pct = int(m.group(1)) if 0 <= pct <= 100: return pct # Pattern: transferred XX% m = re.search(r"transferred\s+(\d{1,3})%", text_line, re.IGNORECASE) if m: pct = int(m.group(1)) if 0 <= pct <= 100: return pct # Pattern: XX files pulled/pushed (XX%) m = re.search( r"\d+\s+files?\s+(?:pulled|pushed).*?\((\d{1,3})%\)", text_line, re.IGNORECASE, ) if m: pct = int(m.group(1)) if 0 <= pct <= 100: return pct return None def pull_folder(self, remote_path: str, local_path: str) -> bool: """Pull files from Android device to local machine.""" # Normalize paths for better compatibility 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 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 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...") proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) self.current_process = proc line_count = 0 last_progress = 0 start_time = time.time() last_update_time = start_time if proc.stdout: for line in proc.stdout: line_count += 1 current_time = time.time() 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: self._update_progress(int(new_progress)) last_progress = new_progress last_update_time = current_time self._update_status(line.strip()) proc.wait() if proc.returncode == 0: self._update_progress(100) self._update_status("Transfer completed successfully.") 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: 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 return False 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 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...") proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) self.current_process = proc except Exception as e: self._update_status(f"Failed to start adb: {e}") return False line_count = 0 last_progress = 0 start_time = time.time() last_update_time = start_time if proc.stdout: for line in proc.stdout: line_count += 1 current_time = time.time() 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: self._update_progress(int(new_progress)) last_progress = new_progress last_update_time = current_time self._update_status(line.strip()) proc.wait() if proc.returncode == 0: self._update_progress(100) self._update_status("Transfer completed successfully.") 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: pass self._update_status(error_msg) self.current_process = None return False def pull_file(self, remote_file_path: str, local_file_path: str) -> bool: """Pull a single file from Android device to local machine.""" # Normalize paths for better compatibility local_file_path = os.path.normpath(local_file_path) remote_file_path = remote_file_path.strip() # Ensure local directory exists local_dir = os.path.dirname(local_file_path) try: if local_dir: # Only create if there's a directory part os.makedirs(local_dir, 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 if os.name == "nt": # Check if this is a root drive (like C:\, D:\, etc.) normalized_path = os.path.abspath(local_dir) 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_file_path, local_file_path] # Debug output for troubleshooting self._update_status(f"Command: adb pull '{remote_file_path}' '{local_file_path}'") try: # Start with initial progress self._update_progress(0) self._update_status("Starting file transfer...") proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) self.current_process = proc line_count = 0 last_progress = 0 start_time = time.time() last_update_time = start_time if proc.stdout: for line in proc.stdout: line_count += 1 current_time = time.time() 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: # For single files, use simpler progress estimation elapsed_time = current_time - start_time if elapsed_time >= 1.0 and last_progress < 90: # Simple time-based progress for files new_progress = min(last_progress + 20, 90) self._update_progress(int(new_progress)) last_progress = new_progress last_update_time = current_time self._update_status(line.strip()) proc.wait() if proc.returncode == 0: self._update_progress(100) self._update_status("File transfer completed successfully.") self.current_process = None return True else: # Capture error output for better debugging error_msg = f"File 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: pass self._update_status(error_msg) self.current_process = None return False except Exception as e: self._update_status(f"File transfer error: {e}") self.current_process = None return False def push_file(self, local_file_path: str, remote_file_path: str) -> bool: """Push a single file from local machine to Android device.""" # Normalize paths for better compatibility local_file_path = os.path.normpath(local_file_path) remote_file_path = remote_file_path.strip() # Validate local file exists if not os.path.isfile(local_file_path): self._update_status(f"Local file does not exist: {local_file_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_file_path) drive_root = os.path.splitdrive(normalized_path)[0] + os.sep if os.path.dirname(normalized_path) == drive_root.rstrip(os.sep): # File in root drive - this might cause issues with ADB self._update_status( "Warning: Pushing from root drive. Consider using a subfolder." ) cmd = [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}'") try: # Start with initial progress self._update_progress(0) self._update_status("Starting file transfer...") proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) self.current_process = proc except Exception as e: self._update_status(f"Failed to start adb: {e}") return False line_count = 0 last_progress = 0 start_time = time.time() last_update_time = start_time if proc.stdout: for line in proc.stdout: line_count += 1 current_time = time.time() 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: # For single files, use simpler progress estimation elapsed_time = current_time - start_time if elapsed_time >= 1.0 and last_progress < 90: # Simple time-based progress for files new_progress = min(last_progress + 20, 90) self._update_progress(int(new_progress)) last_progress = new_progress last_update_time = current_time self._update_status(line.strip()) proc.wait() if proc.returncode == 0: self._update_progress(100) self._update_status("File transfer completed successfully.") self.current_process = None return True else: # Capture error output for better debugging error_msg = f"File 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: pass self._update_status(error_msg) self.current_process = None return False def cancel_transfer(self) -> bool: """Cancel the current transfer operation.""" if self.current_process is not None: try: # Terminate the process self.current_process.terminate() # Give it a moment to terminate gracefully try: self.current_process.wait(timeout=2) except subprocess.TimeoutExpired: # Force kill if it doesn't terminate gracefully self.current_process.kill() self.current_process.wait() self.current_process = None self._update_status("Transfer cancelled by user") return True except Exception as e: self._update_status(f"Error cancelling transfer: {e}") return False return False class LinuxMTPManager: """Manages MTP operations on Linux systems.""" def __init__(self): self.mount_point = "/tmp/android_mtp" def mount_mtp_device(self) -> Optional[str]: """Mount MTP device to filesystem using jmtpfs.""" try: # Create mount point os.makedirs(self.mount_point, exist_ok=True) # Check if already mounted result = subprocess.run( ["mountpoint", self.mount_point], capture_output=True, text=True ) if result.returncode == 0: return self.mount_point # First, try to unmount any existing GVFS MTP mounts self._unmount_gvfs_mtp() # Kill any existing MTP processes that might be interfering subprocess.run(["pkill", "-f", "gvfs-mtp"], capture_output=True) subprocess.run(["pkill", "-f", "jmtpfs"], capture_output=True) # Wait a moment for processes to clean up time.sleep(1) # Mount using jmtpfs result = subprocess.run( ["jmtpfs", self.mount_point], capture_output=True, text=True ) if result.returncode == 0: return self.mount_point else: print(f"Failed to mount MTP device: {result.stderr}") return None except Exception as e: print(f"Error mounting MTP device: {e}") return None def _unmount_gvfs_mtp(self): """Unmount any GVFS MTP mounts.""" try: # Find GVFS MTP mounts result = subprocess.run(["mount"], capture_output=True, text=True) for line in result.stdout.splitlines(): if "gvfs" in line and "mtp" in line: # Extract mount point from mount line parts = line.split() if len(parts) >= 3: mount_point = parts[2] subprocess.run( ["fusermount", "-u", mount_point], capture_output=True ) # Also try to unmount common GVFS locations gvfs_locations = ["/run/user/*/gvfs/mtp*", "/media/*", "~/.gvfs/mtp*"] for location_pattern in gvfs_locations: result = subprocess.run( ["find", "/run/user", "-name", "mtp*", "-type", "d"], capture_output=True, text=True, ) for mount_point in result.stdout.strip().split("\n"): if mount_point: subprocess.run( ["fusermount", "-u", mount_point], capture_output=True ) except Exception as e: print(f"Warning: Could not unmount GVFS MTP: {e}") def unmount_mtp_device(self) -> bool: """Unmount MTP device.""" try: subprocess.run(["fusermount", "-u", self.mount_point], check=True) return True except subprocess.CalledProcessError as e: print(f"Failed to unmount: {e}") return False def find_gvfs_mtp_mount(self) -> Optional[str]: """Find existing GVFS MTP mount point.""" try: # Check common GVFS mount locations (Linux only) if sys.platform.startswith("linux"): try: if hasattr(os, "getuid"): user_id = os.getuid() # type: ignore gvfs_patterns = [ f"/run/user/{user_id}/gvfs/mtp*", "/media/*android*", "/media/*MTP*", ] else: # Fallback if getuid is not available gvfs_patterns = [ "/run/user/*/gvfs/mtp*", "/media/*android*", "/media/*MTP*", ] except (AttributeError, OSError): # Fallback if getuid is not available or fails gvfs_patterns = [ "/run/user/*/gvfs/mtp*", "/media/*android*", "/media/*MTP*", ] for pattern in gvfs_patterns: matches = glob.glob(pattern) if matches: # Return the first valid mount point for mount in matches: if os.path.isdir(mount): return mount return None except Exception as e: print(f"Error finding GVFS mount: {e}") return None # 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) def get_platform_type() -> str: """Get the current platform type.""" return OS_TYPE