diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..d5b579d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,29 @@ +# Project Overview + +This is a small Python GUI tool for managing Android device files via ADB (Android Debug Bridge). +## Folder Structure + +- `/src`: Contains the source code for the application. + - `platform-tools/`: Directory where ADB binaries are stored. Do not modify or delete this folder. +- .github: Contains GitHub-related files, including workflows and issue templates. + - `copilot-instructions.md`: Instructions for GitHub Copilot to assist in code generation. Do not touch this file. + +## Libraries and Frameworks + +- Tkinter for the GUI. +- Requests for HTTP requests. +- Zipfile for handling zip archives. +- Poetry for package management. + +## Coding Standards + +- Follow PEP 8 guidelines for Python code. +- Use meaningful variable and function names. +- Write docstrings for all public modules, functions, and classes. +- 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. + +## UI guidelines + +- Application should have a modern and clean design. \ No newline at end of file diff --git a/debug_tools/README.md b/debug_tools/README.md new file mode 100644 index 0000000..21332af --- /dev/null +++ b/debug_tools/README.md @@ -0,0 +1,36 @@ +# Debug Tools + +This folder contains debugging utilities for troubleshooting the Android File Handler application. + +## Scripts + +### `debug_adb.py` + +Comprehensive ADB debugging script that helps diagnose device detection issues. + +**Usage:** + +```bash +poetry run python debug_tools/debug_adb.py +``` + +**What it checks:** + +- ADB binary path and existence +- Direct ADB command execution +- ADBManager device detection functionality +- Manual parsing of ADB output +- ADB version and server status + +**When to use:** + +- Android device is not being detected +- ADB connectivity issues +- Troubleshooting path problems +- Verifying ADB installation + +**Output interpretation:** + +- ✅ "Device found: [DEVICE_ID]" means ADB is working correctly +- ❌ "ADB binary not found" indicates path configuration issues +- Empty device list suggests USB debugging is not enabled or device is not connected diff --git a/debug_tools/debug_adb.py b/debug_tools/debug_adb.py new file mode 100644 index 0000000..09ca3c3 --- /dev/null +++ b/debug_tools/debug_adb.py @@ -0,0 +1,111 @@ +""" +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. +""" + +import os +import sys + +# 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 + + +def main(): + """Run comprehensive ADB debugging tests.""" + 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}") + print(f" ADB Binary Exists: {os.path.exists(adb_path)}\n") + + if not os.path.exists(adb_path): + print("❌ ADB binary not found! Check the platform-tools directory structure.") + return + + # Test 2: Run ADB command directly + print("2. Testing ADB command directly...") + try: + import subprocess + + result = subprocess.run( + [adb_path, "devices"], capture_output=True, text=True, timeout=10 + ) + print(f" Return code: {result.returncode}") + print(f" STDOUT: {repr(result.stdout)}") + print(f" STDERR: {repr(result.stderr)}\n") + except Exception as e: + print(f" Error running ADB directly: {e}\n") + + # Test 3: Test ADBManager device detection + print("3. Testing with ADBManager...") + try: + result = adb_manager.run_adb_command(["devices"]) + print(f" Raw result: {result}") + print(f" Raw result type: {type(result)}") + + device = adb_manager.check_device() + if device: + print(f" Device found: {device}\n") + else: + print(" No device found by ADBManager\n") + except Exception as e: + print(f" Error with ADBManager: {e}\n") + + # Test 4: Manual parsing for debugging + print("4. Manual parsing of ADB output:") + try: + import subprocess + + result = subprocess.run( + [adb_path, "devices"], capture_output=True, text=True, timeout=10 + ) + print(f" Return code: {result.returncode}") + + lines = result.stdout.strip().split("\n") + print(" Output lines:") + for i, line in enumerate(lines): + print(f" Line {i}: {repr(line)}") + if "\t" in line and "device" in line: + device_id = line.split("\t")[0].strip() + print(f" ✅ Found device: {device_id}") + print() + except Exception as e: + print(f" Error in manual parsing: {e}\n") + + # Test 5: Additional ADB info + print("5. Additional ADB commands for debugging:") + try: + import subprocess + + # ADB version + version_result = subprocess.run( + [adb_path, "version"], capture_output=True, text=True, timeout=5 + ) + print(f" ADB Version: {version_result.stdout.strip()}") + + # Restart ADB server + subprocess.run([adb_path, "kill-server"], capture_output=True, timeout=5) + subprocess.run([adb_path, "start-server"], capture_output=True, timeout=5) + + # Check devices again after restart + devices_result = subprocess.run( + [adb_path, "devices"], capture_output=True, text=True, timeout=10 + ) + print(f" Restarting ADB server...") + print(f" After restart - Return code: {devices_result.returncode}") + print(f" After restart - Output: {repr(devices_result.stdout)}") + + except Exception as e: + print(f" Error in additional tests: {e}") + + +if __name__ == "__main__": + main() diff --git a/debug_tools/test_parsing.py b/debug_tools/test_parsing.py new file mode 100644 index 0000000..f5effb0 --- /dev/null +++ b/debug_tools/test_parsing.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +""" +Test script to debug folder parsing +""" +import sys +import os +sys.path.insert(0, 'src') + +from adb_manager import ADBManager + +def main(): + adb = ADBManager() + print("Testing folder parsing...") + + result = adb.run_adb_command(['shell', 'ls', '-la', '/sdcard/']) + print(f"Return code: {result[2]}") + + lines = result[0].strip().split('\n') + print(f"Total lines: {len(lines)}") + print("\nFirst 10 lines:") + for i, line in enumerate(lines[:10]): + print(f" {i}: {repr(line)}") + + print("\nParsing directories:") + folders = [] + for i, line in enumerate(lines): + if line.startswith('d'): + parts = line.split() + print(f" Line {i}: {len(parts)} parts") + if len(parts) >= 8: # Changed from 9 to 8 + folder_name = ' '.join(parts[7:]) # Changed from 8 to 7 + print(f" Folder name: {repr(folder_name)}") + if folder_name not in ['.', '..']: + folders.append(folder_name) + print(f" ✅ Added: {repr(folder_name)}") + else: + print(f" ❌ Skipped: {repr(folder_name)}") + + print(f"\nFinal folders list ({len(folders)} items):") + for folder in sorted(folders): + print(f" - {repr(folder)}") + +if __name__ == "__main__": + main() diff --git a/poetry.lock b/poetry.lock index 21cf34c..5e27a2b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,74 @@ # This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +[[package]] +name = "altgraph" +version = "0.17.4" +description = "Python graph (network) package" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"}, + {file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"}, +] + +[[package]] +name = "astroid" +version = "3.3.11" +description = "An abstract syntax tree for Python with inference support." +optional = false +python-versions = ">=3.9.0" +groups = ["dev"] +files = [ + {file = "astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec"}, + {file = "astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce"}, +] + +[[package]] +name = "black" +version = "25.1.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"}, + {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"}, + {file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"}, + {file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"}, + {file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"}, + {file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"}, + {file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"}, + {file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"}, + {file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"}, + {file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"}, + {file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"}, + {file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"}, + {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"}, + {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"}, + {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"}, + {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"}, + {file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"}, + {file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"}, + {file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"}, + {file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"}, + {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"}, + {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + [[package]] name = "certifi" version = "2025.8.3" @@ -101,6 +170,50 @@ files = [ {file = "charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14"}, ] +[[package]] +name = "click" +version = "8.2.1" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, + {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "dill" +version = "0.4.0" +description = "serialize all of Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049"}, + {file = "dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0"}, +] + +[package.extras] +graph = ["objgraph (>=1.7.2)"] +profile = ["gprof2dot (>=2022.7.29)"] + [[package]] name = "idna" version = "3.10" @@ -116,6 +229,330 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "isort" +version = "6.0.1" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.9.0" +groups = ["dev"] +files = [ + {file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"}, + {file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"}, +] + +[package.extras] +colors = ["colorama"] +plugins = ["setuptools"] + +[[package]] +name = "macholib" +version = "1.16.3" +description = "Mach-O header analysis and editing" +optional = false +python-versions = "*" +groups = ["dev"] +markers = "sys_platform == \"darwin\"" +files = [ + {file = "macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c"}, + {file = "macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30"}, +] + +[package.dependencies] +altgraph = ">=0.17" + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mypy" +version = "1.17.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "mypy-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3fbe6d5555bf608c47203baa3e72dbc6ec9965b3d7c318aa9a4ca76f465bd972"}, + {file = "mypy-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80ef5c058b7bce08c83cac668158cb7edea692e458d21098c7d3bce35a5d43e7"}, + {file = "mypy-1.17.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a580f8a70c69e4a75587bd925d298434057fe2a428faaf927ffe6e4b9a98df"}, + {file = "mypy-1.17.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd86bb649299f09d987a2eebb4d52d10603224500792e1bee18303bbcc1ce390"}, + {file = "mypy-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a76906f26bd8d51ea9504966a9c25419f2e668f012e0bdf3da4ea1526c534d94"}, + {file = "mypy-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:e79311f2d904ccb59787477b7bd5d26f3347789c06fcd7656fa500875290264b"}, + {file = "mypy-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad37544be07c5d7fba814eb370e006df58fed8ad1ef33ed1649cb1889ba6ff58"}, + {file = "mypy-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:064e2ff508e5464b4bd807a7c1625bc5047c5022b85c70f030680e18f37273a5"}, + {file = "mypy-1.17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70401bbabd2fa1aa7c43bb358f54037baf0586f41e83b0ae67dd0534fc64edfd"}, + {file = "mypy-1.17.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e92bdc656b7757c438660f775f872a669b8ff374edc4d18277d86b63edba6b8b"}, + {file = "mypy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c1fdf4abb29ed1cb091cf432979e162c208a5ac676ce35010373ff29247bcad5"}, + {file = "mypy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:ff2933428516ab63f961644bc49bc4cbe42bbffb2cd3b71cc7277c07d16b1a8b"}, + {file = "mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb"}, + {file = "mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403"}, + {file = "mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056"}, + {file = "mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341"}, + {file = "mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb"}, + {file = "mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19"}, + {file = "mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7"}, + {file = "mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81"}, + {file = "mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6"}, + {file = "mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849"}, + {file = "mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14"}, + {file = "mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a"}, + {file = "mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733"}, + {file = "mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd"}, + {file = "mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0"}, + {file = "mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a"}, + {file = "mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91"}, + {file = "mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed"}, + {file = "mypy-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5d1092694f166a7e56c805caaf794e0585cabdbf1df36911c414e4e9abb62ae9"}, + {file = "mypy-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79d44f9bfb004941ebb0abe8eff6504223a9c1ac51ef967d1263c6572bbebc99"}, + {file = "mypy-1.17.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b01586eed696ec905e61bd2568f48740f7ac4a45b3a468e6423a03d3788a51a8"}, + {file = "mypy-1.17.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43808d9476c36b927fbcd0b0255ce75efe1b68a080154a38ae68a7e62de8f0f8"}, + {file = "mypy-1.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:feb8cc32d319edd5859da2cc084493b3e2ce5e49a946377663cc90f6c15fb259"}, + {file = "mypy-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7598cf74c3e16539d4e2f0b8d8c318e00041553d83d4861f87c7a72e95ac24d"}, + {file = "mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9"}, + {file = "mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01"}, +] + +[package.dependencies] +mypy_extensions = ">=1.0.0" +pathspec = ">=0.9.0" +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "pefile" +version = "2023.2.7" +description = "Python PE parsing module" +optional = false +python-versions = ">=3.6.0" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6"}, + {file = "pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc"}, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, + {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.14.1)"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pyinstaller" +version = "6.15.0" +description = "PyInstaller bundles a Python application and all its dependencies into a single package." +optional = false +python-versions = "<3.15,>=3.8" +groups = ["dev"] +files = [ + {file = "pyinstaller-6.15.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:9f00c71c40148cd1e61695b2c6f1e086693d3bcf9bfa22ab513aa4254c3b966f"}, + {file = "pyinstaller-6.15.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:cbcc8eb77320c60722030ac875883b564e00768fe3ff1721c7ba3ad0e0a277e9"}, + {file = "pyinstaller-6.15.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c33e6302bc53db2df1104ed5566bd980b3e0ee7f18416a6e3caa908c12a54542"}, + {file = "pyinstaller-6.15.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:eb902d0fed3bb1f8b7190dc4df5c11f3b59505767e0d56d1ed782b853938bbf3"}, + {file = "pyinstaller-6.15.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b4df862adae7cf1f08eff53c43ace283822447f7f528f72e4f94749062712f15"}, + {file = "pyinstaller-6.15.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:b9ebf16ed0f99016ae8ae5746dee4cb244848a12941539e62ce2eea1df5a3f95"}, + {file = "pyinstaller-6.15.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:22193489e6a22435417103f61e7950363bba600ef36ec3ab1487303668c81092"}, + {file = "pyinstaller-6.15.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:18f743069849dbaee3e10900385f35795a5743eabab55e99dcc42f204e40a0db"}, + {file = "pyinstaller-6.15.0-py3-none-win32.whl", hash = "sha256:60da8f1b5071766b45c0f607d8bc3d7e59ba2c3b262d08f2e4066ba65f3544a2"}, + {file = "pyinstaller-6.15.0-py3-none-win_amd64.whl", hash = "sha256:cbea297e16eeda30b41c300d6ec2fd2abea4dbd8d8a32650eeec36431c94fcd9"}, + {file = "pyinstaller-6.15.0-py3-none-win_arm64.whl", hash = "sha256:f43c035621742cf2d19b84308c60e4e44e72c94786d176b8f6adcde351b5bd98"}, + {file = "pyinstaller-6.15.0.tar.gz", hash = "sha256:a48fc4644ee4aa2aa2a35e7b51f496f8fbd7eecf6a2150646bbf1613ad07bc2d"}, +] + +[package.dependencies] +altgraph = "*" +macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} +packaging = ">=22.0" +pefile = {version = ">=2022.5.30,<2024.8.26 || >2024.8.26", markers = "sys_platform == \"win32\""} +pyinstaller-hooks-contrib = ">=2025.8" +pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} +setuptools = ">=42.0.0" + +[package.extras] +completion = ["argcomplete"] +hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2025.8" +description = "Community maintained hooks for PyInstaller" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pyinstaller_hooks_contrib-2025.8-py3-none-any.whl", hash = "sha256:8d0b8cfa0cb689a619294ae200497374234bd4e3994b3ace2a4442274c899064"}, + {file = "pyinstaller_hooks_contrib-2025.8.tar.gz", hash = "sha256:3402ad41dfe9b5110af134422e37fc5d421ba342c6cb980bd67cb30b7415641c"}, +] + +[package.dependencies] +packaging = ">=22.0" +setuptools = ">=42.0.0" + +[[package]] +name = "pylint" +version = "3.3.8" +description = "python code static checker" +optional = false +python-versions = ">=3.9.0" +groups = ["dev"] +files = [ + {file = "pylint-3.3.8-py3-none-any.whl", hash = "sha256:7ef94aa692a600e82fabdd17102b73fc226758218c97473c7ad67bd4cb905d83"}, + {file = "pylint-3.3.8.tar.gz", hash = "sha256:26698de19941363037e2937d3db9ed94fb3303fdadf7d98847875345a8bb6b05"}, +] + +[package.dependencies] +astroid = ">=3.3.8,<=3.4.0.dev0" +colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} +dill = {version = ">=0.3.7", markers = "python_version >= \"3.12\""} +isort = ">=4.2.5,<5.13 || >5.13,<7" +mccabe = ">=0.6,<0.8" +platformdirs = ">=2.2" +tomlkit = ">=0.10.1" + +[package.extras] +spelling = ["pyenchant (>=3.2,<4.0)"] +testutils = ["gitpython (>3)"] + +[[package]] +name = "pytest" +version = "8.4.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, + {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +description = "A (partial) reimplementation of pywin32 using ctypes/cffi" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, + {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, +] + [[package]] name = "requests" version = "2.32.4" @@ -138,6 +575,51 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "setuptools" +version = "80.9.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, + {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] + +[[package]] +name = "tomlkit" +version = "0.13.3" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, + {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, + {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, +] + [[package]] name = "urllib3" version = "2.2.3" @@ -158,5 +640,5 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.1" -python-versions = ">=3.8" -content-hash = "22724479fe8e759bcc703c31941828fa61f965e7af8dd57a4e89ea454887ddca" +python-versions = ">=3.13,<3.15" +content-hash = "271e389c31c4b4c771afcf1ec8670666300ee07673ba1c3ca3db2cf601237797" diff --git a/pyproject.toml b/pyproject.toml index fcdabdc..62b68cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,17 +7,44 @@ authors = [ ] license = { text = "MIT" } readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.13,<3.15" dependencies = ["requests>=2.32.4,<3.0.0"] [tool.poetry.dependencies] -python = ">=3.8" +python = ">=3.13,<3.15" requests = ">=2.32.4,<3.0.0" [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" +[tool.poetry.scripts] +# Main application entry point +android-file-handler = "src.main:main" + +# Development and maintenance scripts +dev = "python src/main.py" +gui = "python src/gui.py" +test-adb = "python -c 'from src.adb_manager import ADBManager; m = ADBManager(); print(f\"ADB Available: {m.check_device()}\")" +clean-adb = "python -c 'import shutil, os; shutil.rmtree(\"src/platform-tools\", ignore_errors=True); print(\"ADB cache cleared\")'" + +[tool.poetry.group.dev.dependencies] +black = "^25.1.0" +mypy = "^1.5.0" +pylint = "^3.3.8" +pytest = "^8.4.1" +pyinstaller = "^6.15.0" + +[tool.black] +line-length = 88 +target-version = ['py313'] + +[tool.mypy] +python_version = "3.13" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true + [tool.poetry] package-mode = false diff --git a/src/__init__.py b/src/__init__.py index 47ae99e..eb1a62a 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1 +1,23 @@ -import src.android_file_handler as android_file_handler \ No newline at end of file +""" +Android File Handler - Package Initialization +Exposes the main components of the Android file handler application. +""" + +from .adb_manager import ( + ADBManager, + LinuxMTPManager, + get_adb_binary_path, + is_adb_available, + get_platform_type, +) +from .gui import AndroidFileHandlerGUI, main + +__all__ = [ + "ADBManager", + "LinuxMTPManager", + "AndroidFileHandlerGUI", + "get_adb_binary_path", + "is_adb_available", + "get_platform_type", + "main", +] diff --git a/src/__pycache__/__init__.cpython-313.pyc b/src/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..67412df Binary files /dev/null and b/src/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/__pycache__/adb_manager.cpython-313.pyc b/src/__pycache__/adb_manager.cpython-313.pyc new file mode 100644 index 0000000..40e8c2d Binary files /dev/null and b/src/__pycache__/adb_manager.cpython-313.pyc differ diff --git a/src/__pycache__/gui.cpython-313.pyc b/src/__pycache__/gui.cpython-313.pyc new file mode 100644 index 0000000..48ad4c6 Binary files /dev/null and b/src/__pycache__/gui.cpython-313.pyc differ diff --git a/src/adb_manager.py b/src/adb_manager.py new file mode 100644 index 0000000..4222203 --- /dev/null +++ b/src/adb_manager.py @@ -0,0 +1,361 @@ +""" +Android File Handler - Business Logic Module +Handles ADB operations, device management, and file transfers. +""" + +import os +import sys +import subprocess +import requests +import zipfile +import io +import shutil +import glob +import time +import re +from typing import Optional, Tuple, Callable + +# Constants +ADB_WIN_ZIP_URL = ( + "https://dl.google.com/android/repository/platform-tools-latest-windows.zip" +) +ADB_LINUX_ZIP_URL = ( + "https://dl.google.com/android/repository/platform-tools-latest-linux.zip" +) +LOCAL_ADB_FOLDER = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "platform-tools" +) +OS_TYPE = sys.platform + +# Determine ADB binary name based on platform +if OS_TYPE.startswith("linux"): + ADB_BINARY_NAME = "adb" +elif OS_TYPE.startswith("win"): + ADB_BINARY_NAME = "adb.exe" +else: + ADB_BINARY_NAME = "adb" + +ADB_BINARY_PATH = os.path.join(LOCAL_ADB_FOLDER, ADB_BINARY_NAME) + + +class ADBManager: + """Manages ADB operations and Android device communication.""" + + def __init__(self): + self.progress_callback: Optional[Callable[[int], None]] = None + self.status_callback: Optional[Callable[[str], None]] = None + + def set_progress_callback(self, callback: Callable[[int], None]): + """Set callback function for progress updates.""" + self.progress_callback = callback + + def set_status_callback(self, callback: Callable[[str], None]): + """Set callback function for status updates.""" + self.status_callback = callback + + def _update_progress(self, percentage: int): + """Internal method to update progress.""" + if self.progress_callback: + self.progress_callback(percentage) + + def _update_status(self, message: str): + """Internal method to update status.""" + if self.status_callback: + self.status_callback(message) + + def check_local_disk_space(self) -> bool: + """Check if there's enough disk space for ADB download.""" + try: + free_space = shutil.disk_usage(LOCAL_ADB_FOLDER)[2] + if free_space < 50 * 1024 * 1024: # 50MB minimum + raise Exception("Insufficient disk space") + return True + except OSError: + # Create directory if it doesn't exist + os.makedirs(LOCAL_ADB_FOLDER, exist_ok=True) + return True + + def download_and_extract_adb(self) -> bool: + """Download and extract ADB tools if not present.""" + if os.path.isfile(ADB_BINARY_PATH): + return True + + try: + # Determine the correct URL based on platform + if OS_TYPE.startswith("linux"): + adb_zip_url = ADB_LINUX_ZIP_URL + elif OS_TYPE.startswith("win"): + adb_zip_url = ADB_WIN_ZIP_URL + else: + print("Unsupported platform") + return False + + if not self.check_local_disk_space(): + return False + + self._update_status("Downloading platform-tools (ADB)...") + + response = requests.get(adb_zip_url, stream=True, timeout=30) + response.raise_for_status() + + binary_archive = zipfile.ZipFile(io.BytesIO(response.content)) + binary_archive.extractall(LOCAL_ADB_FOLDER) + + if OS_TYPE.startswith("linux"): + if os.path.exists(ADB_BINARY_PATH): + os.chmod(ADB_BINARY_PATH, 0o755) + else: + raise Exception("ADB binary not found after extraction") + + self._update_status("Downloaded and extracted platform-tools.") + return True + except Exception as e: + print(f"Failed to download ADB: {e}") + return False + + def run_adb_command(self, args: list, capture_output: bool = True): + """Run an ADB command and return output.""" + cmd = [ADB_BINARY_PATH] + args + try: + if capture_output: + p = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + return p.stdout.strip(), p.stderr.strip(), p.returncode + else: + p = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True + ) + return p + except Exception as e: + if capture_output: + return None, str(e), -1 + else: + return None + + def check_device(self) -> Optional[str]: + """Check if an Android device is connected.""" + result = self.run_adb_command(["devices"], capture_output=True) + if isinstance(result, tuple) and len(result) == 3: + out, err, rc = result + if rc != 0 or not out: + return None + for line in out.splitlines(): + if line.endswith("\tdevice"): + return line.split()[0] + return None + + def parse_progress(self, text_line: str) -> Optional[int]: + """Parse progress percentage from ADB output.""" + m = re.search(r"\((\d{1,3})%\)", text_line) + 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.""" + cmd = [ADB_BINARY_PATH, "pull", remote_path, local_path] + + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + if proc.stdout: + for line in proc.stdout: + pct = self.parse_progress(line) + if pct is not None: + self._update_progress(pct) + self._update_status(line.strip()) + + proc.wait() + if proc.returncode == 0: + self._update_progress(100) + self._update_status("Transfer completed successfully.") + return True + else: + self._update_status(f"Transfer failed with code {proc.returncode}") + return False + + except Exception as e: + self._update_status(f"Transfer error: {e}") + return False + + def push_folder(self, local_path: str, remote_path: str) -> bool: + """Push files from local machine to Android device.""" + cmd = [ADB_BINARY_PATH, "push", local_path, remote_path] + + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + except Exception as e: + self._update_status(f"Failed to start adb: {e}") + return False + + self._update_status("Transferring files...") + + if proc.stdout: + for line in proc.stdout: + pct = self.parse_progress(line) + if pct is not None: + self._update_progress(pct) + self._update_status(line.strip()) + + proc.wait() + if proc.returncode == 0: + self._update_progress(100) + self._update_status("Transfer completed successfully.") + return True + else: + self._update_status(f"adb push failed with return code {proc.returncode}") + return False + + +class LinuxMTPManager: + """Manages MTP operations on Linux systems.""" + + def __init__(self): + self.mount_point = "/tmp/android_mtp" + + def mount_mtp_device(self) -> Optional[str]: + """Mount MTP device to filesystem using jmtpfs.""" + try: + # Create mount point + os.makedirs(self.mount_point, exist_ok=True) + + # Check if already mounted + result = subprocess.run( + ["mountpoint", self.mount_point], capture_output=True, text=True + ) + if result.returncode == 0: + return self.mount_point + + # First, try to unmount any existing GVFS MTP mounts + self._unmount_gvfs_mtp() + + # Kill any existing MTP processes that might be interfering + subprocess.run(["pkill", "-f", "gvfs-mtp"], capture_output=True) + subprocess.run(["pkill", "-f", "jmtpfs"], capture_output=True) + + # Wait a moment for processes to clean up + time.sleep(1) + + # Mount using jmtpfs + result = subprocess.run( + ["jmtpfs", self.mount_point], capture_output=True, text=True + ) + if result.returncode == 0: + return self.mount_point + else: + print(f"Failed to mount MTP device: {result.stderr}") + return None + except Exception as e: + print(f"Error mounting MTP device: {e}") + return None + + def _unmount_gvfs_mtp(self): + """Unmount any GVFS MTP mounts.""" + try: + # Find GVFS MTP mounts + result = subprocess.run(["mount"], capture_output=True, text=True) + for line in result.stdout.splitlines(): + if "gvfs" in line and "mtp" in line: + # Extract mount point from mount line + parts = line.split() + if len(parts) >= 3: + mount_point = parts[2] + subprocess.run( + ["fusermount", "-u", mount_point], capture_output=True + ) + + # Also try to unmount common GVFS locations + gvfs_locations = ["/run/user/*/gvfs/mtp*", "/media/*", "~/.gvfs/mtp*"] + + for location_pattern in gvfs_locations: + result = subprocess.run( + ["find", "/run/user", "-name", "mtp*", "-type", "d"], + capture_output=True, + text=True, + ) + for mount_point in result.stdout.strip().split("\n"): + if mount_point: + subprocess.run( + ["fusermount", "-u", mount_point], capture_output=True + ) + + except Exception as e: + print(f"Warning: Could not unmount GVFS MTP: {e}") + + def unmount_mtp_device(self) -> bool: + """Unmount MTP device.""" + try: + subprocess.run(["fusermount", "-u", self.mount_point], check=True) + return True + except subprocess.CalledProcessError as e: + print(f"Failed to unmount: {e}") + return False + + def find_gvfs_mtp_mount(self) -> Optional[str]: + """Find existing GVFS MTP mount point.""" + try: + # Check common GVFS mount locations (Linux only) + if sys.platform.startswith("linux"): + try: + if hasattr(os, "getuid"): + user_id = os.getuid() # type: ignore + gvfs_patterns = [ + f"/run/user/{user_id}/gvfs/mtp*", + "/media/*android*", + "/media/*MTP*", + ] + else: + # Fallback if getuid is not available + gvfs_patterns = [ + "/run/user/*/gvfs/mtp*", + "/media/*android*", + "/media/*MTP*", + ] + except (AttributeError, OSError): + # Fallback if getuid is not available or fails + gvfs_patterns = [ + "/run/user/*/gvfs/mtp*", + "/media/*android*", + "/media/*MTP*", + ] + + for pattern in gvfs_patterns: + matches = glob.glob(pattern) + if matches: + # Return the first valid mount point + for mount in matches: + if os.path.isdir(mount): + return mount + return None + except Exception as e: + print(f"Error finding GVFS mount: {e}") + return None + + +# Helper functions for standalone usage +def get_adb_binary_path() -> str: + """Get the path to the ADB binary.""" + return ADB_BINARY_PATH + + +def is_adb_available() -> bool: + """Check if ADB binary is available.""" + return os.path.isfile(ADB_BINARY_PATH) + + +def get_platform_type() -> str: + """Get the current platform type.""" + return OS_TYPE diff --git a/src/android_file_handler.py b/src/android_file_handler.py deleted file mode 100644 index 746ba57..0000000 --- a/src/android_file_handler.py +++ /dev/null @@ -1,459 +0,0 @@ -import os -import sys -import threading -import subprocess -import requests # type: ignore -import zipfile -import io -import shutil -import tkinter as tk -from tkinter import messagebox, filedialog, ttk - -ADB_WIN_ZIP_URL = "https://dl.google.com/android/repository/platform-tools-latest-windows.zip" -ADB_LINUX_ZIP_URL = "https://dl.google.com/android/repository/platform-tools-latest-linux.zip" -LOCAL_ADB_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "platform-tools") -OS_TYPE = f"{sys.platform}" - -if OS_TYPE.startswith("linux"): - ADB_BINARY_NAME = "adb" -elif OS_TYPE.startswith("win32"): - ADB_BINARY_NAME = "adb.exe" - -ADB_BINARY_PATH = os.path.join(LOCAL_ADB_FOLDER, ADB_BINARY_NAME) - -def check_local_disk_space(): - try: - # Check available disk space - free_space = shutil.disk_usage(LOCAL_ADB_FOLDER)[2] - if free_space < 50 * 1024 * 1024: # 50MB minimum - raise Exception("Insufficient disk space") - except OSError: - # Create directory if it doesn't exist - os.makedirs(LOCAL_ADB_FOLDER, exist_ok=True) -def download_and_extract_adb(): - if os.path.isfile(ADB_BINARY_PATH): - return True - try: - # Determine the correct URL based on platform - if OS_TYPE.startswith("linux"): - ADB_ZIP_URL = ADB_LINUX_ZIP_URL - elif OS_TYPE.startswith("win"): - ADB_ZIP_URL = ADB_WIN_ZIP_URL - else: - print("Unsupported platform") - return False - - check_local_disk_space() - - print("Downloading platform-tools (ADB)...") - - response = requests.get(ADB_ZIP_URL, stream=True, timeout=30) - - response.raise_for_status() - - binaryArchive = zipfile.ZipFile(io.BytesIO(response.content)) - - binaryArchive.extractall(LOCAL_ADB_FOLDER) - - if OS_TYPE.startswith("linux"): - if os.path.exists(ADB_BINARY_PATH): - os.chmod(ADB_BINARY_PATH, 0o755) - elif not os.path.exists(ADB_BINARY_PATH): - check_local_disk_space() - download_and_extract_adb() - else: - raise Exception("ADB binary not found after extraction") - - print("Downloaded and extracted platform-tools.") - return True - except Exception as e: - print(f"Failed to download ADB: {e}") - return False - -def run_adb_command(args, capture_output=True): - cmd = [ADB_BINARY_PATH] + args - try: - if capture_output: - p = subprocess.run(cmd, capture_output=True, text=True, timeout=15) - return p.stdout.strip(), p.stderr.strip(), p.returncode - else: - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) - return p - except Exception as e: - return None, str(e), -1 - -def check_device(): - out, err, rc = run_adb_command(["devices"]) - if rc != 0: - return None - for line in out.splitlines(): - if line.endswith("\tdevice"): - return line.split()[0] - return None - -class App(tk.Tk,): - - def __init__(self): - super().__init__() - self.title("Android Folder Puller") - self.geometry("520x320") - self.minsize(520, 320) # Set minimum window size with more vertical space - self.resizable(True, True) # Make window resizable - # Add to __init__ - 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)) - - - # UI Elements - 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) - - if OS_TYPE.startswith("linux"): - tk.Button(remote_path_frame, text="Browse...", command=self.linux_browse_remote_folder).pack(side="right", padx=(5,0)) - else: - tk.Button(remote_path_frame, text="Browse...", command=self.browse_local_folder).pack(side="right", padx=(5,0)) - - 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)) - - self.progress = ttk.Progressbar(self, orient="horizontal", mode="determinate") - self.progress.pack(fill="x", padx=10, pady=(20, 5)) - - self.status_label = tk.Label(self, text="Status: Idle") - self.status_label.pack(anchor="w", padx=10, fill="x") - - self.start_btn = tk.Button(self, text="Start Transfer", command=self.start_transfer) - self.start_btn.pack(pady=10) - - self.protocol("WM_DELETE_WINDOW", self.on_close) - - # Check adb availability - if not os.path.isfile(ADB_BINARY_PATH): - self.disable_controls() - self.status_label.config(text="ADB not found locally. Downloading...") - self.update() - success = download_and_extract_adb() - if success: - self.status_label.config(text="ADB downloaded and ready.") - self.enable_controls() - else: - self.status_label.config(text="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.status_label.config(text="Checking for connected device...") - self.update() - device = check_device() - if not device: - self.disable_controls() - self.status_label.config(text="No device detected. Enable USB debugging and connect your device.") - self.show_enable_debugging_instructions() - else: - self.status_label.config(text=f"Device detected: {device}") - def linux_mount_mtp_device(self): - """Mount MTP device to filesystem using jmtpfs""" - mount_point = "/tmp/android_mtp" - try: - # Create mount point - os.makedirs(mount_point, exist_ok=True) - - # Check if already mounted - result = subprocess.run(["mountpoint", mount_point], - capture_output=True, text=True) - if result.returncode == 0: - return mount_point - - # First, try to unmount any existing GVFS MTP mounts - self._unmount_gvfs_mtp() - - # Kill any existing MTP processes that might be interfering - subprocess.run(["pkill", "-f", "gvfs-mtp"], capture_output=True) - subprocess.run(["pkill", "-f", "jmtpfs"], capture_output=True) - - # Wait a moment for processes to clean up - import time - time.sleep(1) - - # Mount using jmtpfs - result = subprocess.run(["jmtpfs", mount_point], - capture_output=True, text=True) - if result.returncode == 0: - return mount_point - else: - print(f"Failed to mount MTP device: {result.stderr}") - return None - except Exception as e: - print(f"Error mounting MTP device: {e}") - return None - - def _unmount_gvfs_mtp(self): - """Unmount any GVFS MTP mounts""" - try: - # Find GVFS MTP mounts - result = subprocess.run(["mount"], capture_output=True, text=True) - for line in result.stdout.splitlines(): - if "gvfs" in line and "mtp" in line: - # Extract mount point from mount line - parts = line.split() - if len(parts) >= 3: - mount_point = parts[2] - subprocess.run(["fusermount", "-u", mount_point], - capture_output=True) - - # Also try to unmount common GVFS locations - gvfs_locations = [ - "/run/user/*/gvfs/mtp*", - "/media/*", - "~/.gvfs/mtp*" - ] - - for location_pattern in gvfs_locations: - result = subprocess.run(["find", "/run/user", "-name", "mtp*", "-type", "d"], - capture_output=True, text=True) - for mount_point in result.stdout.strip().split('\n'): - if mount_point: - subprocess.run(["fusermount", "-u", mount_point], - capture_output=True) - - except Exception as e: - print(f"Warning: Could not unmount GVFS MTP: {e}") - - def linux_unmount_mtp_device(self): - """Unmount MTP device""" - mount_point = "/tmp/android_mtp" - try: - subprocess.run(["fusermount", "-u", mount_point], check=True) - return True - except subprocess.CalledProcessError as e: - print(f"Failed to unmount: {e}") - return False - - def linux_browse_remote_folder(self): - """Browse remote Android folders via MTP""" - # First try using existing GVFS mount - gvfs_mount = self._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) - # Clean up the path for Android - 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.linux_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.linux_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.") - - def _find_gvfs_mtp_mount(self): - """Find existing GVFS MTP mount point""" - try: - # Check common GVFS mount locations - import glob - user_id = os.getuid() - gvfs_patterns = [ - f"/run/user/{user_id}/gvfs/mtp*", - "/media/*android*", - "/media/*MTP*" - ] - - for pattern in gvfs_patterns: - matches = glob.glob(pattern) - if matches: - # Return the first valid mount point - for mount in matches: - if os.path.isdir(mount): - return mount - return None - except Exception as e: - print(f"Error finding GVFS mount: {e}") - return None - - def browse_local_folder(self): - folder = filedialog.askdirectory() - if folder: - self.local_path_var.set(folder) - - def disable_controls(self): - self.remote_path_entry.config(state="disabled") - self.local_path_entry.config(state="disabled") - self.start_btn.config(state="disabled") - - def enable_controls(self): - 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): - 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): - 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): - remote_path = self.remote_path_var.get().strip() - local_path = self.local_path_var.get().strip() - direction = self.direction_var.get() - - 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 - - self.disable_controls() - self.progress["value"] = 0 - - if direction == "pull": - self.status_label.config(text="Starting pull transfer...") - threading.Thread(target=self.pull_folder, args=(remote_path, local_path), daemon=True).start() - elif direction == "push": - self.status_label.config(text="Starting push transfer...") - threading.Thread(target=self.push_folder, args=(local_path, remote_path), daemon=True).start() - else: - self.report_error("Invalid transfer direction selected.") - self.enable_controls() - - def pull_folder(self, remote_path, local_path): - cmd = [ADB_BINARY_PATH, "pull", remote_path, local_path] - - try: - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, bufsize=1) - - for line in proc.stdout: - pct = self.parse_progress(line) - if pct is not None: - self.update_progress(pct) - self.set_status(line.strip()) - - proc.wait() - if proc.returncode == 0: - self.update_progress(100) - self.set_status("Transfer completed successfully.") - self.show_disable_debugging_reminder() - else: - self.report_error(f"Transfer failed with code {proc.returncode}") - - except Exception as e: - self.report_error(f"Transfer error: {e}") - finally: - self.enable_controls() - - def push_folder(self, local_path,remote_path): - # Run adb push command with progress parsing - cmd = [ADB_BINARY_PATH, "push", f"{local_path}", f"{remote_path}" ] - - try: - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) - except Exception as e: - self.report_error(f"Failed to start adb: {e}") - return - - self.set_status("Transferring files...") - - for line in proc.stdout: - # adb progress lines look like: - # 12345 KB/s (100%) - # or partial output, parse percentage from line - pct = self.parse_progress(line) - if pct is not None: - self.update_progress(pct) - self.set_status(line.strip()) - - proc.wait() - if proc.returncode == 0: - self.update_progress(100) - self.set_status("Transfer completed successfully.") - self.enable_controls() - self.show_disable_debugging_reminder() - else: - self.report_error(f"adb pull failed with return code {proc.returncode}") - - def parse_progress(self, text_line): - import re - m = re.search(r"\((\d{1,3})%\)", text_line) - if m: - pct = int(m.group(1)) - if 0 <= pct <= 100: - return pct - return None - - def update_progress(self, pct): - self.progress["value"] = pct - self.update_idletasks() - - def set_status(self, msg): - self.status_label.config(text="Status: " + msg) - self.update_idletasks() - - def report_error(self, message): - self.status_label.config(text="Error: " + message) - messagebox.showerror("Error", message) - self.enable_controls() - - def on_close(self): - self.destroy() - -if __name__ == "__main__": - if sys.platform not in ["win32", "linux"]: - tk.Tk().withdraw() - messagebox.showerror("Unsupported OS", "This application only supports Windows or Linux.") - sys.exit(1) - - app = App() - app.mainloop() diff --git a/src/gui.py b/src/gui.py new file mode 100644 index 0000000..6c12858 --- /dev/null +++ b/src/gui.py @@ -0,0 +1,712 @@ +""" +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, + 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, + ) + + +class AndroidFileHandlerGUI(tk.Tk): + """Main GUI application for Android file transfers.""" + + def __init__(self): + super().__init__() + + # Initialize business logic + self.adb_manager = ADBManager() + self.adb_manager.set_progress_callback(self.update_progress) + self.adb_manager.set_status_callback(self.set_status) + + if get_platform_type().startswith("linux"): + self.mtp_manager = LinuxMTPManager() + else: + self.mtp_manager = None + + # Setup UI + self._setup_ui() + 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 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 via MTP (Linux)""" + # For Linux - use MTP browsing + if get_platform_type().startswith("linux") and self.mtp_manager: + # 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.", + ) + else: + # For Windows - show common Android paths dialog + self._show_android_filesystem_tree() + + 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("<>", 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 + ) + + 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.""" + 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.""" + 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.disable_controls() + self.progress["value"] = 0 + + if direction == "pull": + self.set_status("Starting pull transfer...") + threading.Thread( + target=self._pull_thread, args=(remote_path, local_path), daemon=True + ).start() + elif direction == "push": + self.set_status("Starting push transfer...") + threading.Thread( + target=self._push_thread, args=(local_path, remote_path), daemon=True + ).start() + else: + self.report_error("Invalid transfer direction selected.") + self.enable_controls() + + def _pull_thread(self, remote_path: str, local_path: str): + """Thread function for pull operations.""" + try: + success = self.adb_manager.pull_folder(remote_path, local_path) + if success: + self.show_disable_debugging_reminder() + except Exception as e: + self.report_error(f"Pull operation failed: {e}") + finally: + self.enable_controls() + + def _push_thread(self, local_path: str, remote_path: str): + """Thread function for push operations.""" + try: + success = self.adb_manager.push_folder(local_path, remote_path) + if success: + self.show_disable_debugging_reminder() + except Exception as e: + self.report_error(f"Push operation failed: {e}") + finally: + self.enable_controls() + + def update_progress(self, percentage: int): + """Update the progress bar.""" + self.progress["value"] = percentage + self.update_idletasks() + + def set_status(self, message: str): + """Update the status label.""" + self.status_label.config(text=f"Status: {message}") + self.update_idletasks() + + def report_error(self, message: str): + """Report an error to the user.""" + 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/main.py b/src/main.py new file mode 100644 index 0000000..0597dec --- /dev/null +++ b/src/main.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +""" +Android File Handler - Main Entry Point +Simple entry point to launch the Android file transfer application. +""" + +try: + # Try relative import first (when used as module) + from .gui import main +except ImportError: + # Fall back to direct import (when run directly) + from gui import main + +if __name__ == "__main__": + main() diff --git a/test_parsing.py b/test_parsing.py new file mode 100644 index 0000000..657b108 --- /dev/null +++ b/test_parsing.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +""" +Test script to debug folder parsing +""" +import sys +import os + +sys.path.insert(0, "src") + +from adb_manager import ADBManager + + +def main(): + adb = ADBManager() + print("Testing folder parsing...") + + result = adb.run_adb_command(["shell", "ls", "-la", "/sdcard/"]) + print(f"Return code: {result[2]}") + + lines = result[0].strip().split("\n") + print(f"Total lines: {len(lines)}") + print("\nFirst 10 lines:") + for i, line in enumerate(lines[:10]): + print(f" {i}: {repr(line)}") + + print("\nParsing directories:") + folders = [] + for i, line in enumerate(lines): + if line.startswith("d"): + parts = line.split() + print(f" Line {i}: {len(parts)} parts") + if len(parts) >= 8: # Changed from 9 to 8 + folder_name = " ".join(parts[7:]) # Changed from 8 to 7 + print(f" Folder name: {repr(folder_name)}") + if folder_name not in [".", ".."]: + folders.append(folder_name) + print(f" ✅ Added: {repr(folder_name)}") + else: + print(f" ❌ Skipped: {repr(folder_name)}") + + print(f"\nFinal folders list ({len(folders)} items):") + for folder in sorted(folders): + print(f" - {repr(folder)}") + + +if __name__ == "__main__": + main()