...

среда, 3 ноября 2021 г.

Я устал вручную удалять мобильное приложение с устройства и написал расширение для VS Code

Каждый мобильный разработчик рано или поздно сталкивается с тем, что его начинают утомлять некоторые рутинные операции. Скажем, дебажишь авторизацию в приложении. Или проверяешь перехват UTM-метки при первой установке. Или пытаешься понять, работает ли корректно очередная миграция БД. Или попадаешь в еще миллион ситуаций, когда тебе нужно по много раз елозить иконкой приложения по экрану смартфона (или курсором по эмулятору), чтобы его удалить и установить начисто.

Когда я работал над нативными Android-приложениями, меня спасал плагин для Android Studio ADB Idea. Очень удобно через шорткат можно было вызвать окошко с основными ADB командами по типу "Удалить приложение", "Принудительно завершить приложение", "Перезапустить приложение" и т.п.

Команды ADB Idea
Команды ADB Idea

Очень я привязался к этому плагину. И когда жизнь занесла меня в кроссплатформенную мобильную разработку, а конкретно Flutter, я был неприятно удивлен, что даже в Android Studio в Flutter-проектах ADB Idea не работает. Впрочем, глянув мельком на его код, я понял, что там всё довольно сильно завязано на разные API, которые вне нативного проекта в плагине нельзя использовать.

А через некоторое время я вообще переехал на VS Code. Причин было несколько — от более стабильной работы на маке с Apple Silicon до лучшей автономности ноутбука. К своему сожалению, здесь плагина с похожим функционалом найти не удалось.

"Кто, если не мы", — подумал я и пошел изучать API расширений VS Code. Оно оказалось довольно простым. Это радовало, так как подсознательно я решил, что надо максимум за день сделать работающий proof-of-concept. Просто на больше моего энтузиазма может не хватить. Забегая наперед, с первой рабочей версией я в эти временные рамки вложился.

Сходу демонстрация, что в результате получилось:

В целом, от API расширений мне надо было не так много вещей, я просто хотел выполнять несколько терминальных ADB-команд из кода, добавиться в Command Palette и показывать всплывающие уведомления, поля ввода да селекторы со списком вариантов. Ну, еще что-то вроде key-value storage для хранения package name приложение, да доступ к файлам открытого проекта, чтобы этот самый package name попытаться вытащить из build.gradle.

VS Code — это Electron-приложение, так что ожидаемо расширения для него пишутся на JavaScript и TypeScript. С первым у меня есть полгода опыта, когда я писал React Native приложение, так что решил поиграться с TypeScript, ведь проверка типов — наше всё.

Благо, VS Code — это не браузер, и запуск терминальных команд в нем довольно прост:

import { exec, ExecOptions } from 'child_process';

const executeCommand = async (cmd: string, options: ExecOptions | null = null) => {
  return new Promise((resolve, reject) => {
    exec(cmd, options, (err, stdout) => {
      if (err) {
        reject(err);
      } else {
        resolve(stdout);
      }
    });
  });
};

Осталось только разобраться, какие собственно команды следует таким образом выполнять. С adb uninstall com.package.name все понятно, но раз уж затевать разработку плагина, то, наверное, не ради единственной команды. Стоит хотя бы повторить и другие из ADB Idea.

Принудительное закрытие приложение через adb shell am force-stop и очистка данных приложения с помощью adb shell pm clear тоже довольно прозрачны. Вот с запуском приложения пришлось немного погуглить, оказалось, это можно провернуть с помощью утилиты monkey, идущей вместе с Android SDK (штука для стресс-теста приложения кучей тапов в рандомных местах): adb shell monkey -p com.package.name -c android.intent.category.LAUNCHER 1. Здесь запускается как бы один тап, который всегда делается по ярлыку приложения.

Прикрутив эти команды и их комбинации к плагину, у меня получились следующие команды, которые можно запускать с Command Palette VS Code (Cmd/Ctrl+Shift+P):

Ну, еще с "Revoke Permissions" пришлось поиграться. Отобрать пермишен можно с помощью комманды adb shell pm revoke com.package.name android.permission.PERMISSION_NAME, а вот чтобы понять, какие пермишены можно отбирать, пришлось немного попарсить вывод команды adb shell dumpsys package com.package.name. Он выглядит как огромная простыня текста, в которой встречаются вот такие строки:

То есть, строка с названием пермишена, после которого указано granted=true.

Быстренько накидал выковыривание этого списка:

    let grantedPermissions = dumpsysOutput
      .split('\n')
      .filter((line) => line.indexOf('permission') >= 0 && line.indexOf('granted=true') >= 0)
      .map((line) => line.split(':')[0].trim());

Не гарантирую, что это оптимальный способ распарсить эти данные, но он работает. Наверное, еще бы хорошо брать только раздел runtime permissions:, но пока просто игнорирую неудачную попытку отобрать пермишен. Скорее всего, это будет означать, что он не runtime.

