Как стать автором
Обновить
452.41
FirstVDS
Виртуальные серверы в ДЦ в Москве и Амстердаме

Как забрать фотки с флешки, если Android их не отдаёт

Уровень сложностиПростой
Время на прочтение12 мин
Количество просмотров3.4K

Как-то так получилось, что я никогда не воспринимал 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

Теги:
Хабы:
+10
Комментарии7

Публикации

Информация

Сайт
firstvds.ru
Дата регистрации
Дата основания
Численность
51–100 человек
Местоположение
Россия
Представитель
FirstJohn