Как-то так получилось, что я никогда не воспринимал Android-телефон как объект, содержимым которого можно управлять из обычного .fsx
. Все данные туда и обратно я таскал исключительно ручками при помощи USB и проводника. Дальше на стороне компа их мог раскидывать скрипт, но в зону телефона я не лез. Максимум, мог написать мобильное приложение для систематических операций с загрузкой на сервер и обратно. Однако недавно нужда заставила меня проникнуть из скрипта в обе области сразу, и это оказалось настолько проще, чем я предполагал, что теперь я испытываю злость и сожаление (как будто проморгал выигрышную комбинацию и додавливал противника лишние 4 хода).
Дисклеймер: До этого всё, что я публиковал на Хабре, касалось тем, которые мне известны очень хорошо. Ну или как минимум, затрагиваемое пространство всегда было заметно меньше известного. В этот раз ситуация обратная. Я случайно набрёл на интересную нишу и поверхностно изучил её. Так как я благополучно решил все свои задачи, то вряд ли буду раскапывать тему дальше. Но раз уж мне удалось неявно закрыть несколько запылившихся ишуев на Гитхабе, то мне показалось полезным просуммировать в тексте собранные знания, а также некоторые исторические наработки. Считайте, что я на секунду появился в дверях вашей комнаты, сказал нечто вроде «Посоны, там-то и там-то есть что-то интересное» и свалил.
Кризис и поиск решения
По ряду причин я не пользуюсь облачными хранилищами для автоматической разгрузки телефонов. Вместо этого 2-3 раза в год я лично перегружаю их содержимое в фотоархив. Данных у меня немного, так что процесс меня не напрягает, скорее даже успокаивает. Однако неожиданно для самого себя я смог полностью забить 64-гиговую MicroSD-карту фотографиями с велопоездок. Телефон меня об этом не предупредил, зато вместо этого начал складывать новые фото в основную память и забил её в ноль до состояния полной невменяемости. Первые 2500+ (из 8000+) фотографий я уже когда-то копировал на внешние носители, поэтому их можно было удалить без последствий. Однако сделать это с умирающего от перенапряжения телефона оказалось слишком муторно, а с компа вообще невозможно, так как любые попытки открыть папку намертво вешали проводник. Мне было бы несложно дождаться копирования всей папки, невзирая на дубли, но почему-то массовое копирование прерывалось в произвольном месте и оборачивалось битыми изображениями на компе.
Я решил выкинуть проводник и разобраться с папкой при помощи скрипта. System.IO
меня ещё никогда не подводил, но обнаружилось, что он не может добраться до флешки, пока она находится в телефоне. Android
при подключении через USB либо показывает всем только свой основной диск, либо нам показывает 2 диска, а в файловой системе — ни одного (в проводнике директории есть, а пути к ним не существует).
От таких архитектурных изысков я очень быстро пришёл к выводу, что их авторы вконец обнаглели (литературный эквивалент), и вместо выдёргивания карты побежал искать подходящую либу. Эпического путешествия не получилось. Сработала первая же ссылка в поиске, которая вела на нугет-пакет SharpAdbClient
.
Для тех, кто далёк от мобильной разработки, Adb
в названии пакета расшифровывается как Android Debug Bridge
. adb.exe
— это утилита с консольным интерфейсом для доступа к Android-телефону с компа. Вроде бы позиционируется как основное средство коммуникации с устройствами на данной операционной системе. Я попиливаю мобильные приложения немассового характера, но в desktop-first режиме, так что я знаком с adb.exe
очень поверхностно. Напрямую я редко с ней сталкиваюсь и помню из неё наизусть буквально 5 команд, но неявно использую её очень часто через интерфейс IDE и т. д.
Приблизительно ту же роль в нашей схеме исполнит пакет SharpAdbClient
. По нашему запросу он запускает adb.exe
в отдельном процессе, грузит в него наши команды (объекты -> строковые аргументы
) и парсит результат (строковый вывод -> объекты
). В общем либа проследит, чтобы мы не контактировали с adb.exe
и не тратили время на строковое представление данных.
Подключение
По канону здесь я должен был рассказать, как поставить adb.exe
и завести на телефоне «режим разработчика», но, по моему мнению, решение этих задач слишком просто и слишком объёмно, чтобы не разрушить целостность статьи. Так что если вам действительно это необходимо, то рекомендую посмотреть инструкции по запуску Xamarin
-приложений на Android
.
adb.exe
по очевидным причинам не входит в nuget-пакет, так что путь к утилите надо указывать явно в момент запуска сервера adb
. В моём случае инициализация выглядит так:
#r "nuget: SharpAdbClient"
open SharpAdbClient
open System
open System.IO
let inline (^) f x = f x
let server = new AdbServer()
// Путь может сильно разниться.
server.StartServer """C:\Program Files (x86)\Android\android-sdk\platform-tools\adb.exe"""
Далее нам надо завести AdbClient
и найти нужное устройство:
let client = AdbClient()
let device = client.GetDevices() |> Seq.head
GetDevices
возвращает DeviceData ResizeArray
из подключённых к компу в данный момент устройств. Информации в каждом DeviceData
немного:
type DeviceData = { // Имитация, не оригинал.
Features : string
Message : string
Model : string // Обычно не совпадает с "маркетинговым" названием.
Name : string
Product : string
Serial : string // Serial (для usb) или IP:Port (для tcpip)
State : DeviceState // Enum
TransportId : string
Usb : string
}
Если устройств несколько, а вы видите этот список первый раз в жизни, то потребуется время, чтобы установить, кто из них кто. Так что для начала рекомендую подключать их по одному, а в дальнейшем иметь словарик с данными телефонов для их идентификации. Состав этих данных зависит от конкретной ситуации или, точнее, от ваших конвенций.
Например, adb
позволяет работать с андроид-устройством по сети. Для этого достаточно подключить его через USB, вызвать команду adb tcpip <port>
(порт выбираем сами), а потом переподключиться к нему через adb connect <ip>:<port>
(адрес смотрим в настройках WiFi). При таком подключении DeviceData
вместо Serial
выдаёт полный адрес устройства (<ip>:<port>
). Соответственно, два телефона одной модели будут различаться лишь адресом.
Локальные IP-адреса — очень плохие идентификаторы, так как они переходящи, часто даже в рамках одной сети. Это создаёт риск неприятных ситуаций вида: «мы подключились к Б, думая, что это А, синхронизировались, и вот уже третий день думаем, как рассинхронизировать всё обратно». С портами ситуация получше. Мы всегда устанавливаем их вручную индивидуально для каждого телефона, а значит, можем сделать их уникальными. Их нельзя зафиксировать раз и навсегда на уровне железа или ПО, но мне хватает метки с номером порта на чехле (и в архиве) и дрессированной команды, которая поддерживает сквозную нумерацию на всех телефонах участников (а также их семей...). Таким образом, если adb connect <ip>:<port>
проходит успешно, то можно однозначно установить, с кем мы имеем дело, лишь по номеру порта.
Отправка команд
Набор команд adb.exe
хорошо покрывается SharpAdbClient
, но в действительности он не такой уж большой. Кажется, он исчерпывается операциями, которые происходят на стыке двух систем (телефона и компьютера), типа настройки соединения, пересылки файла с/на устройство, установки приложения и т. п. Все «обыденные» операции adb.exe
просто делегирует внутренней оболочке телефона, которую можно дёрнуть через команду shell
.
В оригинале нам надо вызвать adb shell <текст команды>
, чтобы <текст команды>
был интерпретирован устройством (а не adb.exe
). В проекции SharpAdbClient
загрузить <текст команды>
в оболочку можно через IAdbClient.ExecuteRemoteCommand
. Новое название ближе к сути, но из-за особенностей консольного вывода устройство метода оказалось непригодным для конечного использования. К счастью, конкретно в скриптах его можно легко завернуть в расширение типа:
type DeviceData with
member this.Execute cmd =
let receiver = ConsoleOutputReceiver()
client.ExecuteRemoteCommand(cmd, this, receiver)
receiver.ToString()
Теперь мы можем вызвать команду getprop
и получить «человеческое» название модели:
device.Execute "getprop ro.product.marketname" // расположение свойства может отличаться
val it: string = "Redmi Note 10S
" // NB: Перевод строки.
Или Serial
-номера телефона:
device.Execute "getprop ro.serialno"
Эта команда вернёт серийный номер устройства независимо от варианта подключения. Не будет никакой замены на <ip>:<port>
, как в DeviceData
, так что его можно использовать для однозначной идентификации.
Правда, если быть технически точным, то возвращается не значение свойства, а весь вывод из консоли. Его можно интерпретировать визуально, но для дальнейшего использования в алгоритме его надо будет обтесать, а в идеале распарсить и провалидировать. Если этого не сделать, то сообщение об ошибке может спокойно доехать до самого конца скрипта и при этом формально ничего не уронить.
Особое «удовольствие» доставляют ответы, которые являются коллекциями элементов. Так device.Execute "ls"
вернёт список директорий в трёх (число зависит) столбцах, пространство между которыми будет заполнено пробелами. Проблема быстро решается алгоритмически (или параметризацией), но каждое такое решение несёт риск ошибки.
Обмен файлами
Push
и pull
операции с файлами находятся в объекте SyncService
. Это IDisposable
объект, конструктор которого требует IAdbClient
и DeviceData
. В нём есть 3 интересных нам метода:
type SyncService with
// Общая информация по файлу или директории.
member this.Stat remotePath : string -> FileStatistics
// Таже информация но по всем файлам и папкам в директории.
member this.GetDirectoryListing remotePath : string -> FileStatistics Seq
// Загружает файл по указанному пути в переданный Stream.
member this.Pull (
remoteFilePath : string
, stream : Stream
, progress : int IProgress
, cancellationToken : CancellationToken
) -> unit
Их суть самоочевидна, но с их вызовом возникли проблемы.
Short life
Почему-то конкретно в моём случае экземпляры SyncService
приобрели свойство одноразовости. Нетронутый SyncService
можно было хранить неограниченно долго, и первый вызов всегда проходил как надо. Но каждый последующий имел всё более высокую вероятность навернуться. Суть проблемы сводилась к тому, что в общий поток обмена залетали некорректные абракадабры, которые ломали механизм разбора сообщений. Откуда они взялись, я не знаю, но допускаю, что это была та же проблема, что мешала скопировать папку при помощи проводника.
Избавляться от них я даже не пытался. Если один вызов всегда работает, а создание SyncService
по большому счёту нам ничего не стоит, то этого достаточно, чтобы закрыть все наши потребности. Мы просто будем создавать по экземпляру для каждой операции:
type DeviceData with
// (f : SyncService -> 'a) -> 'a
member this.SyncService f =
using (new SyncService(client, this)) f
Ход может выглядеть костыльно и/или пофигистично, но это уже устоявшаяся практика. И я говорю не только о SyncService
, а в целом об одноразовом использовании мутабельных объектов с якобы длительным жизненным циклом.
Концепция «любой дурак может реализовать фичу через мутабельный код» в действительности означает, что у вас есть код, который где-то ломается, но ни вы, ни автор ещё не знаете где. Чаще всего проблема в деградации объекта, иногда в необратимой. Это становится очевидным, если освоить Hedgehog
. И это становится эпической проблемой, если надо поддерживать фреймворки по типу Fabulous
. Ведь такие платформы исходят из положения, что нижние слои не накосячили и нам достаточно лишь вовремя подтаскивать дифы. Почему-то никто не предполагает, что есть организации, которые обделались буквально в каждом своём контроле, и их теперь надо вручную водить по графу простых и «непростых» переходов.
Искусственно сокращая время жизни объекта, мы мешаем ему состариться и прийти в негодность. «Живи быстро, умри молодым» — дурацкая идеология, но она выигрывает, когда ошибки не могут быть исправлены. Я завёл операцию экспорта в REPL минут за 30-40 и потом столько же выкачивал файлы. Это не тот срок, за который можно внести исправления в существующую библиотеку, особенно когда видишь её первый раз в жизни и просто не знаешь, где находится источник проблемы.
В общем, костыль с using
— не костыль, а правда жизни. Ну и раз она неизбежно будет встречаться в полноценных приложениях, то имеет смысл знать, что SyncService
может быть легко перекрыт один в один следующим образом:
type MyService = {
Client : IAdbClient // На случай, если client перестал был частью скоупа.
Device : DeviceData
}
with
member this.SyncService f =
using (new SyncService(this.Client, this.Device)) f
// Создано за 2 минуты при помощи REPL, рефлексии и мультикурсора.
member this.Push(stream, remotePath, permissions, timestamp, progress, cancellationToken) =
this.SyncService ^ fun service ->
service.Push(stream, remotePath, permissions, timestamp, progress, cancellationToken)
member this.Pull(remoteFilepath, stream, progress, cancellationToken) =
this.SyncService ^ fun service ->
service.Pull(remoteFilepath, stream, progress, cancellationToken)
member this.Stat(remotePath) =
this.SyncService ^ fun service ->
service.Stat(remotePath)
member this.GetDirectoryListing(remotePath) =
this.SyncService ^ fun service ->
service.GetDirectoryListing(remotePath)
Файлы и папки
Организация каталога тоже оказалась с особенностями. В первую очередь, следует вспомнить, что Android
не Windows
, поэтому слэши /
в нём надо писать в строго определённую сторону (из 0,1
в 1,0
, как комментарии).
GetDirectoryListing
оказался требовательнее к путям, чем ls
. На пустой путь он возвращает пустую последовательность, а не содержимое корня. Если нам нужен именно он, то его надо указывать явно: service.GetDirectoryListing "."
.
Формально GetDirectoryListing
возвращает FileStatistics seq
, что подразумевает ленивость, но на самом деле операция жадная. Полученная последовательность фиксируется, причём не в момент первого прогона, а в момент своего возникновения. Из-за этого вызов метода может быть ощутимым по времени, но зато последовательность не боится смерти отеческого SyncService
.
Ни Stat
, ни GetDirectoryListing
никак не жалуются на отсутствующий путь. И если пустая коллекция ещё может застопорить алгоритм, то в случае Stat
мы получаем объект, заполненный дефолтными значениями.
type FileStatistics = { // Имитация, не оригинал.
Path : string
Time : DateTimeOffset // UnixEpoch
Size : int
FileMode : System.IO.UnixFileMode
}
FileStatistics.Path
в GetDirectoryListing
— это локальный путь относительно запрошенной директории, поэтому директорию запроса надо протаскивать по алгоритму.
Time
или, точнее, DateTimeOffset
хранят свой часовой пояс, но всё встреченное мною время дано по UTC
(и это не мой часовой пояс). Так что использовать эту информацию для отсева фоток в другом регионе не получится.
Привычные нам диски располагаются в папках. Сомневаюсь, что имена одинаковы для всех устройств, но как минимум на моём многострадальном было так:
Содержимое флешки располагалось по адресу:
"storage/0123-4567/"
.А содержимое встроенной памяти:
"sdcard/"
.
Это не ошибка и не опечатка. Внутреннее хранилище телефона располагается в папке с названием "sdcard"
, в то время как реальная SD-карта располагается где-то в "storage"
. Уверен, что у данного факта есть историческое объяснение, но так как папки я нашёл методом пристального взгляда и рекурсивным обходом, то я об этой истории ничего не знаю.
Кириллица
Фотографии с камеры имеют устоявшийся формат имён из цифр и латинских букв. Но если вам понадобится потягать файлы с более экзотическими названиями, то у вас возникнут небольшие проблемы. Файл с именем "sdcard/Download/Журнал_2021_2_compressed.pdf"
в консольном выводе и в FileStatistics
будет называться "sdcard/Download/ÐÑÑнал_2021_2_compressed.pdf"
.
Выглядит как типовой сбой кодировки, которую надо подправить на стороне клиента, но я не нашёл эту опцию ни в AdbClient
, ни в целом по SharpAdbClient
. Тип Encoding
встречается лишь как одноимённое статическое свойство в AdbClient
с заблокированным сеттером. Там установлена кодировка ISO-8859-1
, в народе известная как System.Text.Encoding.Latin1
. Перебить её мы не можем, но можем к ней адаптироваться.
Перекодировка сама по себе — штука простая:
type Encoding with
member this.RecodeTo target text =
this.GetBytes (text : string)
|> (target : Encoding).GetString
Сложности возникают на этапе её использования:
device.SyncService ^ fun service ->
service.GetDirectoryListing """sdcard/Download/"""
|> Seq.map ^ fun p ->
AdbClient.Encoding.RecodeTo Encoding.UTF8 p.Path
|> List.ofSeq
И дело не в вербозности, которую можно спрятать в условные of/toAbracadabra
. Код выше вернёт коллекцию с человеческими именами. Эти имена хороши для пользовательского вывода и ориентации в файловой системе компьютера, но для коммуникации с adb
нам надо вернуть эти строки к исходному состоянию. Обычный string
не хранит в себе данные о своей кодировке, а без этой информации двойное перекодирование в кашу нет-нет да и происходит. В полноценном приложении такие вопросы решаются изоляцией контекста SharpAdbClient
, которая не позволит Latin1
-строкам выходить за пределы ограниченного домена без преобразования или упаковки в отдельный тип. Это также означает, что UTF8
-строки не должны проникать в контекст без обратного преобразования.
В случае скриптов этот подход обычно не работает, так как полноценная обёртка требует времени, а неполноценная будет бить вас палкой по поводу и без при каждом переходе через границу покрытия. В этой ситуации имеет смысл откатиться от системного решения к исходной хотелке, к строкам, знающим свою кодировку или своё представление в нескольких кодировках:
module Recoded =
open System.Text
type Encoding with
member this.RecodeTo target text =
this.GetBytes (text : string)
|> (target : Encoding).GetString
type Main = {
Properly : string
Abracadabra : string
}
let ofProperly properly = {
Properly = properly
Abracadabra = Encoding.UTF8.RecodeTo AdbClient.Encoding properly
}
let ofAbracadabra abracadabra = {
Properly = AdbClient.Encoding.RecodeTo Encoding.UTF8 abracadabra
Abracadabra = abracadabra
}
let combinePath parent child =
System.IO.Path.Combine(parent.Properly, child.Properly)
|> ofProperly
При вклинении этот тип принудительно рвёт устоявшиеся потоки данных, требуя от системы в обоих направлениях либо перейти на новый тип, либо явно собрать/разобрать объект. Эту задачу мог бы решить любой тип-обёртка над строкой, который на статическом уровне прибит к конкретной кодировке. Однако я предпочитаю «билингвальную» структуру Recoded.Main
, так как она сохраняет связь с неадаптированной частью скрипта, её удобно матчить и её удобно обозревать. Последний фактор наиболее значим, когда работа ведётся в REPL без использования стороннего UI. Эти удобства можно наколдовать поверх любого типа при помощи активных шаблонов, fsi.AddPrint_
и т. п., но рекорды обладают ими из коробки. Таким образом, выходит, что данная конкретная структура Recoded.Main
обусловлена REPL и ленью, и при изменении условий ей также следует измениться.
Удаление файлов
В SyncService
нет методов, отвечающих за удаление файлов. Их нет и в adb.exe
. Это может вызывать удивление, но напоминаю, что задача adb
— это коммуникация между компом и телефоном. Операции, не требующие сетевого взаимодействия, легко решаются средствами встроенной консоли. Конкретно удаление производится через линуксовский rm
:
device.execute "rm <filepath>"
Операции копирования, перемещения и т. п. надо искать в той же области.
Заключение
Предположу, что лично моё погружение в adb
закончилось. Дальше я планирую ещё кое-что накрутить в области логики экспорта и, быть может, UI, но напрямую к данной утилите это отношения не имеет.
В целом вкатывание оказалось довольно простым и местами даже забавным. Так что я бы рекомендовал эту автоматизацию самым новичковым новичкам. И язык с REPL-ом пощупать можно, и пользу принести, и опыт какой-никакой накапает.
Автор статьи @kleidemos
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
— 15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS
Комментарии (7)
whocoulditbe
12.12.2024 14:59Я решил выкинуть проводник и разобраться с папкой при помощи скрипта.
Зачем писать обёртку вокруг adb, если можно сделать
adb pull <directory>
? Поправить кодировку названий файлов?kleidemos
12.12.2024 14:59Не, конфликт кодировок на содержимое файлов не распространяется. Так что если бы я шарил в adb больше, я бы может так и сделал. Однако я иначе представлял себе её назначение, поэтому даже не предполагал наличие такой команды.
К тому же я не знал точный путь к директории. Я был уверен, что меня ждёт отдельный диск D, а не подпапка. Благодаря
SharpAdbClient
иSyncService
у меня была готовая структура данных, которой я мог оперировать в привычной мне манере, так что поиск не занял много времени.Кроме того, структура папок моего фото-архива устроена сложнее, чем исходная папка на телефоне. На F# я могу легко написать предикат, который установит наличие копии в фото-архиве, но сделать это через консольный скрипт я сходу не смогу. Соответственно надо копировать либо всё, либо дёргать файлы по одному, а мне это проще делать через скрипт.
И наконец, мне надо было удалить файлы на телефоне, но не все. Некоторые моменты мне хотелось иметь и там, и там. Это подразумевает поштучное удаление, а в моём случае ещё и перенос файлов с внутреннего хранилища телефона на sd-карту.
Если бы я столкнулся с непреодолимым препятствием, я быть может допёр до
adb pull
, но всё удавалось слишком быстро и легко, чтобы я успел остановиться и подумать об альтернативах. Правда и сейчас я не вижу в этом смысла, так как мои скрипты проворачивают все необходимые мне операции с предельной точностью. Причём они могут делать это в автоматическом режиме, просто обнаруживая телефон в своей зоне действия.
aik
Не совсем понятно, в чём была задача. Вытащить файлы с карты памяти?
А может тогда было проще её вынуть и в кардридер воткнуть?
saege5b
У него часть фоток на самом телефоне. А свободного пространства - 0.
kleidemos
Аргументация верная, но в скрипт я полез из-за того, что у меня бомбило от несовершенства софта. Кроме того флешку не так-то просто вынуть и физически, и программно (там приложения стоят). По хронометражу может и быстрее, но точно не удобнее.
saege5b
Кстати, в некоторых сборках, карта памяти по умолчанию шифруется :)
dartraiden
Карта памяти окажется зашифрованной, если пользователь выбрал использовать её для "расширения встроенной памяти". Ключ шифрования хранится в самом телефоне, удачи его извлечь из TEE.