main window refactor

This commit is contained in:
2025-09-23 06:01:53 +08:00
committed by Jason Ross
parent 21e858dfca
commit 6d08df1807
7 changed files with 1724 additions and 1006 deletions
+124
View File
@@ -0,0 +1,124 @@
"""
Animation Handler Module
Manages all GUI animations for the Android file transfer application.
"""
import tkinter as tk
from typing import Optional, Dict, Any
class AnimationHandler:
"""Handles all GUI animations including transfer and scanning animations."""
def __init__(self, parent_window: tk.Tk):
"""Initialize the animation handler.
Args:
parent_window: The main window instance
"""
self.parent = parent_window
self.animation_job: Optional[str] = None
self.animation_dots = 0
self.scanning_active = False
# File transfer progress tracking
self.transfer_file_progress = {
'current': 0,
'total': 0,
'active': False
}
def start_scanning_animation(self) -> None:
"""Start the 'Scanning for duplicates...' animation."""
self.animation_dots = 0
self.scanning_active = True
self.animation_job = self.parent.after(0, self._animate_scanning_text)
def start_transfer_animation(self) -> None:
"""Start the 'Transferring...' animation."""
self.animation_dots = 0
self.scanning_active = False
self.animation_job = self.parent.after(0, self._animate_transfer_text)
def stop_animation(self) -> None:
"""Stop any running animation."""
if self.animation_job is not None:
self.parent.after_cancel(self.animation_job)
self.animation_job = None
self.scanning_active = False
self._reset_file_progress()
def update_transfer_progress(self, current: int, total: int) -> None:
"""Update the file transfer progress.
Args:
current: Current number of files transferred
total: Total number of files to transfer
"""
self.transfer_file_progress['current'] = current
self.transfer_file_progress['total'] = total
self.transfer_file_progress['active'] = True
def _animate_scanning_text(self) -> None:
"""Animate the scanning text with dots."""
if self.animation_job is not None and self.scanning_active:
dots = "." * (self.animation_dots + 1)
status_text = f"Scanning for duplicates{dots}"
self._update_status_label(status_text)
self.animation_dots = (self.animation_dots + 1) % 5 # Cycle 0-4 dots
# Schedule next update in 500ms
self.animation_job = self.parent.after(500, self._animate_scanning_text)
def _animate_transfer_text(self) -> None:
"""Animate the transfer text with dots."""
if self.animation_job is not None and not self.scanning_active:
dots = "." * (self.animation_dots + 1)
# Show file progress if available
if (self.transfer_file_progress['active'] and
self.transfer_file_progress['total'] > 0):
current = self.transfer_file_progress['current']
total = self.transfer_file_progress['total']
status_text = f"Transferring {current} of {total} files{dots}"
else:
status_text = f"Transferring{dots}"
self._update_status_label(status_text)
self.animation_dots = (self.animation_dots + 1) % 5 # Cycle 0-4 dots
# Schedule next update in 500ms
self.animation_job = self.parent.after(500, self._animate_transfer_text)
def _update_status_label(self, text: str) -> None:
"""Update the status label with the given text.
Args:
text: Text to display in the status label
"""
if hasattr(self.parent, 'status_label'):
self.parent.status_label.config(text=text)
self.parent.update_idletasks()
def _reset_file_progress(self) -> None:
"""Reset file progress tracking."""
self.transfer_file_progress = {
'current': 0,
'total': 0,
'active': False
}
def is_animation_running(self) -> bool:
"""Check if any animation is currently running.
Returns:
True if animation is running, False otherwise
"""
return self.animation_job is not None
def is_scanning(self) -> bool:
"""Check if scanning animation is active.
Returns:
True if scanning animation is active, False otherwise
"""
return self.scanning_active
+172
View File
@@ -0,0 +1,172 @@
"""
Device Manager Module
Handles Android device connection and ADB operations for the GUI.
"""
import tkinter as tk
from tkinter import messagebox
from typing import Optional, Callable
try:
from ..adb_manager import ADBManager, is_adb_available
except ImportError:
from adb_manager import ADBManager, is_adb_available
class DeviceManager:
"""Manages Android device connections and ADB operations."""
def __init__(self, parent_window: tk.Tk, status_callback: Optional[Callable[[str], None]] = None):
"""Initialize the device manager.
Args:
parent_window: The main window instance
status_callback: Callback function for status updates
"""
self.parent = parent_window
self.status_callback = status_callback
self.adb_manager = ADBManager()
self.device_connected = False
# Set up ADB callbacks
self.adb_manager.set_status_callback(self._on_adb_status_update)
self.adb_manager.set_progress_callback(self._on_adb_progress_update)
def initialize_adb(self) -> bool:
"""Initialize ADB and download tools if needed.
Returns:
True if ADB is available and ready, False otherwise
"""
if not is_adb_available():
# Show welcome message when platform-tools need to be downloaded
messagebox.showinfo(
"Welcome to Android File Transfer!",
"Welcome to Android File Transfer! This application simplifies "
"and speeds up file transfers over USB between computers and Android devices. "
"Please do not delete or move the platform-tools folder that will be "
"downloaded. These are tools written by Google "
"and they are required for this application to function properly."
)
self._update_status("ADB not found locally. Downloading...")
self.parent.update()
self.adb_manager.download_and_extract_adb()
success = is_adb_available()
if success:
self._update_status("ADB downloaded and ready.")
return True
else:
self._update_status(
"Failed to download/access Android Debug Bridge tools. "
"Please check your internet and for any blocking security pop-ups and restart."
)
messagebox.showerror("Error", "Failed to download ADB tools. Exiting.")
return False
return True
def check_device_connection(self) -> Optional[str]:
"""Check for device connection and update status.
Returns:
Device ID if connected, None otherwise
"""
self._update_status("Checking for connected device...")
self.parent.update()
device = self.adb_manager.check_device()
if device:
self.device_connected = True
self._update_status(f"Device detected: {device}")
return device
else:
self.device_connected = False
self._update_status(
"No Android devices detected. Please check the USB connection at both ends is "
"securely inserted, USB debugging is enabled, and that File Transfer mode is turned on."
)
return None
def is_remote_file(self, remote_path: str) -> bool:
"""Check if the remote path points to a file (not a directory).
Args:
remote_path: Path on the Android device
Returns:
True if it's a file, False if it's a directory or check fails
"""
try:
result = self.adb_manager.run_adb_command(["shell", "ls", "-la", remote_path])
if isinstance(result, tuple) and len(result) == 3:
stdout, stderr, returncode = result
if returncode == 0 and stdout:
# If the output starts with '-', it's a regular file
return stdout.strip().startswith('-')
return False
except Exception:
return False
def get_file_transfer_methods(self, direction: str, is_file: bool):
"""Get the appropriate transfer methods based on direction and type.
Args:
direction: Transfer direction ('pull' or 'push')
is_file: Whether transferring a file (True) or folder (False)
Returns:
Tuple of (transfer_method, transfer_type)
"""
if direction == "pull":
if is_file:
return self.adb_manager.pull_file, "file"
else:
return self.adb_manager.pull_folder_with_dedup, "folder"
else: # push
if is_file:
return self.adb_manager.push_file, "file"
else:
return self.adb_manager.push_folder_with_dedup, "folder"
def cancel_current_operation(self) -> None:
"""Cancel the current ADB operation."""
try:
self.adb_manager.cancel_current_operation()
except Exception as e:
print(f"Error cancelling operation: {e}")
def cancel_transfer(self) -> bool:
"""Cancel the current transfer operation.
Returns:
True if transfer was cancelled successfully, False otherwise
"""
return self.adb_manager.cancel_transfer()
def _update_status(self, message: str) -> None:
"""Update status through callback if available.
Args:
message: Status message to display
"""
if self.status_callback:
self.status_callback(message)
def _on_adb_status_update(self, message: str) -> None:
"""Handle status updates from ADB manager.
Args:
message: Status message from ADB operations
"""
self._update_status(message)
def _on_adb_progress_update(self, percentage: int) -> None:
"""Handle progress updates from ADB manager.
Args:
percentage: Progress percentage (0-100)
"""
# Currently we ignore progress updates and rely on status updates
pass
+430
View File
@@ -0,0 +1,430 @@
"""
Dialog Manager Module
Handles various dialog boxes and message windows for the application.
"""
import tkinter as tk
from tkinter import messagebox, filedialog, scrolledtext
import os
from typing import Optional, Tuple
class DialogManager:
"""Manages various dialog boxes and user interactions."""
def __init__(self, parent_window: tk.Tk):
"""Initialize the dialog manager.
Args:
parent_window: The main window instance
"""
self.parent = parent_window
self.troubleshooting_steps = (
"Android device appears to have been disconnected and/or USB debugging is disabled.\n"
"Please ensure your Android device is securely connected at both ends.\n\n"
"To enable USB debugging:\n"
"1. Connect your device to the computer via USB\n"
"2. Open Settings → About phone\n"
"3. Tap 'Build number' seven times to unlock Developer Options\n"
" (You only need to do this once unless you disable it, reset settings, or wipe your device)\n"
"4. Navigate back and go to System → Developer Options\n"
"5. Find and enable 'USB debugging'\n"
" (Tip: Use the search icon at the top if you can't find it)\n"
"6. Connect via USB and tap 'Trust' when prompted\n"
" (Checking 'Remember' is recommended for future transfers)\n\n"
"Ensure File Transfer mode is enabled:\n"
"1. After connecting, swipe down to view notifications\n"
"2. Look for a USB notification (often shows 'Charging over USB')\n"
"3. Tap the notification and select 'File Transfer' or 'MTP' mode\n\n"
"Note: Menu names may vary by Android version:\n"
"• Some devices show 'Developer options' under 'System'\n"
"• Others may have it directly in the main Settings menu\n"
"• Samsung devices might show 'Software information' instead of 'About phone'\n\n"
"If you're still having trouble:\n"
"• Try a different USB cable or port (some cables only support charging)\n"
"• Restart both your phone and computer\n"
"• Make sure your phone screen is unlocked when connecting\n"
"• Set your phone screen timeout to 30 minutes (especially for long transfers)\n\n"
"• Use a different computer to test if the issue is computer-specific\n"
"Windows users: If you see a driver installation popup, please allow it to complete.\n"
"Linux users: You may need to run 'sudo usermod -a -G plugdev $USER' and reboot.\n"
"After completing these steps, click 'Recheck for connected Android device' to try again."
)
def show_file_folder_selection_notice(self) -> bool:
"""Show instructions for file and folder selection in a custom dialog.
Returns:
True if user clicked OK, False if user cancelled or closed dialog
"""
# Create custom dialog window
dialog = tk.Toplevel(self.parent)
dialog.title("File/Folder Selection Notice")
dialog.geometry("600x250")
dialog.minsize(600, 250)
dialog.resizable(True, True)
dialog.transient(self.parent)
dialog.grab_set()
# Center the dialog on the parent window
dialog.update_idletasks()
x = self.parent.winfo_x() + (self.parent.winfo_width() // 2) - (600 // 2)
y = self.parent.winfo_y() + (self.parent.winfo_height() // 2) - (250 // 2)
dialog.geometry(f"600x250+{x}+{y}")
# Track if OK was clicked
dialog_confirmed = False
# Create main frame
main_frame = tk.Frame(dialog)
main_frame.pack(fill="both", expand=True, padx=20, pady=20)
# Selection notice text
notice_text = (
"How file and folder selection works in this application\n\n"
"If you want to select a file for transfer, simply click on the file to select it.\n\n"
"If you want to select a folder for transfer, the folder that you navigate to "
"(the current directory you are viewing, not a highlighted folder) will be selected for transfer.\n\n"
"You can only transfer one file or one folder at a time."
)
# Create responsive text label
text_label = tk.Label(
main_frame,
text=notice_text,
justify="left",
anchor="nw",
wraplength=0, # Will be set dynamically
font=("Arial", 10)
)
text_label.pack(fill="both", expand=True, pady=(0, 20))
# OK button with confirmation callback
def on_ok_clicked():
nonlocal dialog_confirmed
dialog_confirmed = True
dialog.destroy()
ok_button = tk.Button(
main_frame,
text="OK",
command=on_ok_clicked,
width=10,
font=("Arial", 10)
)
ok_button.pack(pady=10)
# Configure text wrapping on dialog resize
def on_dialog_configure(event):
if event.widget == dialog:
# Calculate available width for text (account for padding and margins)
available_width = dialog.winfo_width() - 60 # 20px padding * 2 + some margin
if available_width > 200: # Minimum reasonable width
text_label.config(wraplength=available_width)
dialog.bind("<Configure>", on_dialog_configure)
# Set initial wrap length
dialog.after(10, lambda: on_dialog_configure(type('Event', (), {'widget': dialog})()))
# Handle window close (X button) - treat as cancel
def on_dialog_close():
nonlocal dialog_confirmed
dialog_confirmed = False
dialog.destroy()
dialog.protocol("WM_DELETE_WINDOW", on_dialog_close)
# Wait for dialog to close
dialog.wait_window()
return dialog_confirmed
def show_enable_debugging_instructions(self, callback: Optional[callable] = None) -> None:
"""Show instructions to connect device, enable file transfer, and enable USB debugging.
Args:
callback: Optional callback to execute after dialog is closed
"""
# Create custom dialog window
dialog = tk.Toplevel(self.parent)
dialog.title("Check device connection and enable USB Debugging")
dialog.geometry("700x800") # Increased height for better button spacing
dialog.minsize(700, 800) # Set minimum size to ensure all content is visible
dialog.resizable(True, True)
dialog.transient(self.parent)
dialog.grab_set()
# Center the dialog on the parent window
dialog.update_idletasks()
x = self.parent.winfo_x() + (self.parent.winfo_width() // 2) - (700 // 2)
y = self.parent.winfo_y() + (self.parent.winfo_height() // 2) - (450 // 2)
dialog.geometry(f"700x450+{x}+{y}")
# Create main frame
main_frame = tk.Frame(dialog)
main_frame.pack(fill="both", expand=True, padx=20, pady=20)
# Create responsive text label
text_label = tk.Label(
main_frame,
text=self.troubleshooting_steps,
justify="left",
anchor="nw",
wraplength=0, # Will be set dynamically
font=("Arial", 10)
)
text_label.pack(fill="both", expand=True, pady=(0, 20))
# OK button
ok_button = tk.Button(
main_frame,
text="OK",
command=dialog.destroy,
width=10,
font=("Arial", 10)
)
ok_button.pack(pady=10)
# Configure text wrapping on dialog resize
def on_dialog_configure(event):
if event.widget == dialog:
# Calculate available width for text (account for padding and margins)
available_width = dialog.winfo_width() - 60 # 20px padding * 2 + some margin
if available_width > 200: # Minimum reasonable width
text_label.config(wraplength=available_width)
dialog.bind("<Configure>", on_dialog_configure)
# Set initial wrap length
dialog.after(10, lambda: on_dialog_configure(type('Event', (), {'widget': dialog})()))
# Handle dialog close
def on_close():
dialog.destroy()
if callback:
callback()
# After user clicks OK, ensure callback is executed
dialog.protocol("WM_DELETE_WINDOW", on_close)
ok_button.config(command=on_close)
def show_disable_debugging_reminder(self) -> None:
"""Show reminder to disable USB debugging after transfer."""
msg = (
"Transfer completed!\n\n"
"For security, please disable USB debugging when done:\n"
"Settings → Developer Options → disable 'USB debugging'."
)
messagebox.showinfo("Disable USB Debugging", msg)
def show_transfer_stats(self, stats: dict, operation: str, deduplicator=None) -> None:
"""Show transfer statistics dialog.
Args:
stats: Dictionary containing transfer statistics
operation: Type of operation ("Pull" or "Push")
deduplicator: Optional deduplicator instance for byte formatting
"""
# Format bytes saved
bytes_saved_str = ""
if stats['bytes_saved'] > 0:
if deduplicator and hasattr(deduplicator, 'format_bytes'):
bytes_saved_str = f" ({deduplicator.format_bytes(stats['bytes_saved'])} saved)"
else:
bytes_saved_str = f" ({stats['bytes_saved']} bytes saved)"
# Build message
title = f"{operation} Transfer Complete"
if stats['total_files'] == 0:
message = "No files were found to transfer."
else:
message_parts = [
f"Transfer completed successfully!\n",
f"Files found: {stats['total_files']}",
f"Files transferred: {stats['transferred']}",
f"Duplicate files skipped: {stats['skipped']}{bytes_saved_str}"
]
if stats['skipped'] > 0:
message_parts.append(f"\nDuplicate detection helped avoid unnecessary transfers!")
message = "\n".join(message_parts)
# Show dialog
messagebox.showinfo(title, message)
def show_error(self, title: str, message: str) -> None:
"""Show an error dialog.
Args:
title: Dialog title
message: Error message to display
"""
messagebox.showerror(title, message)
def show_info(self, title: str, message: str) -> None:
"""Show an info dialog.
Args:
title: Dialog title
message: Info message to display
"""
messagebox.showinfo(title, message)
def browse_local_file_or_folder(self, direction: str, initial_dir: Optional[str] = None) -> Optional[str]:
"""Browse for local file or folder based on transfer direction.
Args:
direction: Transfer direction ('pull' or 'push')
initial_dir: Initial directory to open browser in
Returns:
Selected path or None if cancelled
"""
if initial_dir is None:
initial_dir = os.path.expanduser("~")
# Show helpful notification about folder selection behavior
if not self.show_file_folder_selection_notice():
return None # User cancelled the notice dialog
if direction == "push":
# For push, show file selection first, then folder selection if cancelled
# First try file selection
selected_path = filedialog.askopenfilename(
title="Select file to push to Android device",
initialdir=initial_dir,
filetypes=[("All files", "*.*")]
)
# If no file was selected, offer folder selection as an alternative
if not selected_path:
selected_path = filedialog.askdirectory(
title="Select folder to push to Android device",
initialdir=initial_dir
)
return selected_path if selected_path else None
else: # pull direction
# For pull, only allow folder selection (destination)
folder = filedialog.askdirectory(
title="Select destination folder for pulled files",
initialdir=initial_dir
)
return folder if folder else None
def show_file_folder_choice(self, on_file_callback, on_folder_callback):
"""Show a dialog to choose between file or folder selection.
Args:
on_file_callback: Callback for file selection
on_folder_callback: Callback for folder selection
"""
# Ask user whether they want to select a file or folder
choice_window = tk.Toplevel(self.parent)
choice_window.title("Select File or Folder")
choice_window.geometry("350x150")
choice_window.resizable(False, False)
choice_window.transient(self.parent)
choice_window.grab_set()
# Center the window
choice_window.geometry("+{}+{}".format(
self.parent.winfo_x() + 100,
self.parent.winfo_y() + 100
))
main_frame = tk.Frame(choice_window)
main_frame.pack(fill="both", expand=True, padx=20, pady=20)
tk.Label(main_frame, text="What would you like to select?",
font=("Arial", 11)).pack(pady=(0, 15))
button_frame = tk.Frame(main_frame)
button_frame.pack(fill="x")
def select_file():
choice_window.destroy()
on_file_callback()
def select_folder():
choice_window.destroy()
on_folder_callback()
def cancel_selection():
choice_window.destroy()
tk.Button(button_frame, text="📄 File", command=select_file,
width=12, font=("Arial", 10)).pack(side="left", padx=(0, 10))
tk.Button(button_frame, text="📁 Folder", command=select_folder,
width=12, font=("Arial", 10)).pack(side="left", padx=(0, 10))
tk.Button(button_frame, text="Cancel", command=cancel_selection,
width=12).pack(side="right")
def show_transfer_stats(self, stats: dict, operation: str):
"""Show transfer statistics in a dialog.
Args:
stats: Dictionary containing transfer statistics
operation: Description of the operation performed
"""
if not stats:
return
stats_window = tk.Toplevel(self.parent)
stats_window.title("Transfer Statistics")
stats_window.geometry("400x300")
stats_window.resizable(True, True)
stats_window.transient(self.parent)
stats_window.grab_set()
# Center the window
stats_window.geometry("+{}+{}".format(
self.parent.winfo_x() + 60,
self.parent.winfo_y() + 60
))
main_frame = tk.Frame(stats_window)
main_frame.pack(fill="both", expand=True, padx=15, pady=15)
# Title
title_label = tk.Label(main_frame, text=f"📊 Transfer Complete - {operation.title()}",
font=("Arial", 14, "bold"))
title_label.pack(pady=(0, 15))
# Stats display
stats_text = tk.Text(main_frame, wrap=tk.WORD, font=("Courier", 10),
height=12, width=50)
stats_text.pack(fill="both", expand=True, pady=(0, 15))
# Format and insert stats
stats_content = []
for key, value in stats.items():
formatted_key = key.replace('_', ' ').title()
stats_content.append(f"{formatted_key}: {value}")
stats_text.insert(tk.END, "\n".join(stats_content))
stats_text.config(state=tk.DISABLED)
# Close button
tk.Button(main_frame, text="Close",
command=stats_window.destroy).pack()
def show_disable_debugging_reminder(self):
"""Show reminder about disabling USB debugging after transfer."""
messagebox.showinfo(
"Security Reminder",
"Transfer complete!\n\nFor security, consider disabling USB debugging when not needed:\n"
"Settings → Developer Options → USB debugging (toggle off)\n\n"
"Keep it enabled if you plan to transfer files again soon."
)
+11 -12
View File
@@ -11,11 +11,13 @@ from tkinter import messagebox, ttk
class AndroidFileBrowser:
"""Android filesystem browser for Windows using ADB."""
def __init__(self, parent_window, adb_manager, remote_path_var, path_changed_callback=None):
def __init__(self, parent_window, adb_manager, path_callback=None):
self.parent = parent_window
self.adb_manager = adb_manager
self.remote_path_var = remote_path_var
self.path_changed_callback = path_changed_callback
self.path_callback = path_callback
# Show the browser immediately
self.show_browser()
def show_browser(self, direction="pull"):
"""Show a browsable Android folder tree.
@@ -469,25 +471,22 @@ class AndroidFileBrowser:
return
else:
# It's a file and we're in pull mode - select the file path directly
self.remote_path_var.set(item_path)
if self.path_changed_callback:
self.path_changed_callback()
if self.path_callback:
self.path_callback(item_path)
browser_window.destroy()
return
elif len(item_values) >= 2 and item_values[1] == "folder":
# It's a folder - select the folder path directly
self.remote_path_var.set(item_path)
if self.path_changed_callback:
self.path_changed_callback()
if self.path_callback:
self.path_callback(item_path)
browser_window.destroy()
return
# Fallback: use current path (for backwards compatibility or when no specific item is selected)
current_path = current_path_var.get()
if current_path and current_path.strip():
self.remote_path_var.set(current_path)
if self.path_changed_callback:
self.path_changed_callback()
if self.path_callback:
self.path_callback(current_path)
browser_window.destroy()
else:
selection_type = "folder" if direction == "push" else "file or folder"
+274 -994
View File
File diff suppressed because it is too large Load Diff
+394
View File
@@ -0,0 +1,394 @@
"""
Transfer Manager Module
Handles file transfer operations and coordination between GUI and ADB manager.
"""
import threading
import os
from typing import Optional, Callable, Tuple, Dict, Any
try:
from .device_manager import DeviceManager
from .animation_handler import AnimationHandler
from .dialog_manager import DialogManager
except ImportError:
from device_manager import DeviceManager
from animation_handler import AnimationHandler
from dialog_manager import DialogManager
class TransferManager:
"""Manages file transfer operations and coordination."""
def __init__(self, parent_window, device_manager: DeviceManager,
animation_handler: AnimationHandler, dialog_manager: DialogManager):
"""Initialize the transfer manager.
Args:
parent_window: The main window instance
device_manager: Device manager instance
animation_handler: Animation handler instance
dialog_manager: Dialog manager instance
"""
self.parent = parent_window
self.device_manager = device_manager
self.animation_handler = animation_handler
self.dialog_manager = dialog_manager
# Transfer tracking
self.current_transfer_id = 0
# UI callbacks
self.ui_callbacks = {}
def set_ui_callback(self, name: str, callback: Callable) -> None:
"""Set a UI callback function.
Args:
name: Name of the callback
callback: Function to call
"""
self.ui_callbacks[name] = callback
def set_status_callback(self, callback: Callable[[str], None]) -> None:
"""Set the status update callback.
Args:
callback: Function to call for status updates
"""
self.ui_callbacks['status'] = callback
self.status_callback = callback
def set_controls_callback(self, callback: Callable[[], None]) -> None:
"""Set the controls update callback.
Args:
callback: Function to call for enabling/disabling controls
"""
self.controls_callback = callback
def start_transfer(self, direction: str, source_path: str, dest_path: str,
completion_callback: Optional[Callable] = None) -> bool:
"""Start a file transfer operation.
Args:
direction: Transfer direction ('pull' or 'push')
source_path: Source file or folder path
dest_path: Destination path
completion_callback: Callback to call when transfer completes
Returns:
True if transfer was started successfully, False otherwise
"""
# Increment transfer ID for cancellation support
self.current_transfer_id += 1
transfer_id = self.current_transfer_id
# Determine if transferring a file or folder
if direction == "pull":
is_file = self._is_remote_file(source_path)
else:
is_file = os.path.isfile(source_path)
# Disable controls during transfer
if 'disable_controls' in self.ui_callbacks:
self.ui_callbacks['disable_controls']()
# Start transfer in background thread
transfer_thread = threading.Thread(
target=self._transfer_thread,
args=(direction, source_path, dest_path, transfer_id, is_file, completion_callback),
daemon=True
)
transfer_thread.start()
return True
def cancel_transfer(self) -> None:
"""Cancel the current transfer operation."""
# Increment transfer ID to invalidate current transfer
self.current_transfer_id += 1
# Cancel ADB operation
self.device_manager.cancel_current_operation()
def _is_remote_file(self, remote_path: str) -> bool:
"""Check if a remote path is a file.
Args:
remote_path: Path on Android device
Returns:
True if path is a file, False if it's a folder
"""
try:
# Use device manager to check if path is a file
return self.device_manager.is_remote_file(remote_path)
except Exception:
# If we can't determine, assume it's a folder for safety
return False
def _transfer_thread(self, direction: str, source_path: str, dest_path: str,
transfer_id: int, is_file: bool, completion_callback: Optional[Callable]):
"""Handle file transfer in background thread.
Args:
direction: Transfer direction ('pull' or 'push')
source_path: Source file or folder path
dest_path: Destination path
transfer_id: Transfer ID for cancellation
is_file: True if transferring a file
completion_callback: Callback for completion
"""
try:
# Check if transfer is still valid
if transfer_id != self.current_transfer_id:
return
# Get ADB manager from device manager
adb_manager = self.device_manager.adb_manager
# Perform the transfer
if direction == "pull":
if is_file:
success, stats = adb_manager.pull_file(source_path, dest_path)
else:
success, stats = adb_manager.pull_folder(source_path, dest_path)
operation = "pulled from Android device"
else: # push
if is_file:
success, stats = adb_manager.push_file(source_path, dest_path)
else:
success, stats = adb_manager.push_folder(source_path, dest_path)
operation = "pushed to Android device"
# Check if transfer was cancelled
if transfer_id != self.current_transfer_id:
return
# Call completion callback on main thread
if completion_callback:
self.parent.after(0, lambda: completion_callback(success, stats, operation))
except Exception as e:
# Handle errors on main thread
if transfer_id == self.current_transfer_id and 'show_error' in self.ui_callbacks:
self.parent.after(0, lambda: self.ui_callbacks["show_error"](f"Transfer error: {str(e)}"))
# Start transfer in background thread
self.current_transfer_id += 1
transfer_id = self.current_transfer_id
threading.Thread(
target=self._transfer_thread,
args=(direction, source_path, dest_path, transfer_id, is_file),
daemon=True
).start()
return True
def cancel_transfer(self) -> bool:
"""Cancel the current transfer operation.
Returns:
True if transfer was cancelled successfully, False otherwise
"""
# Cancel the actual ADB process
cancelled = self.device_manager.cancel_transfer()
# Increment transfer ID to invalidate current transfer
self.current_transfer_id += 1
# Stop animation and restore UI
self.animation_handler.stop_animation()
# Update status
status = "Transfer cancelled by user." if cancelled else "Transfer cancellation failed."
self._update_status(status)
return cancelled
def _validate_transfer_paths(self, direction: str, source_path: str, dest_path: str) -> bool:
"""Validate transfer paths based on direction.
Args:
direction: Transfer direction ('pull' or 'push')
source_path: Source path
dest_path: Destination path
Returns:
True if paths are valid, False otherwise
"""
if not source_path or not dest_path:
self.dialog_manager.show_error("Input Error", "Both source and destination paths are required.")
return False
if direction == "push":
# For push operations, validate local source path exists
if not os.path.exists(source_path):
self.dialog_manager.show_error(
"Input Error",
f"Source path does not exist: {source_path}"
)
return False
else: # pull operations
# For pull operations, validate local destination is a directory
if not os.path.isdir(dest_path):
self.dialog_manager.show_error(
"Input Error",
"Destination must be a valid directory for pulled files."
)
return False
return True
def _transfer_thread(self, direction: str, source_path: str, dest_path: str,
transfer_id: int, is_file: bool) -> None:
"""Background thread function for transfer operations.
Args:
direction: Transfer direction ('pull' or 'push')
source_path: Source path
dest_path: Destination path
transfer_id: Transfer ID for thread safety
is_file: True if transferring a file, False for folder
"""
try:
# Check if this transfer is still current
if self.current_transfer_id != transfer_id:
return
# Recheck device connectivity before proceeding
device = self.device_manager.adb_manager.check_device()
if not device:
if self.current_transfer_id == transfer_id:
self.animation_handler.stop_animation()
self.parent.after(0, self._handle_device_disconnection)
return
# Get the appropriate transfer method
transfer_method, transfer_type = self.device_manager.get_file_transfer_methods(direction, is_file)
# Perform the transfer
success, stats = self._execute_transfer(
direction, source_path, dest_path, transfer_method, transfer_type, is_file
)
if success and self.current_transfer_id == transfer_id:
self.animation_handler.stop_animation()
transfer_desc = "File" if is_file else "Folder"
self._update_status(
f"{transfer_desc} transfer completed successfully. "
"To start another transfer, please select another file or folder."
)
# Show debugging reminder and transfer statistics
self.parent.after(0, self.dialog_manager.show_disable_debugging_reminder)
if stats is not None:
self.parent.after(0, lambda: self.dialog_manager.show_transfer_stats(
stats, direction.capitalize(), self.device_manager.adb_manager.deduplicator
))
except Exception as e:
if self.current_transfer_id == transfer_id:
self.animation_handler.stop_animation()
transfer_desc = "file" if is_file else "folder"
error_msg = f"{direction.capitalize()} {transfer_desc} operation failed: {e}"
self.parent.after(0, lambda: self.dialog_manager.show_error("Transfer Error", error_msg))
finally:
if self.current_transfer_id == transfer_id:
self.animation_handler.stop_animation()
if self.controls_callback:
self.parent.after(0, self.controls_callback)
def _execute_transfer(self, direction: str, source_path: str, dest_path: str,
transfer_method: Callable, transfer_type: str, is_file: bool) -> Tuple[bool, Optional[Dict[str, Any]]]:
"""Execute the actual transfer operation.
Args:
direction: Transfer direction
source_path: Source path
dest_path: Destination path
transfer_method: Transfer method to call
transfer_type: Type of transfer ('file' or 'folder')
is_file: True if transferring a file
Returns:
Tuple of (success, stats_dict or None)
"""
if direction == "pull" and is_file:
# For pull operations with files, construct the full destination path
filename = source_path.split("/")[-1]
full_dest_path = os.path.join(dest_path, filename)
success = transfer_method(source_path, full_dest_path)
return success, None
else:
# For folder transfers or push operations
if transfer_type == "folder":
# Folder transfers return (success, stats)
return transfer_method(source_path, dest_path)
else:
# File transfers return just success
success = transfer_method(source_path, dest_path)
return success, None
def _handle_device_disconnection(self) -> None:
"""Handle device disconnection during transfers."""
self.device_manager.device_connected = False
self._update_status(
"No Android devices detected. Please check the USB connection at both ends is "
"securely inserted, USB debugging is enabled, and that File Transfer mode is turned on."
)
self.dialog_manager.show_enable_debugging_instructions()
def _update_status(self, message: str) -> None:
"""Update status through callback if available.
Args:
message: Status message to display
"""
if self.status_callback:
self.status_callback(message)
def handle_adb_status_update(self, message: str) -> None:
"""Handle status updates from ADB operations.
Args:
message: Status message from ADB
"""
# Check for transfer progress updates
if message.startswith("TRANSFER_PROGRESS:"):
parts = message.split(":")
if len(parts) == 3:
current = int(parts[1])
total = int(parts[2])
self.animation_handler.update_transfer_progress(current, total)
return
# Check for animation state changes
if self.animation_handler.is_animation_running():
if "Scanning for duplicates" in message and not self.animation_handler.is_scanning():
# Already scanning, ignore duplicate messages
return
elif "Starting transfer" in message or "Transferring" in message:
# Switch from scanning to transfer animation
self.animation_handler.stop_animation()
self.animation_handler.start_transfer_animation()
return
elif ("Duplicate scan complete" in message or
"No duplicates found" in message or
"All files already exist" in message):
# Allow these messages to show briefly before transfer starts
self.animation_handler.stop_animation()
self._update_status(message)
return
else:
# Don't update status if animation is running (except for specific cases)
return
elif "Scanning for duplicates" in message:
# Start scanning animation
self.animation_handler.start_scanning_animation()
return
# Normal status update
self._update_status(message)
+319
View File
@@ -0,0 +1,319 @@
"""
UI Components Module
Contains reusable UI components for the Android file transfer application.
"""
import tkinter as tk
from tkinter import messagebox
from typing import Callable, Optional
try:
from .license_agreement import LicenseAgreementFrame, check_license_agreement
except ImportError:
from license_agreement import LicenseAgreementFrame, check_license_agreement
class PathSelectorFrame:
"""Frame component for path selection with browse button."""
def __init__(self, parent: tk.Widget, label_text: str, browse_command: Callable):
"""Initialize the path selector frame.
Args:
parent: Parent widget
label_text: Text for the label
browse_command: Command to execute when browse button is clicked
"""
self.frame = tk.Frame(parent)
self.label = tk.Label(self.frame, text=label_text)
self.label.pack(anchor="w")
# Path frame with label and button
path_frame = tk.Frame(self.frame)
path_frame.pack(fill="x", pady=(0, 10))
self.path_var = tk.StringVar(value="Please select file or folder ->")
self.path_display = tk.Label(
path_frame,
textvariable=self.path_var,
anchor="w"
)
self.path_display.pack(side="left", fill="x", expand=True)
self.browse_btn = tk.Button(
path_frame,
text="Browse...",
command=browse_command
)
self.browse_btn.pack(side="right", padx=(5, 0))
def pack(self, **kwargs):
"""Pack the frame."""
self.frame.pack(**kwargs)
def pack_forget(self):
"""Remove the frame from packing."""
self.frame.pack_forget()
def set_path(self, path: str) -> None:
"""Set the displayed path.
Args:
path: Path to display
"""
self.path_var.set(path)
def get_path(self) -> str:
"""Get the current path.
Returns:
Current path string
"""
return self.path_var.get().strip()
def is_path_selected(self) -> bool:
"""Check if a valid path is selected.
Returns:
True if path is selected and not the default placeholder
"""
path = self.get_path()
return path and path != "Please select file or folder ->"
def clear_path(self) -> None:
"""Clear the path selection."""
self.path_var.set("Please select file or folder ->")
def enable_browse(self) -> None:
"""Enable the browse button."""
self.browse_btn.config(state="normal")
def disable_browse(self) -> None:
"""Disable the browse button."""
self.browse_btn.config(state="disabled")
class DirectionSelector:
"""Component for selecting transfer direction (pull/push)."""
def __init__(self, parent: tk.Widget, on_change_command: Optional[Callable] = None):
"""Initialize the direction selector.
Args:
parent: Parent widget
on_change_command: Command to execute when direction changes
"""
self.direction_var = tk.StringVar(value="pull")
self.frame = tk.Frame(parent)
self.frame.pack(anchor="w", padx=10, pady=(10, 0))
tk.Radiobutton(
self.frame,
text="Pull (Android → Computer)",
variable=self.direction_var,
value="pull",
command=on_change_command,
).pack(side="left")
tk.Radiobutton(
self.frame,
text="Push (Computer → Android)",
variable=self.direction_var,
value="push",
command=on_change_command,
).pack(side="left", padx=(20, 0))
def get_direction(self) -> str:
"""Get the current direction.
Returns:
Current direction ('pull' or 'push')
"""
return self.direction_var.get()
class StatusLabel:
"""Responsive status label with word wrapping."""
def __init__(self, parent: tk.Widget, initial_text: str = "Status: Idle"):
"""Initialize the status label.
Args:
parent: Parent widget
initial_text: Initial status text
"""
self.label = tk.Label(
parent,
text=initial_text,
wraplength=0, # Will be set dynamically
justify="center",
anchor="center"
)
self.label.pack(padx=10, fill="x", pady=(20, 5))
# Bind parent window resize to update wrapping
parent.bind("<Configure>", self._on_window_configure)
def set_text(self, text: str) -> None:
"""Set the status text.
Args:
text: Text to display
"""
self.label.config(text=text)
def get_text(self) -> str:
"""Get the current status text.
Returns:
Current status text
"""
return self.label.cget("text")
def _on_window_configure(self, event) -> None:
"""Handle window resize events to update label wrapping.
Args:
event: Configure event
"""
# Only handle configure events for the main window, not child widgets
if hasattr(event.widget, 'winfo_toplevel') and event.widget == event.widget.winfo_toplevel():
# Calculate available width for the status label
# Account for padding (10px on each side) and some margin
available_width = event.widget.winfo_width() - 40
if available_width > 100: # Minimum reasonable width
self.label.config(wraplength=available_width)
class TransferButton:
"""Multi-mode transfer button that changes text and behavior based on state."""
def __init__(self, parent: tk.Widget):
"""Initialize the transfer button.
Args:
parent: Parent widget
"""
self.button = tk.Button(
parent,
text="Start Transfer",
state="disabled"
)
self.button.pack(pady=10)
self.current_mode = "transfer" # transfer, recheck, cancel
def set_transfer_mode(self, command: Callable, enabled: bool = True) -> None:
"""Set button to transfer mode.
Args:
command: Command to execute on button click
enabled: Whether button should be enabled
"""
self.current_mode = "transfer"
self.button.config(
text="Start Transfer",
command=command,
state="normal" if enabled else "disabled"
)
def set_recheck_mode(self, command: Callable) -> None:
"""Set button to recheck device mode.
Args:
command: Command to execute on button click
"""
self.current_mode = "recheck"
self.button.config(
text="Recheck for connected Android device",
command=command,
state="normal"
)
def set_cancel_mode(self, command: Callable) -> None:
"""Set button to cancel transfer mode.
Args:
command: Command to execute on button click
"""
self.current_mode = "cancel"
self.button.config(
text="Cancel Transfer",
command=command,
state="normal"
)
def set_checking_mode(self) -> None:
"""Set button to temporary checking state."""
self.button.config(
text="Checking...",
state="disabled"
)
def enable(self) -> None:
"""Enable the button."""
self.button.config(state="normal")
def disable(self) -> None:
"""Disable the button."""
self.button.config(state="disabled")
def get_mode(self) -> str:
"""Get the current button mode.
Returns:
Current mode string ('transfer', 'recheck', 'cancel')
"""
return self.current_mode
class LicenseManager:
"""Manages license agreement display and main interface switching."""
def __init__(self, parent_window: tk.Tk):
"""Initialize the license manager.
Args:
parent_window: The main window instance
"""
self.parent = parent_window
self.license_agreed = check_license_agreement()
self.license_frame = None
self.on_agreed_callback: Optional[Callable] = None
def needs_license_agreement(self) -> bool:
"""Check if license agreement is needed.
Returns:
True if license agreement needs to be shown, False otherwise
"""
return not self.license_agreed
def show_license_agreement(self, on_agreed_callback: Callable) -> None:
"""Show the license agreement interface.
Args:
on_agreed_callback: Callback to execute when license is agreed
"""
self.on_agreed_callback = on_agreed_callback
# Adjust window size for license agreement
self.parent.geometry("700x600")
self.parent.minsize(700, 600)
# Create license agreement frame
self.license_frame = LicenseAgreementFrame(self.parent, self._on_license_agreed)
def _on_license_agreed(self) -> None:
"""Handle when user agrees to license."""
self.license_agreed = True
# Remove license frame
if self.license_frame:
self.license_frame.destroy()
self.license_frame = None
# Execute callback
if self.on_agreed_callback:
self.on_agreed_callback()