got path selection to visually switch places on radio button selection

This commit is contained in:
2025-08-21 13:06:47 -05:00
parent 072b246c99
commit 42dff94829
4 changed files with 66 additions and 756 deletions
+1
View File
@@ -23,6 +23,7 @@ This is a small Python GUI tool for managing Android device files via ADB (Andro
- Include type hints for all function parameters and return values.
- Use f-strings for string formatting.
- No single letter variable names except for `e` when appropriate.
- Do not recreate files I have deleted.
## UI guidelines
+18 -10
View File
@@ -195,11 +195,15 @@ class ADBManager:
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."
)
if os.name == "nt":
# Check if this is a root drive (like C:\, D:\, etc.)
normalized_path = os.path.abspath(local_path)
drive_root = os.path.splitdrive(normalized_path)[0] + os.sep
if normalized_path == drive_root:
# Root drive path like C:\ - this might cause issues with ADB
self._update_status(
"Warning: Transferring to root drive. Consider using a subfolder."
)
cmd = [ADB_BINARY_PATH, "pull", remote_path, local_path]
@@ -315,11 +319,15 @@ class ADBManager:
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."
)
if os.name == "nt":
# Check if this is a root drive (like C:\, D:\, etc.)
normalized_path = os.path.abspath(local_path)
drive_root = os.path.splitdrive(normalized_path)[0] + os.sep
if normalized_path == drive_root:
# Root drive path like C:\ - this might cause issues with ADB
self._update_status(
"Warning: Pushing from root drive. Consider using a subfolder."
)
cmd = [ADB_BINARY_PATH, "push", local_path, remote_path]
-728
View File
@@ -1,728 +0,0 @@
"""
Android File Handler - GUI Module
Provides the user interface for the Android file transfer application.
"""
import os
import sys
import threading
import tkinter as tk
from tkinter import messagebox, filedialog, ttk, simpledialog
from typing import Optional
try:
# Try relative import first (when used as module)
from .adb_manager import (
ADBManager,
is_adb_available,
)
except ImportError:
# Fall back to direct import (when run directly)
from adb_manager import (
ADBManager,
is_adb_available,
)
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
# Setup UI
self._setup_ui()
self._setup_progress_handling()
self._initialize_app()
def _setup_progress_handling(self):
"""Set up progress handling callbacks."""
# Set up ADB callbacks using our own methods for this legacy version
self.adb_manager.set_progress_callback(self.update_progress)
self.adb_manager.set_status_callback(self.set_status)
def _setup_ui(self):
"""Setup the user interface."""
# Window configuration
self.title("Android File Handler")
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="indeterminate")
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 button
self.start_btn = tk.Button(
self, text="Start Transfer", command=self.start_transfer
)
self.start_btn.pack(pady=10)
# Window close protocol
self.protocol("WM_DELETE_WINDOW", self.on_close)
def _initialize_app(self):
"""Initialize the application - check ADB and device."""
# Check adb availability
if not is_adb_available():
self.disable_controls()
self.set_status("ADB not found locally. Downloading...")
self.update()
success = self.adb_manager.download_and_extract_adb()
if success:
self.set_status("ADB downloaded and ready.")
self.enable_controls()
else:
self.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.set_status("Checking for connected device...")
self.update()
device = self.adb_manager.check_device()
if not device:
self.disable_controls()
self.set_status(
"No device detected. Enable USB debugging and connect your device."
)
self.show_enable_debugging_instructions()
else:
self.set_status(f"Device detected: {device}")
def browse_remote_folder(self):
"""Browse remote Android folders."""
messagebox.showinfo(
"Feature Not Available",
"Remote folder browsing is not available in this version. Please manually enter the path."
)
def _show_android_filesystem_tree(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)
browser_window.title("Browse Android Folders")
browser_window.geometry("500x400")
browser_window.transient(self)
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.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 (last part after spaces)
parts = line.split()
if (
len(parts) >= 8
): # Android ls -la typically has 8+ parts
folder_name = " ".join(
parts[7:]
) # Handle names with spaces (start from column 8)
if 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.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.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 (last part after spaces)
parts = line.split()
if len(parts) >= 8: # Android ls -la typically has 8+ parts
folder_name = " ".join(
parts[7:]
) # Handle names with spaces (start from column 8)
if 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("<<TreeviewOpen>>", on_tree_expand)
tree.bind("<<TreeviewSelect>>", 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
)
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")
self.start_btn.config(state="disabled")
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")
self.start_btn.config(state="normal")
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, restart this app."
)
messagebox.showinfo("Enable USB Debugging", msg)
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."""
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
# Start transfer
self.current_transfer_id += 1
transfer_id = self.current_transfer_id
self.disable_controls()
# Reset progress bar in thread-safe way
self._update_progress_ui(0)
if direction == "pull":
self.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.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()
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()
def update_progress(self, percentage: int):
"""Update the progress bar (thread-safe)."""
# Schedule UI update on main thread using a proper closure
def update_ui():
self._update_progress_ui(percentage)
self.after(0, update_ui)
def _update_progress_ui(self, percentage: int):
"""Internal method to update progress bar on main thread."""
# For indeterminate mode, start/stop animation based on progress
try:
if percentage > 0 and percentage < 100:
self.progress.start(10) # Start animation
elif percentage >= 100:
self.progress.stop() # Stop animation when complete
self.update_idletasks()
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.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.update_idletasks()
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()
+47 -18
View File
@@ -80,46 +80,55 @@ class AndroidFileHandlerGUI(tk.Tk):
text="Pull (Android → Computer)",
variable=self.direction_var,
value="pull",
command=self._on_direction_change,
).pack(side="left")
tk.Radiobutton(
direction_frame,
text="Push (Computer → Android)",
variable=self.direction_var,
value="push",
command=self._on_direction_change,
).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)
)
# Create a container frame for the path sections that can be reordered
self.path_container = tk.Frame(self)
self.path_container.pack(fill="x", padx=10, pady=(10, 0))
# Create the Android device path section
self.android_frame = tk.Frame(self.path_container)
self.android_label = tk.Label(self.android_frame, text="Android device:")
self.android_label.pack(anchor="w")
android_path_frame = tk.Frame(self.android_frame)
android_path_frame.pack(fill="x", pady=(0, 10))
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
android_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
android_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)
)
# Create the Computer path section
self.computer_frame = tk.Frame(self.path_container)
self.computer_label = tk.Label(self.computer_frame, text="Computer:")
self.computer_label.pack(anchor="w")
computer_path_frame = tk.Frame(self.computer_frame)
computer_path_frame.pack(fill="x", pady=(0, 10))
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
computer_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
computer_path_frame, text="Browse...", command=self.browse_local_folder
).pack(side="right", padx=(5, 0))
# Initially arrange for pull (Android on top)
self._arrange_path_sections()
# Status label
self.status_label = tk.Label(self, text="Status: Idle")
self.status_label.pack(anchor="w", padx=10, fill="x", pady=(20, 5))
@@ -260,6 +269,26 @@ class AndroidFileHandlerGUI(tk.Tk):
self.local_path_entry.config(state="normal")
# Button state is handled by mode switching methods
def _on_direction_change(self):
"""Handle radio button direction change."""
self._arrange_path_sections()
def _arrange_path_sections(self):
"""Arrange the Android and Computer path sections based on direction."""
# Remove both frames from container
self.android_frame.pack_forget()
self.computer_frame.pack_forget()
direction = self.direction_var.get()
if direction == "pull":
# Pull: Android → Computer (Android on top)
self.android_frame.pack(fill="x", pady=(0, 5))
self.computer_frame.pack(fill="x")
else: # push
# Push: Computer → Android (Computer on top)
self.computer_frame.pack(fill="x", pady=(0, 5))
self.android_frame.pack(fill="x")
def _switch_to_recheck_mode(self):
"""Switch button to recheck device mode."""
self.start_btn.config(