11import * as _ from 'lodash' ;
2+ import * as fs from 'fs' ;
23import * as os from 'os' ;
4+ import * as path from 'path' ;
35import * as crypto from 'crypto' ;
6+ import * as stream from 'stream' ;
47import * as forge from 'node-forge' ;
8+ import * as semver from 'semver' ;
9+ import fetch from 'node-fetch' ;
510
611import { Interceptor } from '..' ;
712import { HtkConfig } from '../../config' ;
@@ -16,6 +21,7 @@ import {
1621 stringAsStream
1722} from './adb-commands' ;
1823import { reportError } from '../../error-tracking' ;
24+ import { readDir , createTmp , renameFile } from '../../util' ;
1925
2026function 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 ( / ^ h t t p t o o l k i t - ( .* ) .a p k $ / ) )
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+
42162export 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:
0 commit comments