Следующая задача — понять, Package Name какого приложения использовать в ADB-командах. Изначально я добавил возможность указать имя пакета вручную, показывая пользователю инпут, валидируя ввод и складывая его в ExtensionContext.workspaceState, но хотелось бы как-то упросить эту ситуацию и получать applicationId из Gradle автоматически (только сейчас понял, что можно еще пробовать искать package в AndroidManifest, хотя сейчас его указание там вручную необязательно). В Gradle можно наворотить что угодно, связанное с генерацией applicationId, однако в кросс-платформенных проектах этим не особо заморачиваются, так что в большинстве случаев проблем быть не должно. Пока реализован парсинг самой простой ситуации — когда только выполнил flutter create myapp и больше ничего в Gradle не трогал. То же самое будет работать и для React Native, во всяком случае без Expo. Сработает, и если открыть в VS Code сам Android проект, но вряд ли это кому-то нужно.

const POSSIBLE_BUILD_GRADLE_FILES = [
  'app/build.gradle',
  'android/app/build.gradle',
];

const findProjectAndroidApplicationId = async (workspaceFolders: Array<Uri>): Promise<string> => {
  let foldersToCheck = workspaceFolders.filter((uri) => uri.scheme === 'file');

  const readFile = promisify(fs.readFile);

  for (let folder of foldersToCheck) {
    for (let possibleFile of POSSIBLE_BUILD_GRADLE_FILES) {
      let possiblePath = path.join(folder.fsPath, possibleFile);
      try {
        let openedFile = await readFile(possiblePath, { encoding: 'utf8' });
        let possibleLines = openedFile.split('\n')
          .filter((line) => line.indexOf('applicationId ') >= 0)
          .map((line) => {
            const appIdMatch = line.match(VALID_APPLICATION_ID_MATCHER);
            if (appIdMatch === null || appIdMatch.length === 0) { return ''; }
            return appIdMatch[0];
          })
          .filter((appId) => appId.length > 0);

        if (possibleLines.length > 0) {
          return possibleLines[0];
        };
      } catch (err) {
        console.log(`Cannot open file ${possiblePath}, will not get application id from it: ${err}`);
        continue;
      }
    }
  }

  return '';
};

Грубо говоря, ищу в файлах build.gradle что-то похожее на имя пакета в одной строке с вызовом applicationId, и возвращаю первое попавшееся. Внезапно, для всех моих проектов это сработало и вытащить правильный package name там удалось (но если у вас есть идея получше, милости прошу в PR на Github). Для ситуаций, когда все же не удалось таким образом найти package name, показываю инпут для ввода и сохраняю его на будущее.

В общем, за несколько небольших сессий в течение дня получилось собрать что-то рабочее. Правда, потом я понял, что устройств же может быть подключено несколько, и тогда в adb надо передавать флаг -s с идентификатором устройства. Тут тоже пришлось парсить вывод команды adb devices.

private chooseDeviceToRunCommandOn = async (): Promise<string> => {
    let activeDevices = (await executeCommand('adb devices') as string)
      .split('\n')
      .filter((_, index) => index > 0)
      .map((line) => line.split('\t')[0].trim())
      .filter((line) => line.length > 0);
    if (activeDevices.length === 0) { return ''; }
    if (activeDevices.length === 1) { return activeDevices[0]; }

    let userSelectedDevice = await vscode.window.showQuickPick(
      activeDevices,
      {
        title: 'Choose target device',
        canPickMany: false,
      }
    );

    return userSelectedDevice ?? '';
  };

Если устройство только одно, то используем его, если же больше, то показываем их список, чтобы пользователь выбрал нужное. Вот теперь можно и релизить.

Скинул плагин нескольким друзьям, которые работают с Flutter и React Native, на попробовать. Угадайте, каким был первый запрос. "А для iOS то же самое можно?" Естественно, ADB c iOS не работает, пришлось идти в гугл, где обнаружился IDB. Такая себе аллюзия на ADB, но от Facebook и для iOS. adb devices превращается в idb list-targets, adb shell am force-stop в idb terminate. Вместо флага для передачи идентификатора устройства надо предварительно вызывать idb connect. Можно работать.

Эта штука, конечно, не настолько распространена, но процесс установки довольно прост. Печально только, что для ARM до сих пор надо собирать с исходников, иначе не заведется:

brew install protobuf 

brew install grpc

git clone git@github.com:facebook/idb.git

cd idb

pod install

./idb_build.sh idb_companion build /opt/homebrew

codesign --force --sign - --timestamp=none /opt/homebrew/Frameworks/FBDeviceControl.framework/Versions/A/Resources/libShimulator.dylib

codesign --force --sign - --timestamp=none /opt/homebrew/Frameworks/FBSimulatorControl.framework/Versions/A/Resources/libShimulator.dylib

codesign --force --sign - --timestamp=none /opt/homebrew/Frameworks/XCTestBootstrap.framework/Versions/A/Resources/libShimulator.dylib

