Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .DS_Store
Binary file not shown.
22 changes: 22 additions & 0 deletions src/app/core/configurations.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# ADB File Explorer
# Copyright (C) 2022 Azat Aldeshov
import logging
import os
import platform

Expand Down Expand Up @@ -84,6 +85,27 @@ def device_downloads_path(cls, device: Device) -> str:
return Settings.downloads_path


_logger = logging.getLogger(__name__)

_PHONE_SCRIPTS_DIR = os.path.expanduser('~/PhoneAsExtStorage/adbfs-rootless')


def _checked_script(name: str) -> str:
"""Return expanded path; log a warning if the file does not exist."""
path = os.path.join(_PHONE_SCRIPTS_DIR, name)
if not os.path.exists(path):
_logger.warning("Script not found: %s", path)
return path


class AppScripts:
"""Centralised paths for helper shell scripts."""
STREAM_SCRIPT = _checked_script('phone-stream.sh')
TRANSPORT_SCRIPT = _checked_script('phone-transport.sh')
# общее ядро range-стрима: его же гоняет phone-stream.sh и CLI
ADB_STREAM = _checked_script('adb_stream.py')


class Resources:
__metaclass__ = Singleton

Expand Down
3 changes: 3 additions & 0 deletions src/app/core/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,14 @@ def up(cls) -> bool:
def get_device(cls) -> Device:
return cls.__device

DEFAULT_PATH = 'sdcard'

@classmethod
def set_device(cls, device: Device) -> bool:
if device:
cls.clear()
cls.__device = device
cls.__path.append(cls.DEFAULT_PATH)
return True

@classmethod
Expand Down
16 changes: 16 additions & 0 deletions src/app/data/repositories/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,22 @@ def upload(cls, progress_callback: callable, source: str) -> (str, str):
)


class StorageRepository:
"""Delegates to android_adb.StorageRepository (only available with EXTERNAL_TOOL_ADB core)."""

@staticmethod
def get_device_model(device_id: str) -> str:
return android_adb.StorageRepository.get_device_model(device_id)

@staticmethod
def get_disk_info(device_id: str, path: str = '/sdcard') -> dict:
return android_adb.StorageRepository.get_disk_info(device_id, path)

@staticmethod
def get_sd_card_path(device_id: str) -> str:
return android_adb.StorageRepository.get_sd_card_path(device_id)


class DeviceRepository:
@classmethod
def devices(cls) -> (List[Device], str):
Expand Down
145 changes: 142 additions & 3 deletions src/app/data/repositories/android_adb.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# ADB File Explorer
# Copyright (C) 2022 Azat Aldeshov
import os
import threading
from typing import List

from app.core.configurations import Settings
Expand Down Expand Up @@ -35,16 +37,24 @@ def file(cls, path: str) -> (File, str):
file.path = path
return file, response.ErrorData

# Таймаут на листинг каталога (секунды).
# 25с — достаточно для большинства папок даже по Wi-Fi.
FILES_TIMEOUT = 25

@classmethod
def files(cls) -> (List[File], str):
if not ADBManager.get_device():
return None, "No device selected!"

path = ADBManager.path()
args = adb.ShellCommand.LS_ALL_LIST + [path]
response = adb.shell(ADBManager.get_device().id, [shlex.join(args)])
response = adb.shell(ADBManager.get_device().id, [shlex.join(args)],
timeout=cls.FILES_TIMEOUT)
if not response.IsSuccessful and response.ExitCode != 1:
return [], response.ErrorData or response.OutputData
err = response.ErrorData or response.OutputData or ''
# Понятное сообщение при таймауте/offline уже формируется в CommonProcess,
# просто пробрасываем его
return [], err

