Каждый мобильный разработчик рано или поздно сталкивается с тем, что его начинают утомлять некоторые рутинные операции. Скажем, дебажишь авторизацию в приложении. Или проверяешь перехват UTM-метки при первой установке. Или пытаешься понять, работает ли корректно очередная миграция БД. Или попадаешь в еще миллион ситуаций, когда тебе нужно по много раз елозить иконкой приложения по экрану смартфона (или курсором по эмулятору), чтобы его удалить и установить начисто.
Когда я работал над нативными Android-приложениями, меня спасал плагин для Android Studio ADB Idea. Очень удобно через шорткат можно было вызвать окошко с основными ADB командами по типу "Удалить приложение", "Принудительно завершить приложение", "Перезапустить приложение" и т.п.
Очень я привязался к этому плагину. И когда жизнь занесла меня в кроссплатформенную мобильную разработку, а конкретно 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 '';
};
Собственно, пока все. Расширение работает и мои задачи в полной мере решает. Так как оно может решать и чьи-то еще задачи, я его опубликовал:
VS Code Marketplace. Отсюда можно устанавливать.
Github. Сюда можно ходить с багами и Pull Request-ами.
В качестве заключения выводы:
-
Писать расширения проще, чем кажется, во всяком случае, для VS Code.
-
FacebookMeta пока разработчикам не выдает маки на Apple Silicon, раз установку IDB за год не починили. -
TypeScript волне себе неплох, даже немного жаль, что Flutter решил жить с Dart.
Комментариев нет:
Отправить комментарий