codesign --force --sign - --timestamp=none /opt/homebrew/Frameworks/FBControlCore.framework/Versions/A/Resources/libShimulator.dylib

pip3 install fb-idb

Еще одна проблема с IDB — это невозможность узнать, какой симулятор в данный момент запущен. Команда idb list-targets выдает такой результат:

Возле всех симуляторов написано "Shutdown", пока на них не будет выполнено idb connect. Благо, хоть реальные устройства сразу получают статус "Booted". Сделал сортировку, чтобы реальные устройства были вверху, чуть ниже симуляторы, которые уже использовались, и ниже все остальные. Это вроде немного уменьшает боль от постоянной необходимости выбора устройства.

private chooseDeviceToRunCommandOn = async (): Promise<string> => {
    let devices = (await executeCommand('idb list-targets') as string)
      .split('\n')
      .filter((line) => line.length > 0)
      .map((line) => {
        const values = line.split('|').map((value) => value.trim());

        return {
          uuid: values[1],
          name: values[0],
          isBooted: values[2] === 'Booted',
          isPhysical: values[3] === 'device',
          osVersion: values[4],
        } as Device;
      }).sort((deviceLeft, deviceRight) => {
        if (deviceLeft.isPhysical && !deviceRight.isPhysical) { return -1; }
        if (deviceLeft.isBooted && !deviceRight.isBooted) { return -1; };
        return deviceLeft.name.localeCompare(deviceRight.name);
      });

    const userSelectedDeviceLabel = await vscode.window.showQuickPick(
      devices.map((device) => `${device.name} | ${device.osVersion} ${device.isBooted ? '| connected' : ''}`),
      {
        title: 'Choose target device',
        canPickMany: false,
      }
    );

    if (!userSelectedDeviceLabel) { return ''; }
    const userSelectedDeviceName = userSelectedDeviceLabel.substring(0, userSelectedDeviceLabel.indexOf('|')).trim();

    const userSelectedDevice = devices.filter((device) => device.name === userSelectedDeviceName)[0];
    return userSelectedDevice.uuid;
  };

Понятно, все команды ADB повторить не удалось, но самые нужные, как по мне, присутствуют:

Да, Bundle ID тоже попробовал поискать в *.xcodeproj. Для простых кейсов вроде работает.

export const findProjectIOSBundleIdentifier = async (workspaceFolders: Array<Uri>): Promise<string> => {
  const readFile = promisify(fs.readFile);
  const readdir = promisify(fs.readdir);

  try {
    let dirsToCheck = workspaceFolders.filter((uri) => uri.scheme === 'file');
    let finalDirs = Array<string>();

    for (let dir of dirsToCheck) {
      finalDirs.push(dir.fsPath);
      const openedDir = await readdir(dir.fsPath);
      const iosDir = openedDir.find((folder) => folder === 'ios');
      if (iosDir) { finalDirs.push(path.join(dir.fsPath, iosDir)); }
    }

    for (let possibleDir of finalDirs) {
      try {
        const dir = await readdir(possibleDir);
        const xcodeProjects = dir.filter((name) => name.endsWith('.xcodeproj'));
        for (let projectPackage of xcodeProjects) {
          const projectFiles = await readdir(path.join(possibleDir, projectPackage));
          const project = projectFiles.find((name) => name === 'project.pbxproj');
          if (!project) { continue; }
          const openedProject = await readFile(path.join(possibleDir, projectPackage, project), { encoding: 'utf8' });
          let possibleIds = openedProject.split('\n')
            .filter((line) => line.indexOf('PRODUCT_BUNDLE_IDENTIFIER') >= 0)
            .map((line) => {
              const appIdMatch = line.match(VALID_APPLICATION_ID_MATCHER);
              if (appIdMatch === null || appIdMatch.length === 0) { return ''; }
              return appIdMatch[0];
            });
          if (possibleIds.length > 0) {
            return possibleIds[0];
          }
        }
      } catch (err) {
        console.log(`Cannot work with directory ${possibleDir}, will not get application id from it: ${err}`);
        continue;
      }
    }
  } catch (err) {
    console.log(`Cannot get application id: ${err}`);
  }

  return '';
};
iOS команды тоже работают
iOS команды тоже работают

Собственно, пока все. Расширение работает и мои задачи в полной мере решает. Так как оно может решать и чьи-то еще задачи, я его опубликовал:

VS Code Marketplace. Отсюда можно устанавливать.

Github. Сюда можно ходить с багами и Pull Request-ами.

В качестве заключения выводы:

  • Писать расширения проще, чем кажется, во всяком случае, для VS Code.

  • Facebook Meta пока разработчикам не выдает маки на Apple Silicon, раз установку IDB за год не починили.

  • TypeScript волне себе неплох, даже немного жаль, что Flutter решил жить с Dart.

Adblock test (Why?)

Комментариев нет:

Отправить комментарий