Skip to content

Commit 00d28fc

Browse files
committed
Automatically download & install the app via ADB, if not present
1 parent 624c321 commit 00d28fc

File tree

4 files changed

+147
-17
lines changed

4 files changed

+147
-17
lines changed

package-lock.json

Lines changed: 5 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
"@sentry/integrations": "^5.10.2",
3636
"@sentry/node": "^5.10.2",
3737
"@types/command-exists": "^1.2.0",
38+
"@types/node-fetch": "^2.5.4",
39+
"@types/tmp": "0.0.33",
3840
"adbkit": "^2.11.1",
3941
"async-mutex": "^0.1.3",
4042
"chrome-remote-interface": "^0.28.0",
@@ -47,11 +49,13 @@
4749
"graphql-yoga": "^1.18.1",
4850
"lodash": "^4.17.13",
4951
"mockttp": "^0.19.2",
52+
"node-fetch": "^2.6.0",
5053
"node-forge": "^0.9.0",
5154
"node-gsettings-wrapper": "^0.5.0",
5255
"portfinder": "^1.0.25",
5356
"registry-js": "^1.4.0",
5457
"rimraf": "^2.6.2",
58+
"tmp": "0.0.33",
5559
"tslib": "^1.9.3",
5660
"win-version-info": "^3.0.1"
5761
},
@@ -66,10 +70,8 @@
6670
"@types/lodash": "^4.14.117",
6771
"@types/mocha": "^5.2.5",
6872
"@types/node": "^12.7.9",
69-
"@types/node-fetch": "^2.5.3",
7073
"@types/request-promise-native": "^1.0.15",
7174
"@types/rimraf": "^2.0.2",
72-
"@types/tmp": "0.0.33",
7375
"@types/ws": "^6.0.1",
7476
"axios": "^0.19.0",
7577
"bent": "^1.5.13",
@@ -81,15 +83,13 @@
8183
"mocha": "^5.2.0",
8284
"needle": "^2.4.0",
8385
"node-dev": "^3.1.3",
84-
"node-fetch": "^2.6.0",
8586
"node-noop": "^1.0.0",
8687
"request": "^2.88.0",
8788
"request-promise-native": "^1.0.5",
8889
"reqwest": "^2.0.5",
8990
"stripe": "^7.4.0",
9091
"superagent": "^5.1.0",
9192
"tarball-extract": "0.0.6",
92-
"tmp": "0.0.33",
9393
"ts-loader": "^6.0.0",
9494
"ts-node": "^8.4.1",
9595
"typescript": "^3.6.3",

src/interceptors/android/android-adb-interceptor.ts

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import * as _ from 'lodash';
2+
import * as fs from 'fs';
23
import * as os from 'os';
4+
import * as path from 'path';
35
import * as crypto from 'crypto';
6+
import * as stream from 'stream';
47
import * as forge from 'node-forge';
8+
import * as semver from 'semver';
9+
import fetch from 'node-fetch';
510

611
import { Interceptor } from '..';
712
import { HtkConfig } from '../../config';
@@ -16,6 +21,7 @@ import {
1621
stringAsStream
1722
} from './adb-commands';
1823
import { reportError } from '../../error-tracking';
24+
import { readDir, createTmp, renameFile } from '../../util';
1925

