1- import { resolve , join } from "path" ;
1+ import { join } from "path" ;
22import * as _ from "lodash" ;
33import { hasValidAndroidSigning } from "../../common/helpers" ;
4- import { IChildProcess , ISysInfo , IErrors } from "../../common/declarations" ;
4+ import {
5+ IChildProcess ,
6+ IErrors ,
7+ IFileSystem ,
8+ ISettingsService ,
9+ ISysInfo ,
10+ Server ,
11+ } from "../../common/declarations" ;
512import {
613 IAndroidBundleToolService ,
714 IBuildApksOptions ,
815 IInstallApksOptions ,
916} from "../../definitions/android-bundle-tool-service" ;
17+ import { ITerminalSpinnerService } from "../../definitions/terminal-spinner-service" ;
18+ import {
19+ BUNDLETOOL_CACHE_DIRNAME ,
20+ BUNDLETOOL_PATH_ENV_VAR ,
21+ BUNDLETOOL_RELEASES_URL ,
22+ BUNDLETOOL_SHA256 ,
23+ BUNDLETOOL_VERSION ,
24+ } from "../../constants" ;
1025import { injector } from "../../common/yok" ;
1126
1227export class AndroidBundleToolService implements IAndroidBundleToolService {
28+ // a cold download of ~32MB can outlast the default 10s lock, and a second
29+ // process has to keep waiting rather than pull the same jar again
30+ private static LOCK_OPTIONS : ILockOptions = {
31+ stale : 5 * 60 * 1000 ,
32+ retriesObj : {
33+ retries : 300 ,
34+ minTimeout : 200 ,
35+ maxTimeout : 2000 ,
36+ factor : 1.5 ,
37+ } ,
38+ } ;
39+
1340 private javaPath : string ;
14- private aabToolPath : string ;
41+ private bundleToolPathPromise : Promise < string > ;
42+
1543 constructor (
1644 private $childProcess : IChildProcess ,
1745 private $sysInfo : ISysInfo ,
18- private $errors : IErrors
19- ) {
20- this . aabToolPath = resolve (
21- join ( __dirname , "../../../vendor/aab-tool/bundletool.jar" )
22- ) ;
23- }
46+ private $errors : IErrors ,
47+ private $fs : IFileSystem ,
48+ private $httpClient : Server . IHttpClient ,
49+ private $lockService : ILockService ,
50+ private $logger : ILogger ,
51+ private $settingsService : ISettingsService ,
52+ private $terminalSpinnerService : ITerminalSpinnerService ,
53+ ) { }
2454
2555 public async buildApks ( options : IBuildApksOptions ) : Promise < void > {
2656 if ( ! hasValidAndroidSigning ( options . signingData ) ) {
2757 this . $errors . fail (
28- `Unable to build "apks" without a full signing information.`
58+ `Unable to build "apks" without a full signing information.` ,
2959 ) ;
3060 }
3161
@@ -46,7 +76,7 @@ export class AndroidBundleToolService implements IAndroidBundleToolService {
4676 ] ) ;
4777 if ( aabToolResult . exitCode !== 0 && aabToolResult . stderr ) {
4878 this . $errors . fail (
49- `Unable to build "apks" from the provided "aab". Error: ${ aabToolResult . stderr } `
79+ `Unable to build "apks" from the provided "aab". Error: ${ aabToolResult . stderr } ` ,
5080 ) ;
5181 }
5282 }
@@ -61,18 +91,18 @@ export class AndroidBundleToolService implements IAndroidBundleToolService {
6191 ] ) ;
6292 if ( aabToolResult . exitCode !== 0 && aabToolResult . stderr ) {
6393 this . $errors . fail (
64- `Unable to install "apks" on device "${ options . deviceId } ". Error: ${ aabToolResult . stderr } `
94+ `Unable to install "apks" on device "${ options . deviceId } ". Error: ${ aabToolResult . stderr } ` ,
6595 ) ;
6696 }
6797 }
6898
6999 private async execBundleTool ( args : string [ ] ) {
70100 const javaPath = await this . getJavaPath ( ) ;
71- const defaultArgs = [ "-jar" , this . aabToolPath ] ;
101+ const defaultArgs = [ "-jar" , await this . getBundleToolPath ( ) ] ;
72102
73103 const result = await this . $childProcess . trySpawnFromCloseEvent (
74104 javaPath ,
75- _ . concat ( defaultArgs , args )
105+ _ . concat ( defaultArgs , args ) ,
76106 ) ;
77107
78108 return result ;
@@ -85,6 +115,130 @@ export class AndroidBundleToolService implements IAndroidBundleToolService {
85115
86116 return this . javaPath ;
87117 }
118+
119+ private getBundleToolPath ( ) : Promise < string > {
120+ this . bundleToolPathPromise =
121+ this . bundleToolPathPromise || this . resolveBundleToolPath ( ) ;
122+
123+ return this . bundleToolPathPromise ;
124+ }
125+
126+ private async resolveBundleToolPath ( ) : Promise < string > {
127+ const customPath = process . env [ BUNDLETOOL_PATH_ENV_VAR ] ;
128+ if ( customPath ) {
129+ if ( ! this . $fs . exists ( customPath ) ) {
130+ this . $errors . fail (
131+ `${ BUNDLETOOL_PATH_ENV_VAR } is set to "${ customPath } ", but no file exists there.` ,
132+ ) ;
133+ }
134+
135+ this . $logger . trace (
136+ `Using bundletool from ${ BUNDLETOOL_PATH_ENV_VAR } : ${ customPath } ` ,
137+ ) ;
138+ return customPath ;
139+ }
140+
141+ const cacheDir = join (
142+ this . $settingsService . getProfileDir ( ) ,
143+ BUNDLETOOL_CACHE_DIRNAME ,
144+ ) ;
145+ const jarPath = join ( cacheDir , `bundletool-all-${ BUNDLETOOL_VERSION } .jar` ) ;
146+ this . $fs . ensureDirectoryExists ( cacheDir ) ;
147+
148+ if ( await this . isExpectedJar ( jarPath ) ) {
149+ return jarPath ;
150+ }
151+
152+ return this . $lockService . executeActionWithLock (
153+ async ( ) => {
154+ // whoever held the lock before us may have just downloaded it
155+ if ( await this . isExpectedJar ( jarPath ) ) {
156+ return jarPath ;
157+ }
158+
159+ await this . downloadBundleTool ( jarPath ) ;
160+ return jarPath ;
161+ } ,
162+ join ( cacheDir , `bundletool-${ BUNDLETOOL_VERSION } .lock` ) ,
163+ AndroidBundleToolService . LOCK_OPTIONS ,
164+ ) ;
165+ }
166+
167+ private async isExpectedJar ( jarPath : string ) : Promise < boolean > {
168+ if ( ! this . $fs . exists ( jarPath ) ) {
169+ return false ;
170+ }
171+
172+ const shasum = await this . $fs . getFileShasum ( jarPath , {
173+ algorithm : "sha256" ,
174+ } ) ;
175+ if ( shasum === BUNDLETOOL_SHA256 ) {
176+ return true ;
177+ }
178+
179+ this . $logger . warn (
180+ `Cached bundletool at "${ jarPath } " does not match the expected checksum and will be downloaded again.` ,
181+ ) ;
182+ this . $fs . deleteFile ( jarPath ) ;
183+ return false ;
184+ }
185+
186+ private async downloadBundleTool ( jarPath : string ) : Promise < void > {
187+ const jarName = `bundletool-all-${ BUNDLETOOL_VERSION } .jar` ;
188+ const url = `${ BUNDLETOOL_RELEASES_URL } /${ BUNDLETOOL_VERSION } /${ jarName } ` ;
189+ const tempPath = `${ jarPath } .download` ;
190+ const spinner = this . $terminalSpinnerService . createSpinner ( ) ;
191+
192+ spinner . start ( `Downloading bundletool ${ BUNDLETOOL_VERSION } ` ) ;
193+
194+ try {
195+ await this . $httpClient . httpRequest ( {
196+ url,
197+ method : "GET" ,
198+ // identity keeps Content-Length equal to the real jar size, so the
199+ // progress readout is not skewed by a re-compressed response
200+ headers : { "Accept-Encoding" : "identity" } ,
201+ pipeTo : this . $fs . createWriteStream ( tempPath ) ,
202+ onDownloadProgress : ( progress : { loaded : number ; total ?: number } ) => {
203+ spinner . text = `Downloading bundletool ${ BUNDLETOOL_VERSION } ${ this . formatProgress (
204+ progress ,
205+ ) } `;
206+ } ,
207+ } ) ;
208+ } catch ( err ) {
209+ spinner . fail ( `Failed to download bundletool ${ BUNDLETOOL_VERSION } ` ) ;
210+ this . $fs . deleteFile ( tempPath ) ;
211+ this . $errors . fail (
212+ `Unable to download bundletool from "${ url } ". ` +
213+ `Set ${ BUNDLETOOL_PATH_ENV_VAR } to the path of a local bundletool jar to skip the download. ` +
214+ `Error: ${ err . message } ` ,
215+ ) ;
216+ }
217+
218+ const shasum = await this . $fs . getFileShasum ( tempPath , {
219+ algorithm : "sha256" ,
220+ } ) ;
221+ if ( shasum !== BUNDLETOOL_SHA256 ) {
222+ spinner . fail ( `Failed to download bundletool ${ BUNDLETOOL_VERSION } ` ) ;
223+ this . $fs . deleteFile ( tempPath ) ;
224+ this . $errors . fail (
225+ `Checksum mismatch for bundletool downloaded from "${ url } ". ` +
226+ `Expected ${ BUNDLETOOL_SHA256 } , got ${ shasum } .` ,
227+ ) ;
228+ }
229+
230+ // rename is atomic, so a concurrent reader never sees a partial jar
231+ this . $fs . rename ( tempPath , jarPath ) ;
232+ spinner . succeed ( `Downloaded bundletool ${ BUNDLETOOL_VERSION } ` ) ;
233+ }
234+
235+ private formatProgress ( progress : { loaded : number ; total ?: number } ) : string {
236+ const toMb = ( bytes : number ) => ( bytes / 1024 / 1024 ) . toFixed ( 1 ) ;
237+
238+ return progress . total
239+ ? `${ toMb ( progress . loaded ) } /${ toMb ( progress . total ) } MB`
240+ : `${ toMb ( progress . loaded ) } MB` ;
241+ }
88242}
89243
90244injector . register ( "androidBundleToolService" , AndroidBundleToolService ) ;
0 commit comments