import subprocess import configparser import os import argparse import time import logging from subprocess import TimeoutExpired from BetterADBSync import sync_with_options from tcpip import reconnect_as_tcpip # debug vs standard logging DEBUG = False if DEBUG: logging.basicConfig( level=logging.DEBUG, format="[%(asctime)s][%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S" ) else: logging.basicConfig( level=logging.INFO, format="[%(asctime)s][%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", filename="sync.log", filemode="a" ) def parse_command_output(output: str) -> dict: lines = output.split(os.linesep) result = {} i = lines.index("[INFO] SYNCING") trees = { 'deleted': [ 'Deleting delete tree', 'Empty delete tree', 'Deleting non-excluded-supporting destination unaccounted tree' ], 'copied': [ 'Copying copy tree', 'Empty copy tree' ] } current_tree = '' for line in lines[i + 2:]: actual_line = line[6:].strip() for tree, matches in trees.items(): if actual_line in matches: current_tree = tree result[current_tree] = [] break else: if current_tree: if actual_line == '': current_tree = '' continue if actual_line.startswith('Removing '): actual_line = actual_line[9:] result[current_tree].append(actual_line) return result def traverse_tree(tree: dict, level: int = 0): """Traverse a tree and print it nicely. Also keep a count of the number of files in the tree.""" count = 0 for key, value in tree.items(): if isinstance(value, dict): logging.info(f"{' ' * level}{key}/") count += traverse_tree(value, level + 1) else: logging.info(f"{' ' * level}{key}") count += 1 return count def main(): parser = argparse.ArgumentParser(description="Daemon for syncing files between a computer and an Android device.") parser.add_argument( "--dry-run", action="store_true", help="Perform a dry run without actually syncing files. (Only runs once)" ) parser.add_argument( "--tcpip", action="store_true", help="Reconnect to device over TCP/IP before syncing. Linux only." ) args = parser.parse_args() if args.tcpip: if os.name != 'posix': logging.error("TCP/IP mode is only supported on Linux systems.") return # Read config file if not os.path.exists("config.ini"): # Create a new config file config = configparser.ConfigParser() config["Global"] = { "adb_path": "adb", "device": "" } config["__example_Path_1"] = { "type": "push", "source": "C:/path/to/source", "destination": "/path/to/destination", "exclude_1": "path/to/exclude", "exclude_2": "path/to/exclude", "delete_files_in_dest": "no" } print("New configuration file created. Please edit the file to set paths and other configurations.") with open("config.ini", "w") as configfile: config.write(configfile) return else: config = configparser.ConfigParser() config.read("config.ini") adb_path = config["Global"]["adb_path"] device = config["Global"]["device"] # First run adb, check if server is running result = subprocess.run([adb_path, "start-server"]) if result.returncode != 0: logging.error("Could not start ADB server. Exiting...") return # Disconnect all devices to start fresh subprocess.run([adb_path, "disconnect"], stdout=subprocess.PIPE) run = True first_run = True waiting_for_connection = False while run: # Get the list of devices result = subprocess.run([adb_path, "devices"], stdout=subprocess.PIPE) output = result.stdout.decode("utf-8") devices = output.split(os.linesep)[1:-2] devices = [device.split("\t")[0] for device in devices if "offline" not in device] if len(devices) == 0: # Device is not yet connected device = config["Global"]["device"] # reset device to config value, in case it was TCP/IP before first_run = True if not waiting_for_connection: logging.info("No device connected. Waiting silently until a device is connected.") waiting_for_connection = True time.sleep(10) continue waiting_for_connection = False if device and device not in devices: time.sleep(60) continue if not device: device = devices[0] if first_run: logging.info(f"Device connected: {device}") if args.tcpip: # Reconnect over TCP/IP logging.info("Reconnecting device over TCP/IP...") try: tcpip_device = reconnect_as_tcpip(device, adb_path) except (RuntimeError, TimeoutExpired) as e: logging.warning(f"Failed to reconnect device over TCP/IP: {e}") logging.warning("Continuing with the current connection after 10 seconds.") time.sleep(10) else: device = tcpip_device logging.info(f"Device reconnected over TCP/IP at {tcpip_device}") logging.info(f"Found {len(config.sections()) - 1} path{'s' if len(config.sections()) > 2 else ''} to sync.") logging.info('') # Sync files for section in config.sections(): if section == "Global" or section.startswith("__"): continue if first_run: logging.info(f"Syncing {section}...") type = config[section]["type"] source = config[section]["source"] destination = config[section]["destination"] exclude = [config[section][key] for key in config[section] if key.startswith("exclude")] delete_files_in_dest = config[section].getboolean("delete_files_in_dest") options = { 'adb_options': {'s': device}, } if args.dry_run: options['dry_run'] = True if exclude: options['exclude'] = exclude if delete_files_in_dest: options['delete'] = True options['show_progress'] = True options['direction'] = type options['source'] = source options['dest'] = destination copied, deleted = sync_with_options(**options) if copied: count = traverse_tree(copied) logging.info(f"Copied {count} files to {destination}\n") if deleted and delete_files_in_dest: count = traverse_tree(deleted) logging.info(f"Deleted {count} files from {destination}\n") if args.dry_run: run = False else: if first_run: logging.info("Entering loop and waiting for file changes") first_run = False time.sleep(300) if __name__ == "__main__": main()