diff --git a/.gitignore b/.gitignore index 3e2a44b..52c7f9b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ -src/platform-tools \ No newline at end of file +src/platform-tools + +# Python cache files +__pycache__/ +*.py[cod] +*$py.class \ No newline at end of file diff --git a/debug_tools/debug_adb.py b/debug_tools/debug_adb.py index 09ca3c3..94dad2d 100644 --- a/debug_tools/debug_adb.py +++ b/debug_tools/debug_adb.py @@ -1,26 +1,128 @@ """ -Debug script for troubleshooting ADB device detection issues. - -This script provides comprehensive debugging information for ADB connectivity, -including binary path verification, device detection, and command execution testing. +Debug large transfer ADB output """ -import os import sys +import subprocess +import re +import time +import os # Add the src directory to Python path for imports sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), "src")) -from adb_manager import ADBManager +from adb_manager import ADB_BINARY_PATH + + +def debug_large_transfer_output(): + """Debug what ADB output looks like for large transfers.""" + + print("=== DEBUGGING LARGE TRANSFER ADB OUTPUT ===") + print("This will show you what ADB actually outputs during large transfers.") + print("Use Ctrl+C to stop when you see enough output.\n") + + # Get user input for paths + remote_path = input("Enter remote path (e.g., /sdcard/DCIM): ").strip() + if not remote_path: + remote_path = "/sdcard/DCIM" + + local_path = input("Enter local path (e.g., C:\\temp\\debug_transfer): ").strip() + if not local_path: + local_path = "C:\\temp\\debug_transfer" + + print(f"\nDebugging transfer: {remote_path} -> {local_path}") + print("ADB output analysis:\n") + + cmd = [ADB_BINARY_PATH, "pull", remote_path, local_path] + + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + line_count = 0 + progress_lines = [] + + if proc.stdout: + for line in proc.stdout: + line_count += 1 + line_clean = line.strip() + + # Check for progress information + progress_match = re.search(r"\((\d{1,3})%\)", line) + + # Log interesting lines + if ( + progress_match + or "%" in line + or line_count <= 20 + or line_count % 100 == 0 + ): + print(f"Line {line_count:4d}: {line_clean}") + + if progress_match: + pct = int(progress_match.group(1)) + progress_lines.append((line_count, pct, line_clean)) + + # Show periodic updates for very long transfers + if line_count % 500 == 0: + print(f"... ({line_count} lines processed) ...") + + proc.wait() + + print(f"\n=== TRANSFER COMPLETED ===") + print(f"Total lines: {line_count}") + print(f"Return code: {proc.returncode}") + print(f"Progress lines found: {len(progress_lines)}") + + if progress_lines: + print("\nProgress line analysis:") + for line_num, pct, text in progress_lines: + print(f" Line {line_num}: {pct}% - {text}") + else: + print("No explicit progress percentages found in output!") + print("This explains why large transfers don't show progress.") + + except KeyboardInterrupt: + print(f"\n\nStopped at line {line_count}") + if proc: + proc.terminate() + + if progress_lines: + print(f"Progress lines found so far: {len(progress_lines)}") + for line_num, pct, text in progress_lines[-5:]: # Show last 5 + print(f" Line {line_num}: {pct}% - {text}") + + except Exception as e: + print(f"Error: {e}") def main(): - """Run comprehensive ADB debugging tests.""" + """Debug large transfer functionality.""" + print("Choose debug option:") + print("1. Debug large transfer ADB output") + print("2. Run original ADB detection tests") + + choice = input("Enter choice (1 or 2): ").strip() + + if choice == "1": + debug_large_transfer_output() + else: + debug_original_adb_detection() + + +def debug_original_adb_detection(): + """Original ADB debugging functionality.""" + from adb_manager import ADBManager + print("=== ADB Device Detection Debug ===\n") # Test 1: Check if ADB binary exists adb_manager = ADBManager() - from adb_manager import ADB_BINARY_PATH adb_path = ADB_BINARY_PATH print(f"1. ADB Binary Path: {adb_path}") diff --git a/debug_tools/debug_main.py b/debug_tools/debug_main.py new file mode 100644 index 0000000..a437b35 --- /dev/null +++ b/debug_tools/debug_main.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +""" +Debug version of main window with console output +""" + +import sys + +sys.path.insert(0, "src") + +from gui.main_window import AndroidFileHandlerGUI + + +class DebugAndroidFileHandlerGUI(AndroidFileHandlerGUI): + """Debug version with console output.""" + + def _initialize_components(self): + """Initialize GUI components and handlers with debug output.""" + super()._initialize_components() + + # Wrap the progress callback with debug output + original_update_progress = self.progress_handler.update_progress + + def debug_update_progress(percentage): + print(f"DEBUG: Progress update called with {percentage}%") + return original_update_progress(percentage) + + # Wrap the status callback with debug output + original_set_status = self.progress_handler.set_status + + def debug_set_status(message): + print(f"DEBUG: Status update called with '{message}'") + return original_set_status(message) + + # Replace the callbacks + self.progress_handler.update_progress = debug_update_progress + self.progress_handler.set_status = debug_set_status + + # Update ADB manager callbacks + self.adb_manager.set_progress_callback(self.progress_handler.update_progress) + self.adb_manager.set_status_callback(self.progress_handler.set_status) + + print("DEBUG: Callbacks set up with debug wrappers") + + +def main(): + """Main function to run the debug application.""" + if sys.platform not in ["win32", "linux"]: + import tkinter as tk + from tkinter import messagebox + + root = tk.Tk() + root.withdraw() + messagebox.showerror( + "Unsupported OS", "This application only supports Windows or Linux." + ) + sys.exit(1) + + app = DebugAndroidFileHandlerGUI() + app.mainloop() + + +if __name__ == "__main__": + main() diff --git a/debug_tools/debug_parsing.py b/debug_tools/debug_parsing.py new file mode 100644 index 0000000..d3e8f93 --- /dev/null +++ b/debug_tools/debug_parsing.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +Debug folder name parsing from ADB ls output +""" + +import sys +import subprocess + +sys.path.insert(0, "src") + +from adb_manager import ADB_BINARY_PATH + + +def debug_folder_parsing(): + """Debug folder name parsing from ls -la output.""" + + print("=== DEBUGGING FOLDER NAME PARSING ===") + print("This will show actual ADB ls output and how it's parsed.\n") + + path = input("Enter Android path to list (e.g., /sdcard): ").strip() + if not path: + path = "/sdcard" + + print(f"Running: adb shell ls -la '{path}'\n") + + try: + cmd = [ADB_BINARY_PATH, "shell", "ls", "-la", path] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + + print(f"Return code: {result.returncode}") + print(f"Raw output:\n{repr(result.stdout)}") + print(f"Error output: {repr(result.stderr)}") + print("\n" + "=" * 50) + print("PARSED OUTPUT:") + print("=" * 50) + + if result.stdout and result.stdout.strip(): + lines = result.stdout.strip().split("\n") + print(f"Total lines: {len(lines)}\n") + + for i, line in enumerate(lines): + print(f"Line {i+1}: {repr(line)}") + + if line.startswith("d"): + # Current parsing logic + parts = line.split() + print(f" Parts ({len(parts)}): {parts}") + + if len(parts) >= 8: + current_name = " ".join(parts[7:]) + print(f" Current parsing result: '{current_name}'") + + # Improved parsing - try different approaches + # Method 1: Find the last occurrence of time pattern and take everything after + import re + + time_pattern = r"\d{2}:\d{2}" + time_match = list(re.finditer(time_pattern, line)) + if time_match: + last_time = time_match[-1] + improved_name = line[last_time.end() :].strip() + print(f" Improved parsing result: '{improved_name}'") + + if improved_name != current_name: + print(f" *** DIFFERENCE DETECTED! ***") + + # Method 2: Manual field parsing (more reliable) + # drwxrwxrwx root root 2024-01-01 12:00 folder_name + tokens = line.split() + if len(tokens) >= 6: + # Skip permissions, links, user, group, size, date, time + # Take everything from index 8 (or find after time pattern) + manual_name = ( + " ".join(tokens[8:]) + if len(tokens) > 8 + else (tokens[7] if len(tokens) == 8 else "") + ) + print(f" Manual parsing result: '{manual_name}'") + else: + print( + f" *** NOT ENOUGH PARTS! Expected >= 8, got {len(parts)}" + ) + + print() + else: + print("No output received!") + + except subprocess.TimeoutExpired: + print("Command timed out!") + except Exception as e: + print(f"Error: {e}") + + +if __name__ == "__main__": + debug_folder_parsing() diff --git a/debug_tools/test_large_transfer_progress.py b/debug_tools/test_large_transfer_progress.py new file mode 100644 index 0000000..94c524e --- /dev/null +++ b/debug_tools/test_large_transfer_progress.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +""" +Test improved progress calculation for large transfers +""" + +import sys +import time +import threading + +sys.path.insert(0, "src") + +from gui.main_window import AndroidFileHandlerGUI + + +def test_large_transfer_progress(): + """Test improved progress calculation.""" + + class TestApp(AndroidFileHandlerGUI): + def __init__(self): + super().__init__() + self.progress_updates = [] + + # Capture progress updates + original_update = self.progress_handler.update_progress + + def capture_progress(percentage): + self.progress_updates.append((time.time(), percentage)) + print(f"Progress: {percentage}%") + return original_update(percentage) + + self.progress_handler.update_progress = capture_progress + self.adb_manager.set_progress_callback( + self.progress_handler.update_progress + ) + + app = TestApp() + + def simulate_large_transfer(): + """Simulate a large transfer with many output lines.""" + time.sleep(1) + + print("=== Testing Large Transfer Progress ===") + + # Simulate the internal progress logic without actual ADB + start_time = time.time() + last_update_time = start_time + last_progress = 0 + + print("Simulating 2000 lines of output (like a large transfer)...") + + for line_count in range(1, 2001): + current_time = time.time() + + # Simulate the improved progress logic + elapsed_time = current_time - start_time + time_since_last_update = current_time - last_update_time + + 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: + if line_count > 100: + activity_factor = min(line_count / 1000, 50) + time_factor = min(elapsed_time / 60, 40) + new_progress = min(activity_factor + time_factor, 95) + else: + new_progress = min(last_progress + 10, 95) + should_update = True + + # Line-based progress + elif line_count % 50 == 0 and last_progress < 90: + increment = max(1, min(5, 90 // (line_count // 50 + 1))) + new_progress = min(last_progress + increment, 90) + should_update = True + + if should_update and new_progress > last_progress: + app.adb_manager._update_progress(int(new_progress)) + last_progress = new_progress + last_update_time = current_time + + # Speed up simulation + if line_count % 100 == 0: + time.sleep(0.1) + + # Complete the transfer + app.adb_manager._update_progress(100) + + print(f"\nTotal progress updates: {len(app.progress_updates)}") + print("Progress timeline:") + start_time = app.progress_updates[0][0] if app.progress_updates else time.time() + for timestamp, percentage in app.progress_updates[-10:]: # Show last 10 + elapsed = timestamp - start_time + print(f" {elapsed:.1f}s: {percentage}%") + + threading.Thread(target=simulate_large_transfer, daemon=True).start() + + # Run for limited time + app.after(15000, app.destroy) # Close after 15 seconds + app.mainloop() + + +if __name__ == "__main__": + test_large_transfer_progress() diff --git a/debug_tools/test_minimal_progress.py b/debug_tools/test_minimal_progress.py new file mode 100644 index 0000000..1fd2d06 --- /dev/null +++ b/debug_tools/test_minimal_progress.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +Minimal test to verify progress updates work in modular GUI +""" + +import sys +import time +import threading + +sys.path.insert(0, "src") + +from gui.main_window import AndroidFileHandlerGUI + + +def test_minimal_progress(): + """Test minimal progress updates.""" + print("Creating GUI...") + app = AndroidFileHandlerGUI() + + def test_updates(): + time.sleep(1) # Wait for GUI to be ready + print("Starting test updates...") + + # Test direct progress handler calls + print("Testing progress handler directly...") + app.progress_handler.update_progress(25) + time.sleep(0.5) + app.progress_handler.update_progress(50) + time.sleep(0.5) + app.progress_handler.update_progress(75) + time.sleep(0.5) + app.progress_handler.update_progress(100) + + time.sleep(1) + + # Test ADB manager calls (should go through callbacks) + print("Testing through ADB manager...") + app.adb_manager._update_progress(0) + time.sleep(0.5) + app.adb_manager._update_progress(30) + time.sleep(0.5) + app.adb_manager._update_progress(60) + time.sleep(0.5) + app.adb_manager._update_progress(90) + time.sleep(0.5) + app.adb_manager._update_progress(100) + + print("Test completed!") + + # Start test in background + threading.Thread(target=test_updates, daemon=True).start() + + # Run GUI + print("Starting GUI mainloop...") + app.mainloop() + + +if __name__ == "__main__": + test_minimal_progress() diff --git a/debug_tools/test_modular_progress.py b/debug_tools/test_modular_progress.py new file mode 100644 index 0000000..705d64a --- /dev/null +++ b/debug_tools/test_modular_progress.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +""" +Debug test for progress callback in modular GUI +""" + +import sys +import os + +sys.path.insert(0, "src") + +from gui.main_window import AndroidFileHandlerGUI + + +def test_callbacks(): + """Test if callbacks are being set properly.""" + app = AndroidFileHandlerGUI() + + print("Testing callback setup...") + print(f"ADB Manager progress callback: {app.adb_manager.progress_callback}") + print(f"ADB Manager status callback: {app.adb_manager.status_callback}") + print(f"Progress handler: {app.progress_handler}") + print( + f"Progress handler update_progress method: {app.progress_handler.update_progress}" + ) + print(f"Progress handler set_status method: {app.progress_handler.set_status}") + + # Test direct call + print("\nTesting direct progress update...") + app.progress_handler.update_progress(50) + app.update() + + print("\nTesting direct status update...") + app.progress_handler.set_status("Testing status") + app.update() + + print( + "\nTest completed. Check if progress bar shows 50% and status shows 'Testing status'" + ) + + app.mainloop() + + +if __name__ == "__main__": + test_callbacks() diff --git a/debug_tools/test_modular_successive.py b/debug_tools/test_modular_successive.py new file mode 100644 index 0000000..4809eff --- /dev/null +++ b/debug_tools/test_modular_successive.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +Test successive transfers with modular GUI +""" + +import sys +import time +import threading + +sys.path.insert(0, "src") + +from gui.main_window import AndroidFileHandlerGUI + + +def simulate_transfer(app, transfer_name): + """Simulate a transfer with progress updates.""" + print(f"Starting {transfer_name}") + app.progress_handler.set_status(f"Starting {transfer_name}...") + app.progress_handler.reset_progress() + + # Simulate progress updates + for i in range(0, 101, 10): + time.sleep(0.1) # Simulate work + app.progress_handler.update_progress(i) + app.progress_handler.set_status(f"{transfer_name}: {i}% complete") + + app.progress_handler.set_status(f"{transfer_name} completed!") + print(f"Completed {transfer_name}") + + +def test_successive_transfers(): + """Test successive transfers to ensure progress bar works correctly.""" + app = AndroidFileHandlerGUI() + + def run_transfers(): + time.sleep(1) # Wait for GUI to be ready + simulate_transfer(app, "Transfer 1") + time.sleep(0.5) + simulate_transfer(app, "Transfer 2") + time.sleep(0.5) + simulate_transfer(app, "Transfer 3") + print("All transfers completed!") + + # Start transfers in background thread + threading.Thread(target=run_transfers, daemon=True).start() + + app.mainloop() + + +if __name__ == "__main__": + test_successive_transfers() diff --git a/debug_tools/test_normal_flow.py b/debug_tools/test_normal_flow.py new file mode 100644 index 0000000..ac9dd32 --- /dev/null +++ b/debug_tools/test_normal_flow.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +""" +Debug test following normal application flow +""" + +import sys +import os +import time +import threading + +sys.path.insert(0, "src") + +from gui.main_window import AndroidFileHandlerGUI + + +def test_normal_flow(): + """Test following the normal application flow exactly.""" + app = AndroidFileHandlerGUI() + + def debug_transfer(): + # Wait for app to initialize + time.sleep(2) + + print("=== DEBUGGING NORMAL APPLICATION FLOW ===") + print(f"App ADB manager: {app.adb_manager}") + print(f"App progress handler: {app.progress_handler}") + print(f"ADB manager progress callback: {app.adb_manager.progress_callback}") + print(f"ADB manager status callback: {app.adb_manager.status_callback}") + + # Set up a test transfer + app.remote_path_var.set("/sdcard/Download") + app.local_path_var.set(r"C:\temp") + + print("\n=== SIMULATING START TRANSFER BUTTON CLICK ===") + + # Check if local path exists + if not os.path.exists(r"C:\temp"): + os.makedirs(r"C:\temp", exist_ok=True) + + # Test the exact method that gets called + app.current_transfer_id += 1 + transfer_id = app.current_transfer_id + print(f"Transfer ID: {transfer_id}") + + app.disable_controls() + app.progress_handler.reset_progress() + + print("\n=== TESTING PROGRESS UPDATES DIRECTLY ===") + app.adb_manager._update_progress(25) + app.update() + time.sleep(0.5) + + app.adb_manager._update_progress(50) + app.update() + time.sleep(0.5) + + app.adb_manager._update_progress(75) + app.update() + time.sleep(0.5) + + app.adb_manager._update_progress(100) + app.update() + + print("=== PROGRESS UPDATES COMPLETED ===") + + app.enable_controls() + + # Start debug in background + threading.Thread(target=debug_transfer, daemon=True).start() + + app.mainloop() + + +if __name__ == "__main__": + test_normal_flow() diff --git a/debug_tools/test_real_workflow.py b/debug_tools/test_real_workflow.py new file mode 100644 index 0000000..81df5a6 --- /dev/null +++ b/debug_tools/test_real_workflow.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +""" +Test real transfer workflow +""" + +import sys +import os +import time +import threading + +sys.path.insert(0, "src") + +from gui.main_window import AndroidFileHandlerGUI + + +def test_real_transfer_workflow(): + """Test the real transfer workflow with console output.""" + + # Add debug output to see what's happening + class DebugApp(AndroidFileHandlerGUI): + def __init__(self): + super().__init__() + + # Wrap callbacks with debug output + original_progress = self.adb_manager.progress_callback + original_status = self.adb_manager.status_callback + + def debug_progress(percentage): + print(f"PROGRESS: {percentage}%") + if original_progress: + original_progress(percentage) + + def debug_status(message): + print(f"STATUS: {message}") + if original_status: + original_status(message) + + self.adb_manager.set_progress_callback(debug_progress) + self.adb_manager.set_status_callback(debug_status) + + app = DebugApp() + + def simulate_transfer(): + time.sleep(2) # Wait for app to be ready + + print("=== SIMULATING BUTTON CLICK ===") + + # Set up paths (use a path that doesn't exist to avoid actual transfer) + app.remote_path_var.set("/nonexistent/path") + app.local_path_var.set(r"C:\temp") + + # Create temp dir if needed + os.makedirs(r"C:\temp", exist_ok=True) + + # Call start_transfer method directly + print("Calling start_transfer()...") + app.start_transfer() + + # Start simulation + threading.Thread(target=simulate_transfer, daemon=True).start() + + print("Starting app...") + app.mainloop() + + +if __name__ == "__main__": + test_real_transfer_workflow() diff --git a/src/__pycache__/__init__.cpython-313.pyc b/src/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 67412df..0000000 Binary files a/src/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/__pycache__/adb_manager.cpython-313.pyc b/src/__pycache__/adb_manager.cpython-313.pyc deleted file mode 100644 index 40e8c2d..0000000 Binary files a/src/__pycache__/adb_manager.cpython-313.pyc and /dev/null differ diff --git a/src/__pycache__/gui.cpython-313.pyc b/src/__pycache__/gui.cpython-313.pyc deleted file mode 100644 index 48ad4c6..0000000 Binary files a/src/__pycache__/gui.cpython-313.pyc and /dev/null differ diff --git a/src/adb_manager.py b/src/adb_manager.py index 0d53835..ff3d1c9 100644 --- a/src/adb_manager.py +++ b/src/adb_manager.py @@ -144,18 +144,67 @@ class ADBManager: return None def parse_progress(self, text_line: str) -> Optional[int]: - """Parse progress percentage from ADB output.""" + """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" and len(local_path) == 3 and local_path.endswith(":\\"): + # 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) @@ -171,19 +220,59 @@ class ADBManager: 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: - # If no explicit progress, simulate some progress based on activity - if line_count % 5 == 0 and last_progress < 90: - estimated_progress = min(last_progress + 5, 90) - self._update_progress(estimated_progress) - last_progress = estimated_progress + # 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()) @@ -193,7 +282,16 @@ class ADBManager: self._update_status("Transfer completed successfully.") return True else: - self._update_status(f"Transfer failed with code {proc.returncode}") + # 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) return False except Exception as e: @@ -202,8 +300,27 @@ 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" and len(local_path) == 3 and local_path.endswith(":\\"): + # 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) @@ -222,19 +339,59 @@ class ADBManager: 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: - # If no explicit progress, simulate some progress based on activity - if line_count % 5 == 0 and last_progress < 90: - estimated_progress = min(last_progress + 5, 90) - self._update_progress(estimated_progress) - last_progress = estimated_progress + # 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()) @@ -244,7 +401,16 @@ class ADBManager: self._update_status("Transfer completed successfully.") return True else: - self._update_status(f"adb push failed with return code {proc.returncode}") + # 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) return False diff --git a/src/gui/__init__.py b/src/gui/__init__.py new file mode 100644 index 0000000..f12d5ab --- /dev/null +++ b/src/gui/__init__.py @@ -0,0 +1,17 @@ +""" +GUI Package for Android File Handler +Provides modular GUI components for the Android file transfer application. +""" + +from .main_window import AndroidFileHandlerGUI, main +from .progress_handler import ProgressHandler +from .windows_browser import WindowsAndroidBrowser +from .linux_browser import LinuxAndroidBrowser + +__all__ = [ + "AndroidFileHandlerGUI", + "main", + "ProgressHandler", + "WindowsAndroidBrowser", + "LinuxAndroidBrowser", +] diff --git a/src/gui/linux_browser.py b/src/gui/linux_browser.py new file mode 100644 index 0000000..bb7339e --- /dev/null +++ b/src/gui/linux_browser.py @@ -0,0 +1,68 @@ +""" +Linux Android Browser Module +Provides Android filesystem browsing capabilities for Linux using MTP. +""" + +import os +from tkinter import messagebox, filedialog + + +class LinuxAndroidBrowser: + """Android filesystem browser for Linux using MTP.""" + + def __init__(self, parent_window, mtp_manager, remote_path_var): + self.parent = parent_window + self.mtp_manager = mtp_manager + self.remote_path_var = remote_path_var + + def show_browser(self): + """Browse remote Android folders via MTP (Linux)""" + if not self.mtp_manager: + messagebox.showerror( + "Error", + "MTP manager not available. This feature is only supported on Linux.", + ) + return + + # First try using existing GVFS mount + gvfs_mount = self.mtp_manager.find_gvfs_mtp_mount() + if gvfs_mount: + try: + folder = filedialog.askdirectory( + initialdir=gvfs_mount, title="Select Android folder" + ) + if folder: + # Convert filesystem path back to Android path + relative_path = os.path.relpath(folder, gvfs_mount) + if relative_path == ".": + android_path = "/sdcard" + else: + android_path = f"/sdcard/{relative_path}".replace("\\", "/") + self.remote_path_var.set(android_path) + return + except Exception as e: + print(f"GVFS browse failed: {e}") + + # Fallback to jmtpfs + mount_point = self.mtp_manager.mount_mtp_device() + if mount_point: + try: + folder = filedialog.askdirectory( + initialdir=mount_point, title="Select Android folder" + ) + if folder: + # Convert filesystem path back to Android path + relative_path = os.path.relpath(folder, mount_point) + if relative_path == ".": + android_path = "/sdcard" + else: + android_path = f"/sdcard/{relative_path}".replace("\\", "/") + self.remote_path_var.set(android_path) + finally: + self.mtp_manager.unmount_mtp_device() + else: + messagebox.showerror( + "Error", + "Could not mount Android device via MTP. " + "Make sure it's connected and set to 'File Transfer' mode.", + ) diff --git a/src/gui/main_window.py b/src/gui/main_window.py new file mode 100644 index 0000000..ee2ac36 --- /dev/null +++ b/src/gui/main_window.py @@ -0,0 +1,430 @@ +""" +Main Window Module +Core GUI application window for Android file transfers. +""" + +import os +import sys +import threading +import tkinter as tk +from tkinter import messagebox, filedialog, ttk + +try: + # Try relative import first (when used as module) + from ..adb_manager import ( + ADBManager, + LinuxMTPManager, + get_platform_type, + is_adb_available, + ) +except ImportError: + # Fall back to direct import (when run directly) + from adb_manager import ( + ADBManager, + LinuxMTPManager, + get_platform_type, + is_adb_available, + ) + +from .progress_handler import ProgressHandler +from .windows_browser import WindowsAndroidBrowser +from .linux_browser import LinuxAndroidBrowser + + +class AndroidFileHandlerGUI(tk.Tk): + """Main GUI application for Android file transfers.""" + + def __init__(self): + super().__init__() + + # Initialize business logic + self.adb_manager = ADBManager() + + # Transfer tracking for thread safety + self.current_transfer_id = 0 + self.device_connected = False # Track device connection state + + if get_platform_type().startswith("linux"): + self.mtp_manager = LinuxMTPManager() + else: + self.mtp_manager = None + + # Setup UI + self._setup_ui() + self._initialize_components() + self._initialize_app() + + def _setup_ui(self): + """Setup the user interface.""" + # Window configuration + self.title("Android Folder Puller") + self.geometry("520x320") + self.minsize(520, 320) + self.resizable(True, True) + + # Direction selection + self.direction_var = tk.StringVar(value="pull") + direction_frame = tk.Frame(self) + direction_frame.pack(anchor="w", padx=10, pady=(10, 0)) + tk.Radiobutton( + direction_frame, + text="Pull (Android → Computer)", + variable=self.direction_var, + value="pull", + ).pack(side="left") + tk.Radiobutton( + direction_frame, + text="Push (Computer → Android)", + variable=self.direction_var, + value="push", + ).pack(side="left", padx=(20, 0)) + + # Remote folder path + tk.Label(self, text="Remote folder path (Android device):").pack( + anchor="w", padx=10, pady=(10, 0) + ) + self.remote_path_var = tk.StringVar() + remote_path_frame = tk.Frame(self) + remote_path_frame.pack(fill="x", padx=10) + self.remote_path_entry = tk.Entry( + remote_path_frame, textvariable=self.remote_path_var, width=50 + ) + self.remote_path_entry.pack(side="left", fill="x", expand=True) + + # Browse button for remote path + tk.Button( + remote_path_frame, text="Browse...", command=self.browse_remote_folder + ).pack(side="right", padx=(5, 0)) + + # Local folder path + tk.Label(self, text="Local destination folder (Computer):").pack( + anchor="w", padx=10, pady=(10, 0) + ) + self.local_path_var = tk.StringVar() + local_path_frame = tk.Frame(self) + local_path_frame.pack(fill="x", padx=10) + self.local_path_entry = tk.Entry( + local_path_frame, textvariable=self.local_path_var, width=50 + ) + self.local_path_entry.pack(side="left", fill="x", expand=True) + tk.Button( + local_path_frame, text="Browse...", command=self.browse_local_folder + ).pack(side="right", padx=(5, 0)) + + # Progress bar + self.progress = ttk.Progressbar(self, orient="horizontal", mode="determinate") + self.progress.pack(fill="x", padx=10, pady=(20, 5)) + + # Status label + self.status_label = tk.Label(self, text="Status: Idle") + self.status_label.pack(anchor="w", padx=10, fill="x") + + # Start/Recheck button (will change based on device state) + self.start_btn = tk.Button( + self, text="Start Transfer", command=self.handle_button_click + ) + self.start_btn.pack(pady=10) + + # Window close protocol + self.protocol("WM_DELETE_WINDOW", self.on_close) + + def _initialize_components(self): + """Initialize GUI components and handlers.""" + # Progress handler + self.progress_handler = ProgressHandler(self, self.progress, self.status_label) + + # Set up ADB callbacks + self.adb_manager.set_progress_callback(self.progress_handler.update_progress) + self.adb_manager.set_status_callback(self.progress_handler.set_status) + + # Browser components + self.windows_browser = WindowsAndroidBrowser( + self, self.adb_manager, self.remote_path_var + ) + self.linux_browser = LinuxAndroidBrowser( + self, self.mtp_manager, self.remote_path_var + ) + + def _initialize_app(self): + """Initialize the application - check ADB and device.""" + # Check adb availability + if not is_adb_available(): + self.disable_controls() + self.progress_handler.set_status("ADB not found locally. Downloading...") + self.update() + success = self.adb_manager.download_and_extract_adb() + if success: + self.progress_handler.set_status("ADB downloaded and ready.") + self.enable_controls() + else: + self.progress_handler.set_status( + "Failed to download ADB. Please check your internet and restart." + ) + messagebox.showerror("Error", "Failed to download ADB tools. Exiting.") + self.quit() + return + + # Check device connected + self.check_device_connection() + + def check_device_connection(self): + """Check for device connection and update UI accordingly.""" + self.progress_handler.set_status("Checking for connected device...") + self.update() + device = self.adb_manager.check_device() + if not device: + self.device_connected = False + self.disable_controls() + self.progress_handler.set_status( + "No device detected. Enable USB debugging and connect your device." + ) + self._switch_to_recheck_mode() + self.show_enable_debugging_instructions() + else: + self.device_connected = True + self.progress_handler.set_status(f"Device detected: {device}") + self._switch_to_transfer_mode() + self.enable_controls() + + def browse_remote_folder(self): + """Browse remote Android folders.""" + if get_platform_type().startswith("linux") and self.mtp_manager: + # Use Linux MTP browser + self.linux_browser.show_browser() + else: + # Use Windows ADB browser + self.windows_browser.show_browser() + + def browse_local_folder(self): + """Browse for local folder.""" + folder = filedialog.askdirectory() + if folder: + self.local_path_var.set(folder) + + def disable_controls(self): + """Disable UI controls during operations.""" + self.remote_path_entry.config(state="disabled") + self.local_path_entry.config(state="disabled") + # Don't disable start_btn here - it will be handled by button mode switching + + def enable_controls(self): + """Enable UI controls after operations (thread-safe).""" + # Schedule UI update on main thread + self.after(0, self._enable_controls_ui) + + def _enable_controls_ui(self): + """Internal method to enable controls on main thread.""" + self.remote_path_entry.config(state="normal") + self.local_path_entry.config(state="normal") + # Button state is handled by mode switching methods + + def _switch_to_recheck_mode(self): + """Switch button to recheck device mode.""" + self.start_btn.config( + text="Recheck for connected Android device", + command=self.recheck_device, + state="normal", + ) + + def _switch_to_transfer_mode(self): + """Switch button to transfer mode.""" + self.start_btn.config( + text="Start Transfer", command=self.start_transfer, state="normal" + ) + + def handle_button_click(self): + """Handle button click - delegates to appropriate method based on device state.""" + if self.device_connected: + self.start_transfer() + else: + self.recheck_device() + + def recheck_device(self): + """Recheck for connected Android device.""" + # Temporarily disable the button during recheck + self.start_btn.config(state="disabled") + self.start_btn.config(text="Checking...") + + # Use after() to allow UI to update before blocking operation + self.after(100, self._perform_device_recheck) + + def _perform_device_recheck(self): + """Perform the actual device recheck.""" + self.check_device_connection() + + def show_enable_debugging_instructions(self): + """Show instructions for enabling USB debugging.""" + msg = ( + "To enable USB debugging:\n" + "1. Open Settings → About phone.\n" + "2. Tap 'Build number' seven times to unlock Developer Options.\n" + "3. Go back to Settings → Developer Options.\n" + "4. Enable 'USB debugging'.\n" + "5. Connect your phone via USB and accept the prompt to allow debugging.\n\n" + "After enabling, click 'Recheck for connected Android device' to try again." + ) + result = messagebox.showinfo("Enable USB Debugging", msg) + # After user clicks OK, ensure recheck button is enabled + self.after(0, self._enable_recheck_after_dialog) + + def _enable_recheck_after_dialog(self): + """Re-enable recheck button after user dismisses the dialog.""" + if not self.device_connected: + self._switch_to_recheck_mode() + + def show_disable_debugging_reminder(self): + """Show reminder to disable USB debugging after transfer (thread-safe).""" + # Schedule UI update on main thread + self.after(0, self._show_debugging_reminder_ui) + + def _show_debugging_reminder_ui(self): + """Internal method to show debugging reminder on main thread.""" + msg = ( + "Transfer completed.\n\n" + "For security, disable USB debugging when done:\n" + "Settings → Developer Options → disable 'USB debugging'." + ) + messagebox.showinfo("Disable USB Debugging", msg) + + def start_transfer(self): + """Start the file transfer operation.""" + # Double-check device is still connected before starting transfer + if not self.device_connected: + messagebox.showerror( + "No Device", + "No Android device is connected. Please connect your device and enable USB debugging.", + ) + self._switch_to_recheck_mode() + return + + remote_path = self.remote_path_var.get().strip() + local_path = self.local_path_var.get().strip() + direction = self.direction_var.get() + + # Validate inputs + if not remote_path: + messagebox.showerror("Input Error", "Remote folder path cannot be empty.") + return + if not local_path or not os.path.isdir(local_path): + messagebox.showerror( + "Input Error", "Please select a valid local destination folder." + ) + return + + # Final device check before transfer + device = self.adb_manager.check_device() + if not device: + self.device_connected = False + messagebox.showerror( + "Device Disconnected", + "Android device was disconnected. Please reconnect and try again.", + ) + self._switch_to_recheck_mode() + return + + # Start transfer + self.current_transfer_id += 1 + transfer_id = self.current_transfer_id + + self.disable_controls() + self.start_btn.config( + state="disabled" + ) # Disable transfer button during transfer + # Reset progress bar in thread-safe way + self.progress_handler.reset_progress() + + if direction == "pull": + self.progress_handler.set_status("Starting pull transfer...") + threading.Thread( + target=self._pull_thread, + args=(remote_path, local_path, transfer_id), + daemon=True, + ).start() + elif direction == "push": + self.progress_handler.set_status("Starting push transfer...") + threading.Thread( + target=self._push_thread, + args=(local_path, remote_path, transfer_id), + daemon=True, + ).start() + else: + self.report_error("Invalid transfer direction selected.") + self.enable_controls() + + def _pull_thread(self, remote_path: str, local_path: str, transfer_id: int): + """Thread function for pull operations.""" + try: + # Check if this transfer is still current + if self.current_transfer_id != transfer_id: + return + + success = self.adb_manager.pull_folder(remote_path, local_path) + if success and self.current_transfer_id == transfer_id: + self.show_disable_debugging_reminder() + except Exception as e: + if self.current_transfer_id == transfer_id: + self.report_error(f"Pull operation failed: {e}") + finally: + if self.current_transfer_id == transfer_id: + self.enable_controls() + # Restore proper button state after transfer + self.after(0, self._restore_button_state) + + def _push_thread(self, local_path: str, remote_path: str, transfer_id: int): + """Thread function for push operations.""" + try: + # Check if this transfer is still current + if self.current_transfer_id != transfer_id: + return + + success = self.adb_manager.push_folder(local_path, remote_path) + if success and self.current_transfer_id == transfer_id: + self.show_disable_debugging_reminder() + except Exception as e: + if self.current_transfer_id == transfer_id: + self.report_error(f"Push operation failed: {e}") + finally: + if self.current_transfer_id == transfer_id: + self.enable_controls() + # Restore proper button state after transfer + self.after(0, self._restore_button_state) + + def _restore_button_state(self): + """Restore the correct button state based on device connection.""" + if self.device_connected: + self._switch_to_transfer_mode() + else: + self._switch_to_recheck_mode() + + def report_error(self, message: str): + """Report an error to the user (thread-safe).""" + # Schedule UI update on main thread + self.after(0, lambda: self._report_error_ui(message)) + + def _report_error_ui(self, message: str): + """Internal method to report error on main thread.""" + self.status_label.config(text=f"Error: {message}") + messagebox.showerror("Error", message) + self.enable_controls() + + def on_close(self): + """Handle window close event.""" + self.destroy() + + +def main(): + """Main function to run the application.""" + if sys.platform not in ["win32", "linux"]: + root = tk.Tk() + root.withdraw() + messagebox.showerror( + "Unsupported OS", "This application only supports Windows or Linux." + ) + sys.exit(1) + + app = AndroidFileHandlerGUI() + app.mainloop() + + +if __name__ == "__main__": + main() diff --git a/src/gui/progress_handler.py b/src/gui/progress_handler.py new file mode 100644 index 0000000..2bd5362 --- /dev/null +++ b/src/gui/progress_handler.py @@ -0,0 +1,78 @@ +""" +Progress Handler Module +Handles progress bar updates and status messages in a thread-safe manner. +""" + +import tkinter as tk +from tkinter import ttk + + +class ProgressHandler: + """Handles progress bar updates and status messages.""" + + def __init__( + self, parent_widget, progress_bar: ttk.Progressbar, status_label: tk.Label + ): + self.parent = parent_widget + self.progress_bar = progress_bar + self.status_label = status_label + + def update_progress(self, percentage: int): + """Update the progress bar (thread-safe).""" + # Debug output for large transfer troubleshooting + print(f"[DEBUG] Progress update called: {percentage}%") + + # Schedule UI update on main thread using a proper closure + def update_ui(): + self._update_progress_ui(percentage) + + self.parent.after(0, update_ui) + + def _update_progress_ui(self, percentage: int): + """Internal method to update progress bar on main thread.""" + # Ensure percentage is within valid range + percentage = max(0, min(100, percentage)) + + # Debug output for large transfer troubleshooting + current_value = self.progress_bar["value"] + print(f"[DEBUG] UI Progress update: {current_value} -> {percentage}%") + + if ( + hasattr(self, "_last_percentage") + and abs(percentage - self._last_percentage) >= 10 + ): + print( + f"[DEBUG] Major progress jump: {self._last_percentage}% -> {percentage}%" + ) + self._last_percentage = percentage + + try: + self.progress_bar["value"] = percentage + self.progress_bar.update() # Force immediate update + self.parent.update_idletasks() + print(f"[DEBUG] Progress bar updated successfully to {percentage}%") + except Exception as e: + print(f"[DEBUG] Error updating progress bar: {e}") + + def set_status(self, message: str): + """Update the status label (thread-safe).""" + + # Schedule UI update on main thread using a proper closure + def update_ui(): + self._set_status_ui(message) + + self.parent.after(0, update_ui) + + def _set_status_ui(self, message: str): + """Internal method to update status on main thread.""" + self.status_label.config(text=f"Status: {message}") + self.parent.update_idletasks() + + def reset_progress(self): + """Reset progress bar to 0 (thread-safe).""" + + # Schedule UI update on main thread using a proper closure + def update_ui(): + self._update_progress_ui(0) + + self.parent.after(0, update_ui) diff --git a/src/gui/windows_browser.py b/src/gui/windows_browser.py new file mode 100644 index 0000000..f9289a6 --- /dev/null +++ b/src/gui/windows_browser.py @@ -0,0 +1,426 @@ +""" +Windows Android Browser Module +Provides Android filesystem browsing capabilities for Windows using ADB. +""" + +import threading +import tkinter as tk +from tkinter import messagebox, ttk, simpledialog + + +class WindowsAndroidBrowser: + """Android filesystem browser for Windows using ADB.""" + + def __init__(self, parent_window, adb_manager, remote_path_var): + self.parent = parent_window + self.adb_manager = adb_manager + self.remote_path_var = remote_path_var + + def show_browser(self): + """Show a browsable Android folder tree for Windows users.""" + # Check if device is connected + device = self.adb_manager.check_device() + if not device: + messagebox.showerror( + "No Device", + "No Android device connected. Please connect your device and enable USB debugging.", + ) + return + + # Create browsable folder dialog + browser_window = tk.Toplevel(self.parent) + browser_window.title("Browse Android Folders") + browser_window.geometry("500x400") + browser_window.transient(self.parent) + browser_window.grab_set() + + tk.Label( + browser_window, + text="Browse Android device folders:", + font=("Arial", 10, "bold"), + ).pack(pady=10) + + # Create treeview for folder browsing + tree_frame = tk.Frame(browser_window) + tree_frame.pack(fill="both", expand=True, padx=10) + + # Treeview with scrollbars + tree_scroll_y = tk.Scrollbar(tree_frame) + tree_scroll_y.pack(side="right", fill="y") + + tree_scroll_x = tk.Scrollbar(tree_frame, orient="horizontal") + tree_scroll_x.pack(side="bottom", fill="x") + + tree = ttk.Treeview( + tree_frame, + yscrollcommand=tree_scroll_y.set, + xscrollcommand=tree_scroll_x.set, + ) + tree.pack(side="left", fill="both", expand=True) + + tree_scroll_y.config(command=tree.yview) + tree_scroll_x.config(command=tree.xview) + + # Current path display + path_frame = tk.Frame(browser_window) + path_frame.pack(fill="x", padx=10, pady=5) + tk.Label(path_frame, text="Current path:").pack(side="left") + current_path_var = tk.StringVar(value="/sdcard") + current_path_label = tk.Label( + path_frame, textvariable=current_path_var, font=("Courier", 9) + ) + current_path_label.pack(side="left", padx=(5, 0)) + + def load_folders_async(parent_item, path): + """Load folders asynchronously to avoid UI freezing.""" + + def load_in_thread(): + try: + # Ensure path ends with / for proper directory listing + list_path = path if path.endswith("/") else path + "/" + + # Use ls -la to get detailed listing with file type information + result = self.adb_manager.run_adb_command( + ["shell", "ls", "-la", list_path] + ) + + if not isinstance(result, tuple) or len(result) != 3: + self.parent.after( + 0, + lambda: tree.insert( + parent_item, + "end", + text="(Error loading folders)", + values=[""], + ), + ) + return + + stdout, stderr, returncode = result + + # Update UI in main thread - always remove Loading... first + def update_tree(): + # First, remove any existing dummy children + children = tree.get_children(parent_item) + for child in children: + child_text = tree.item(child, "text") + if child_text in [ + "Loading...", + "(No Folders)", + "(Error loading folders)", + "(Permission denied)", + ]: + tree.delete(child) + + # Check for errors + if returncode != 0: + if stderr and "Permission denied" in stderr: + tree.insert( + parent_item, + "end", + text="(Permission denied)", + values=[""], + ) + else: + tree.insert( + parent_item, + "end", + text="(Error loading folders)", + values=[""], + ) + return + + if not stdout or not stdout.strip(): + tree.insert( + parent_item, "end", text="(No Folders)", values=[""] + ) + return + + # Parse ls -la output to find directories + folders = [] + lines = stdout.strip().split("\n") + for line in lines: + if line.startswith("d"): + # Extract folder name - improved parsing to prevent truncation + parts = line.split() + if len(parts) >= 8: + # Method 1: Use regex to find time pattern and extract name after it + import re + + time_pattern = r"\d{1,2}:\d{2}" + time_matches = list(re.finditer(time_pattern, line)) + + if time_matches: + # Take everything after the last time pattern + last_time_match = time_matches[-1] + folder_name = line[ + last_time_match.end() : + ].strip() + else: + # Fallback: join from part 8 (skip date/time fields) + folder_name = ( + " ".join(parts[8:]) + if len(parts) > 8 + else parts[7] + ) + + # Additional validation and cleanup + folder_name = folder_name.strip() + + if ( + folder_name + and folder_name not in [".", ".."] + and not folder_name.startswith(".") + ): + folders.append(folder_name) + + # Add folders to tree + if folders: + for folder in sorted(folders): + folder_path = f"{path.rstrip('/')}/{folder}" + item = tree.insert( + parent_item, + "end", + text=folder, + values=[folder_path], + ) + # Add a dummy child to make it expandable + tree.insert(item, "end", text="Loading...") + else: + # No folders found, show indicator + tree.insert( + parent_item, "end", text="(No Folders)", values=[""] + ) + + self.parent.after(0, update_tree) + + except Exception as e: + print(f"Error loading folders from {path}: {e}") + + def error_update(): + # Remove Loading... even on error + children = tree.get_children(parent_item) + for child in children: + child_text = tree.item(child, "text") + if child_text == "Loading...": + tree.delete(child) + tree.insert( + parent_item, + "end", + text="(Error loading folders)", + values=[""], + ) + + self.parent.after(0, error_update) + + # Run in background thread + threading.Thread(target=load_in_thread, daemon=True).start() + + def load_folders(parent_item, path): + """Load folders from Android device using ADB (legacy sync version for initial load).""" + # First, remove any existing dummy children + children = tree.get_children(parent_item) + for child in children: + child_text = tree.item(child, "text") + if child_text in [ + "Loading...", + "(No Folders)", + "(Error loading folders)", + "(Permission denied)", + ]: + tree.delete(child) + + try: + # Ensure path ends with / for proper directory listing + list_path = path if path.endswith("/") else path + "/" + + # Use ls -la to get detailed listing with file type information + result = self.adb_manager.run_adb_command( + ["shell", "ls", "-la", list_path] + ) + + if not isinstance(result, tuple) or len(result) != 3: + tree.insert( + parent_item, "end", text="(Error loading folders)", values=[""] + ) + return [] + + stdout, stderr, returncode = result + if returncode != 0: + if stderr and "Permission denied" in stderr: + tree.insert( + parent_item, "end", text="(Permission denied)", values=[""] + ) + else: + tree.insert( + parent_item, + "end", + text="(Error loading folders)", + values=[""], + ) + return [] + + if not stdout or not stdout.strip(): + tree.insert(parent_item, "end", text="(No Folders)", values=[""]) + return [] + + # Parse ls -la output to find directories + folders = [] + lines = stdout.strip().split("\n") + for line in lines: + if line.startswith("d"): + # Extract folder name - improved parsing to prevent truncation + parts = line.split() + if len(parts) >= 8: + # Method 1: Use regex to find time pattern and extract name after it + import re + + time_pattern = r"\d{1,2}:\d{2}" + time_matches = list(re.finditer(time_pattern, line)) + + if time_matches: + # Take everything after the last time pattern + last_time_match = time_matches[-1] + folder_name = line[last_time_match.end() :].strip() + else: + # Fallback: join from part 8 (skip date/time fields) + folder_name = ( + " ".join(parts[8:]) if len(parts) > 8 else parts[7] + ) + + # Additional validation and cleanup + folder_name = folder_name.strip() + + if ( + folder_name + and folder_name not in [".", ".."] + and not folder_name.startswith(".") + ): + folders.append(folder_name) + + # Add folders to tree + if folders: + for folder in sorted(folders): + folder_path = f"{path.rstrip('/')}/{folder}" + item = tree.insert( + parent_item, "end", text=folder, values=[folder_path] + ) + # Add a dummy child to make it expandable + tree.insert(item, "end", text="Loading...") + else: + # No folders found, show indicator + tree.insert(parent_item, "end", text="(No Folders)", values=[""]) + + return folders + except Exception as e: + print(f"Error loading folders from {path}: {e}") + tree.insert( + parent_item, "end", text="(Error loading folders)", values=[""] + ) + return [] + + def on_tree_expand(event): + """Handle tree expansion - load subfolders dynamically.""" + item = tree.selection()[0] if tree.selection() else tree.focus() + if not item: + return + + # Get the path + folder_path = ( + tree.item(item, "values")[0] if tree.item(item, "values") else None + ) + if not folder_path: # Skip items without valid paths (like "(No Folders)") + return + + current_path_var.set(folder_path) + + # Check if we need to load subfolders + children = tree.get_children(item) + has_loading = any( + tree.item(child, "text") == "Loading..." for child in children + ) + + # Only load if we have a "Loading..." placeholder - use async version + if has_loading: + load_folders_async(item, folder_path) + + def on_tree_select(event): + """Handle tree selection - update current path.""" + item = tree.selection()[0] if tree.selection() else None + if item: + folder_path = ( + tree.item(item, "values")[0] if tree.item(item, "values") else None + ) + if folder_path: # Only update if valid path + current_path_var.set(folder_path) + + # Bind events + tree.bind("<>", on_tree_expand) + tree.bind("<>", on_tree_select) + + # Determine which path to use - prefer /sdcard, fallback to /storage/emulated/0 + primary_path = "/sdcard" + fallback_path = "/storage/emulated/0" + + # Test if /sdcard is accessible (with trailing slash for directory listing) + test_result = self.adb_manager.run_adb_command( + ["shell", "ls", "-la", primary_path + "/"] + ) + if ( + isinstance(test_result, tuple) + and len(test_result) == 3 + and test_result[2] == 0 + ): + # /sdcard is accessible + android_path = primary_path + else: + # /sdcard not accessible, use fallback + android_path = fallback_path + + # Create single "Android" root item + android_item = tree.insert("", "end", text="Android", values=[android_path]) + tree.insert(android_item, "end", text="Loading...") + + # Set initial path + current_path_var.set(android_path) + + # Expand and load the Android root immediately + tree.item(android_item, open=True) + load_folders(android_item, android_path) + + # Select the Android item + tree.selection_set(android_item) + + # Buttons + button_frame = tk.Frame(browser_window) + button_frame.pack(pady=10) + + def select_current_folder(): + """Select the currently highlighted folder.""" + current_path = current_path_var.get() + if current_path and current_path.strip(): + self.remote_path_var.set(current_path) + browser_window.destroy() + else: + messagebox.showwarning("No Selection", "Please select a valid folder.") + + def enter_custom_path(): + """Allow user to enter a custom path.""" + browser_window.destroy() + custom_path = simpledialog.askstring( + "Custom Path", + "Enter custom Android folder path:", + initialvalue="/sdcard/", + ) + if custom_path: + self.remote_path_var.set(custom_path.strip()) + + tk.Button( + button_frame, text="Select This Folder", command=select_current_folder + ).pack(side="left", padx=5) + tk.Button( + button_frame, text="Enter Custom Path", command=enter_custom_path + ).pack(side="left", padx=5) + tk.Button(button_frame, text="Cancel", command=browser_window.destroy).pack( + side="left", padx=5 + ) diff --git a/src/main.py b/src/main.py index 0597dec..3907a15 100644 --- a/src/main.py +++ b/src/main.py @@ -6,10 +6,31 @@ Simple entry point to launch the Android file transfer application. try: # Try relative import first (when used as module) - from .gui import main + from .gui.main_window import main except ImportError: # Fall back to direct import (when run directly) - from gui import main + try: + from gui.main_window import main + except ImportError: + from gui import AndroidFileHandlerGUI + import sys + + def main(): + """Main function to run the application.""" + if sys.platform not in ["win32", "linux"]: + import tkinter as tk + from tkinter import messagebox + + root = tk.Tk() + root.withdraw() + messagebox.showerror( + "Unsupported OS", "This application only supports Windows or Linux." + ) + sys.exit(1) + + app = AndroidFileHandlerGUI() + app.mainloop() + if __name__ == "__main__": main()