2026
function urlSafeBase64(content: string) {
2127
return Buffer.from(content, 'utf8').toString('base64')
@@ -39,6 +45,120 @@ function getCertificateHash(cert: forge.pki.Certificate) {
3945
.toString(16);
4046
}
4147

48+
async function getLatestRelease(): Promise<{ version: string, url: string } | undefined> {
49+
try {
50+
const response = await fetch(
51+
"https://api.github.com/repos/httptoolkit/httptoolkit-android/releases/latest"
52+
);
53+
const release = await response.json();
54+
const apkAsset = release.assets.filter((a: any) => a.name === "httptoolkit.apk")[0];
55+
56+
// Ignore non-semver releases
57+
if (!semver.valid(release.name)) return;
58+
59+
return {
60+
version: release.name,
61+
url: apkAsset.browser_download_url
62+
};
63+
} catch (e) {
64+
console.log("Could not check latest Android app release", e);
65+
}
66+
}
67+
68+
async function getLocalApk(config: HtkConfig) {
69+
try {
70+
const apks = (await readDir(config.configPath))
71+
.map(filename => filename.match(/^httptoolkit-(.*).apk$/))
72+
.filter((match): match is RegExpMatchArray => !!match)
73+
.map((match) => ({
74+
path: path.join(config.configPath, match[0]),
75+
version: match[1]
76+
}));
77+
78+
apks.sort((apk1, apk2) => {
79+
return -1 * semver.compare(apk1.version, apk2.version);
80+
});
81+
82+
const latestLocalApk = apks[0];
83+
if (!latestLocalApk) return;
84+
else return latestLocalApk;
85+
} catch (e) {
86+
console.log("Could not check for local Android app APK", e);
87+
reportError(e);
88+
}
89+
}
90+
91+
async function updateLocalApk(
92+
version: string,
93+
apkStream: stream.Readable | NodeJS.ReadableStream,
94+
config: HtkConfig
95+
) {
96+
console.log(`Updating local APK to version ${version}`);
97+
const {
98+
path: tmpApk,
99+
fd: tmpApkFd,
100+
cleanupCallback
101+
} = await createTmp();
102+
103+
const tmpApkStream = fs.createWriteStream(tmpApk, {
104+
fd: tmpApkFd,
105+
encoding: 'binary'
106+
});
107+
apkStream.pipe(tmpApkStream);
108+
109+
await new Promise((resolve, reject) => {
110+
apkStream.on('error', (e) => {
111+
reject(e);
112+
tmpApkStream.close();
113+
cleanupCallback();
114+
});
115+
tmpApkStream.on('error', (e) => {
116+
reject(e);
117+
cleanupCallback();
118+
});
119+
tmpApkStream.on('finish', () => resolve());
120+
});
121+
122+
console.log(`Local APK written to ${tmpApk}`);
123+
124+
await renameFile(tmpApk, path.join(config.configPath, `httptoolkit-${version}.apk`));
125+
console.log(`Local APK moved to ${path.join(config.configPath, `httptoolkit-${version}.apk`)}`);
126+
}
127+
128+
async function streamLatestApk(config: HtkConfig): Promise<stream.Readable> {
129+
const [latestApkRelease, localApk] = await Promise.all([
130+
await getLatestRelease(),
131+
await getLocalApk(config)
132+
]);
133+
134+
if (!localApk) {
135+
if (!latestApkRelease) {
136+
throw new Error("Couldn't find an Android APK locally or remotely");
137+
} else {
138+
console.log('Streaming remote APK directly');
139+
const apkStream = (await fetch(latestApkRelease.url)).body;
140+
updateLocalApk(latestApkRelease.version, apkStream, config);
141+
return apkStream as stream.Readable;
142+
}
143+
}
144+
145+
if (!latestApkRelease || semver.gte(localApk.version, latestApkRelease.version, true)) {
146+
console.log('Streaming local APK');
147+
// If we have an APK locally and it's up to date, or we can't tell, just use it
148+
return fs.createReadStream(localApk.path, { encoding: 'binary' });
149+
}
150+
151+
// We have a local APK & a remote APK, and the remote is newer.
152+
// Try to update it async, and use the local APK in the meantime.
153+
fetch(latestApkRelease.url).then((apkResponse) => {
154+
const apkStream = apkResponse.body;
155+
updateLocalApk(latestApkRelease.version, apkStream, config);
156+
}).catch(reportError);
157+
158+
console.log('Streaming local APK, and updating it async');
159+
return fs.createReadStream(localApk.path, { encoding: 'binary' });
160+
}
161+
42162
export class AndroidAdbInterceptor implements Interceptor {
43163
readonly id = 'android-adb';
44164
readonly version = '1.0.0';
@@ -72,9 +192,11 @@ export class AndroidAdbInterceptor implements Interceptor {
72192
}): Promise<void | {}> {
73193
await this.injectSystemCertIfPossible(options.deviceId, this.config.https.certContent);
74194

75-
// Is the app already present? If not, install it
76-
if (!await this.adbClient.isInstalled(options.deviceId, 'tech.httptoolkit.android')) {
77-
throw new Error("App must be installed before automatic ADB setup can be used");
195+
if (!(await this.adbClient.isInstalled(options.deviceId, 'tech.httptoolkit.android'))) {
196+
console.log("App not installed, installing...");
197+
let stream = await streamLatestApk(this.config);
198+
await this.adbClient.install(options.deviceId, stream);
199+
console.log("App installed successfully");
78200
}
79201

80202
// Build a trigger URL to activate the proxy on the device:

src/util.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { promisify } from 'util';
22
import * as fs from 'fs';
3+
import * as tmp from 'tmp';
34
import * as rimraf from 'rimraf';
45
import { spawn } from 'child_process';
56

@@ -63,4 +64,15 @@ export const canAccess = (path: string) => checkAccess(path).then(() => true).ca
6364
export const deleteFolder = promisify(rimraf);
6465

6566
export const ensureDirectoryExists = (path: string) =>
66-
checkAccess(path).catch(() => mkDir(path, { recursive: true }));
67+
checkAccess(path).catch(() => mkDir(path, { recursive: true }));
68+
69+
export const createTmp = () => new Promise<{
70+
path: string,
71+
fd: number,
72+
cleanupCallback: () => void
73+
}>((resolve, reject) => {
74+
tmp.file((err, path, fd, cleanupCallback) => {
75+
if (err) return reject(err);
76+
resolve({ path, fd, cleanupCallback });
77+
});
78+
});

0 commit comments

Comments
 (0)