if not response.OutputData:
return [], response.ErrorData
Expand All @@ -63,7 +73,9 @@ def files(cls) -> (List[File], str):
# Build one script to test all symlinks using shared helper; safely quotes each path
script = build_test_d_batch_script(symlink_paths)
cmd = shlex.join(['sh', '-c', script])
batch_resp = adb.shell(ADBManager.get_device().id, [cmd])
# Даём пропорциональный таймаут: не менее 10с
sym_timeout = max(10, min(len(symlink_paths) // 2, 20))
batch_resp = adb.shell(ADBManager.get_device().id, [cmd], timeout=sym_timeout)
if batch_resp.IsSuccessful and batch_resp.OutputData:
status = parse_test_d_batch_output(batch_resp.OutputData)

Expand Down Expand Up @@ -116,13 +128,53 @@ def call(self, data: str):
elif data:
self.messages.append(data)

@staticmethod
def _remote_file_size(source: str) -> int:
try:
command = shlex.join(['stat', '-c', '%s', source])
response = adb.shell(ADBManager.get_device().id, [command])
if response.IsSuccessful and response.OutputData:
return int(response.OutputData.strip())
except (ValueError, AttributeError, TypeError):
pass
return 0

@staticmethod
def _poll_download_progress(dest_file, total, callback, name, stop_event):
# adb pull only prints `[ N%]` progress to a TTY; when its output is
# piped (as here) it stays silent until the transfer finishes, so the
# progress bar never moved. Derive progress from the size of the file
# being written locally instead.
while not stop_event.wait(0.25):
try:
current = os.path.getsize(dest_file)
except OSError:
continue
callback(name, min(int(current * 100 / total), 99))

@classmethod
def download(cls, progress_callback: callable, source: str, destination: str) -> (str, str):
if not destination:
destination = Settings.device_downloads_path(ADBManager.get_device())
if ADBManager.get_device() and source and destination:
helper = cls.UpDownHelper(progress_callback)

name = source.rstrip('/').rsplit('/', 1)[-1]
total = cls._remote_file_size(source)
dest_file = os.path.join(destination, name) if os.path.isdir(destination) else destination
stop_event = threading.Event()
if total > 0 and progress_callback:
threading.Thread(
target=cls._poll_download_progress,
args=(dest_file, total, progress_callback, name, stop_event),
daemon=True,
).start()

response = adb.pull(ADBManager.get_device().id, source, destination, helper.call)
stop_event.set()
if response.IsSuccessful and total > 0 and progress_callback:
progress_callback(name, 100)

if not response.IsSuccessful:
return None, response.ErrorData or "\n".join(helper.messages)

Expand All @@ -140,6 +192,23 @@ def new_folder(cls, name) -> (str, str):
return None, response.ErrorData or response.OutputData
return response.OutputData, response.ErrorData

@classmethod
def fetch_exif_thumbnail(cls, device_id: str, path: str):
"""Returns (jpeg_bytes, None) or (None, error_str)"""
try:
import piexif
from app.services.adb import exec_out_head
raw = exec_out_head(device_id, path)
if not raw:
return None, "empty response"
exif = piexif.load(raw)
thumb = exif.get("thumbnail")
if thumb:
return thumb, None
return None, "no thumbnail in EXIF"
except Exception as e:
return None, str(e)

@classmethod
def upload(cls, progress_callback: callable, source: str) -> (str, str):
if ADBManager.get_device() and ADBManager.path() and source:
Expand All @@ -152,6 +221,76 @@ def upload(cls, progress_callback: callable, source: str) -> (str, str):
return None, None


class StorageRepository:
"""Helpers for device model name, disk usage, and SD card detection."""

@staticmethod
def get_device_model(device_id: str) -> str:
"""Return ro.product.model, e.g. 'Pixel 6'."""
try:
response = adb.shell(device_id, [shlex.join(adb.ShellCommand.GETPROP_PRODUCT_MODEL)],
timeout=5)
if response.IsSuccessful and response.OutputData:
return response.OutputData.strip()
except Exception:
pass
return ''

@staticmethod
def get_disk_info(device_id: str, path: str = '/sdcard') -> dict:
"""Return {'avail_gb': float, 'total_gb': float} for path, or empty dict on failure.

Tries `df -k <path>` (POSIX, Android busybox) first; the last data line has
columns: Filesystem, 1K-blocks, Used, Available, Use%, Mounted-on.
Falls back to `df <path>` which may give KB or 512-byte blocks depending on busybox."""
for flag in ['-k', '']:
try:
cmd_parts = ['df'] + ([flag] if flag else []) + [path]
response = adb.shell(device_id, [shlex.join(cmd_parts)], timeout=5)
if not response.IsSuccessful or not response.OutputData:
continue
lines = [l.strip() for l in response.OutputData.splitlines() if l.strip()]
# skip header, take last data line (handles line-wrapped output)
data_lines = [l for l in lines if not l.startswith('Filesystem')]
if not data_lines:
continue
parts = data_lines[-1].split()
# expect at least 4 numeric columns after filesystem name
# handle wrapped: if first line ends with fs name only, parts may be short
if len(parts) < 4:
continue
# columns: [filesystem, total, used, avail, ...] (1K-blocks with -k)
total_kb = int(parts[1])
avail_kb = int(parts[3])
if flag == '-k' or total_kb > 1024 * 1024:
# values are in KB
divisor = 1024 * 1024
else:
# values are in 512-byte blocks (some old busybox without -k)
divisor = 2 * 1024 * 1024
return {
'avail_gb': round(avail_kb / divisor, 1),
'total_gb': round(total_kb / divisor, 1),
}
except Exception:
continue
return {}

@staticmethod
def get_sd_card_path(device_id: str) -> str:
"""Return /storage/XXXX-XXXX path if external SD card is present, else empty string."""
import re
try:
response = adb.shell(device_id, [shlex.join(['ls', '/storage/'])], timeout=5)
if response.IsSuccessful and response.OutputData:
for entry in response.OutputData.split():
if re.match(r'^[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}$', entry.strip()):
return '/storage/' + entry.strip()
except Exception:
pass
return ''


class DeviceRepository:
@classmethod
def devices(cls) -> (List[Device], str):
Expand Down
41 changes: 38 additions & 3 deletions src/app/gui/explorer/devices.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# ADB File Explorer
# Copyright (C) 2022 Azat Aldeshov
import subprocess
from typing import Any

from PyQt5 import QtGui, QtCore
Expand All @@ -8,14 +9,22 @@
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QStyledItemDelegate, QStyleOptionViewItem, QApplication, \
QStyle, QListView

from app.core.configurations import Resources
from app.core.configurations import AppScripts, Resources
from app.core.main import Adb
from app.core.managers import Global
from app.data.models import DeviceType, MessageData, MessageType
from app.data.repositories import DeviceRepository
from app.data.repositories import DeviceRepository, StorageRepository
from app.helpers.tools import AsyncRepositoryWorker, read_string_from_file
from app.gui.widgets.circular_progress import CircularProgress

def _devices_with_auto_connect():
"""Обёртка для DeviceRepository.devices с авто-подключением по Wi-Fi-adb через phone-transport.sh."""
try:
subprocess.run(['bash', AppScripts.TRANSPORT_SCRIPT], capture_output=True, timeout=6)
except Exception:
pass
return DeviceRepository.devices()


class DeviceItemDelegate(QStyledItemDelegate):
def sizeHint(self, option: 'QStyleOptionViewItem', index: QtCore.QModelIndex) -> QtCore.QSize:
Expand Down Expand Up @@ -123,7 +132,7 @@ def update(self):
worker = AsyncRepositoryWorker(
name="Devices",
worker_id=self.DEVICES_WORKER_ID,
repository_method=DeviceRepository.devices,
repository_method=_devices_with_auto_connect,
arguments=(),
response_callback=self._async_response
)
Expand Down Expand Up @@ -166,6 +175,16 @@ def open(self):
if self.device.id:
if Adb.manager().set_device(self.device):
Global().communicate.files.emit()
# Fetch device info in background and broadcast via signal
worker = AsyncRepositoryWorker(
name="DeviceInfo",
worker_id=201,
repository_method=self._fetch_device_info,
arguments=(self.device.id,),
response_callback=self._on_device_info
)
if Adb.worker().work(worker):
worker.start()
else:
Global().communicate.notification.emit(
MessageData(
Expand All @@ -174,3 +193,19 @@ def open(self):
body="Could not open the device %s" % Adb.manager().get_device().name
)
)

@staticmethod
def _fetch_device_info(device_id: str):
"""Called in background thread — returns (model, avail_gb, total_gb, sd_path)."""
model = StorageRepository.get_device_model(device_id)
disk = StorageRepository.get_disk_info(device_id, '/sdcard')
sd = StorageRepository.get_sd_card_path(device_id)
avail = disk.get('avail_gb', 0.0)
total = disk.get('total_gb', 0.0)
return (model, avail, total, sd), None

@staticmethod
def _on_device_info(data, error):
if data:
model, avail, total, sd = data
Global().communicate.device_info_ready.emit(model, float(avail), float(total), sd or '')
Loading