From 5965ef39fde4bfee3b5c3bf27862f8705817fd9f Mon Sep 17 00:00:00 2001 From: bootstraponline Date: Wed, 9 Oct 2013 10:37:47 -0400 Subject: [PATCH] Android mobile :reset now uses adb uninstall/pm install The old clean.apk approach to resetting state doesn't work well. Using instrumentation means the app is running when the data is deleted. It's better to use the standard adb install/uninstall commands. To make this faster, the apk is pushed to the device once (/data/local/tmp/) and then installed from that location on each reset. Using the regular adb install command is slow due to the file transfer speed. Before uninstalling the app, it's stopped to prevent crashes. Some apps don't like to be uninstalled while running. --- android/adb.js | 267 ++++++++++++++++------------ app/android/AndroidManifest.xml.src | 20 --- app/android/Clean.apk | Bin 3875 -> 0 bytes app/android/ScreenShooter.jar | Bin 7430 -> 0 bytes package.json | 1 + 5 files changed, 155 insertions(+), 133 deletions(-) delete mode 100644 app/android/AndroidManifest.xml.src delete mode 100644 app/android/Clean.apk delete mode 100644 app/android/ScreenShooter.jar diff --git a/android/adb.js b/android/adb.js index a90cb91e5..24c9171aa 100644 --- a/android/adb.js +++ b/android/adb.js @@ -19,6 +19,7 @@ var spawn = require('win-spawn') , rimraf = require('rimraf') , Logcat = require('./logcat') , isWindows = helpers.isWindows() + , md5 = require('MD5') , deviceState = require('./device_state'); var noop = function() {}; @@ -64,7 +65,6 @@ var ADB = function(opts, android) { this.emulatorPort = null; this.debugMode = true; this.logcat = null; - this.cleanAPK = path.resolve(helpers.getTempPath(), this.appPackage + '.clean.apk'); // This is set to true when the bootstrap jar crashes. this.restartBootstrap = false; // The android ref is used to resend the command that @@ -73,6 +73,7 @@ var ADB = function(opts, android) { this.cmdCb = null; this.binaries = {}; this.resendLastCommand = function() {}; + this.appMD5 = null; }; ADB.prototype.checkSdkBinaryPresent = function(binary, cb) { @@ -152,38 +153,6 @@ ADB.prototype.checkAppPresent = function(cb) { } }; -// Fast reset -ADB.prototype.buildFastReset = function(skipAppSign, cb) { - logger.info("Building fast reset"); - // Create manifest - var targetAPK = this.apkPath - , cleanAPKSrc = path.resolve(__dirname, '..', 'app', 'android', 'Clean.apk') - , newPackage = this.appPackage + '.clean' - , srcManifest = path.resolve(__dirname, '..', 'app', 'android', - 'AndroidManifest.xml.src') - , dstManifest = path.resolve(getTempPath(), 'AndroidManifest.xml'); - - fs.writeFileSync(dstManifest, fs.readFileSync(srcManifest, "utf8"), "utf8"); - var resignApks = function(cb) { - // Resign clean apk and target apk - var apks = [ this.cleanAPK ]; - if (!skipAppSign) { - logger.debug("Signing app and clean apk."); - apks.push(targetAPK); - } else { - logger.debug("Skip app sign. Sign clean apk."); - } - this.sign(apks, cb); - }.bind(this); - - async.series([ - function(cb) { this.checkSdkBinaryPresent("aapt", cb); }.bind(this), - function(cb) { this.compileManifest(dstManifest, newPackage, this.appPackage, cb); }.bind(this), - function(cb) { this.insertManifest(dstManifest, cleanAPKSrc, this.cleanAPK, cb); }.bind(this), - function(cb) { resignApks(cb); }.bind(this) - ], cb); -}; - ADB.prototype.insertSelendroidManifest = function(serverPath, cb) { logger.info("Inserting selendroid manifest"); var newServerPath = this.selendroidServerPath @@ -463,41 +432,6 @@ ADB.prototype.checkApkCert = function(apk, cb) { }); }; -ADB.prototype.checkFastReset = function(cb) { - logger.info("Checking whether we need to run fast reset"); - // NOP if fast reset is not true. - if (!this.fastReset) { - logger.info("User doesn't want fast reset, doing nothing"); - return cb(null); - } - - if (this.apkPath === null) { - logger.info("Can't run fast reset on an app that's already on the device " + - "so doing nothing"); - return cb(null); - } - - if (!this.appPackage) return cb(new Error("appPackage must be set.")); - - this.checkApkCert(this.cleanAPK, function(cleanSigned){ - this.checkApkCert(this.apkPath, function(appSigned){ - logger.debug("App signed? " + appSigned + " " + this.apkPath); - // Only build & resign clean.apk if it doesn't exist or isn't signed. - if (!fs.existsSync(this.cleanAPK) || !cleanSigned) { - this.buildFastReset(appSigned, function(err){ if (err) return cb(err); cb(null); }); - } else { - if (!appSigned) { - // Resign app apk because it's not signed. - this.sign([this.apkPath], cb); - } else { - // App and clean are already existing and signed. - cb(null); - } - } - }.bind(this)); - }.bind(this)); -}; - ADB.prototype.getDeviceWithRetry = function(cb, count) { logger.info("Trying to find a connected android device"); var error = new Error("Could not find a connected Android device."); @@ -536,8 +470,7 @@ ADB.prototype.prepareDevice = function(onReady) { function(cb) { this.prepareEmulator(cb); }.bind(this), function(cb) { this.getDeviceWithRetry(cb);}.bind(this), function(cb) { this.waitForDevice(cb); }.bind(this), - function(cb) { this.startLogcat(cb); }.bind(this), - function(cb) { this.checkFastReset(cb); }.bind(this) + function(cb) { this.startLogcat(cb); }.bind(this) ], onReady); }; @@ -1411,6 +1344,114 @@ ADB.prototype.installApk = function(apk, cb) { }); }; +ADB.prototype.removeOldApks = function(cb) { + var listApks = function(cb) { + var cmd = this.adbCmd + ' shell "ls /data/local/tmp/*.apk"'; + logger.info("listApks: " + cmd); + exec(cmd, { maxBuffer: 524288 }, function(err, stdout) { + if (err || stdout.indexOf("No such file") !== -1) { + return cb(null, []); + } + + var apks = stdout.split("\n"); + cb(null, apks); + }); + }.bind(this); + + var removeOtherApks = function(apks, cb) { + var matchingApkFound = false; + var removeString = ""; + _.each(apks, function(path) { + path = path.trim(); + if (path.indexOf(this.appMD5) === -1 && path !== '') { + removeString += ' rm \\"' + path + '\\";'; + logger.info("removeOtherApks pushing: " + removeString); + } else { + matchingApkFound = true; + } + }.bind(this)); + + // Invoking adb shell with an empty string will open a shell console + // so return here if there's nothing to remove. + if (removeString === '') { + return cb(null, matchingApkFound); + } + + var cmd = this.adbCmd + ' shell "' + removeString + '"'; + logger.info("removeOtherApks: " + cmd); + exec(cmd, { maxBuffer: 524288 }, function(err, stdout) { + if (stdout) logger.info(stdout); + if (err) logger.info(err); + cb(null, matchingApkFound); + }); + }.bind(this); + + async.waterfall([ + function(cb){ listApks(cb); }, + function(apks, cb) { removeOtherApks(apks, cb); } + ], function(err, matchingApkFound) { cb(null, matchingApkFound); }); +}; + +// This is only invoked after checking if the app is installed. +// installAppApk will always install the apk. +ADB.prototype.installAppApk = function(apk, cb) { + var getMD5 = function(cb) { + fs.readFile(apk, function(err, buffer) { + this.appMD5 = md5(buffer); + cb(null); + }.bind(this)); + }.bind(this); + + var adbMakeFolder = function(cb) { + var cmd = this.adbCmd + ' shell "mkdir /data/local/tmp/"'; + logger.info("adbMakeFolder: " + cmd); + exec(cmd, { maxBuffer: 524288 }, function(err, stdout) { + cb(null); + }); + }.bind(this); + + var adbPush = function(cb) { + var cmd = this.adbCmd + ' push "' + apk + '" "/data/local/tmp/' + this.appMD5 + '.apk"'; + logger.info("adbPush: " + cmd); + exec(cmd, { maxBuffer: 524288 }, function(err, stdout) { + if (err) { + logger.error(err); + cb(err); + } else { + logger.debug(stdout); + cb(null); + } + }); + }.bind(this); + + var adbInstall = function(cb) { + var cmd = this.adbCmd + ' shell "pm install -r /data/local/tmp/' + this.appMD5 + '.apk"'; + logger.info("adbInstall: " + cmd); + exec(cmd, { maxBuffer: 524288 }, function(err, stdout) { + if (err) { + logger.error(err); + cb(err); + } else { + logger.debug(stdout); + cb(null); + } + }); + }.bind(this); + + async.waterfall([ + function(cb) { adbMakeFolder(cb); }, + function(cb) { getMD5(cb); }, + function(cb) { this.removeOldApks(cb); }.bind(this), + // If the apk is already on device, then don't push it again. + function(matchingApkFound, cb) { + if (matchingApkFound) { cb(null); } else { adbPush(cb); } + }, + function(cb) { + adbInstall(cb); + } + ], cb); +}; + ADB.prototype.uninstallApp = function(cb) { var next = function() { this.requireDeviceId(); @@ -1442,41 +1483,53 @@ ADB.prototype.uninstallApp = function(cb) { }; ADB.prototype.runFastReset = function(cb) { - // list instruments with: adb shell pm list instrumentation - // targetPackage + '.clean' / clean.apk.Clear - var clearCmd = this.adbCmd + ' shell am instrument ' + this.appPackage + '.clean/clean.apk.Clean'; - logger.debug("Running fast reset clean: " + clearCmd); - exec(clearCmd, { maxBuffer: 524288 }, function(err, stdout, stderr) { - if (err) { - logger.warn(stderr); - cb(err); - } else { + var stopApp = function(cb) { + var cmd = this.adbCmd + ' shell am force-stop ' + this.appPackage; + logger.info("stopApp: " + cmd); + exec(cmd, { maxBuffer: 524288 }, function(err, stdout) { cb(null); - } - }); + }); + }.bind(this); + + var uninstallApp = function(cb) { + var cmd = this.adbCmd + ' uninstall ' + this.appPackage; + logger.info("uninstallApp: " + cmd); + exec(cmd, { maxBuffer: 524288 }, function(err, stdout) { + cb(null); + }); + }.bind(this); + + var installApp = function(cb) { + var cmd = this.adbCmd + ' shell pm install /data/local/tmp/' + this.appMD5 + '.apk'; + logger.info("installApp: " + cmd); + exec(cmd, { maxBuffer: 524288 }, function(err, stdout) { + cb(null); + }); + }.bind(this); + + async.series([ + function(cb) { stopApp(cb); }, + function(cb) { uninstallApp(cb); }, + function(cb) { installApp(cb); } + ], cb); }; ADB.prototype.checkAppInstallStatus = function(pkg, cb) { - var installed = false - , cleanInstalled = false; + var installed = false; this.requireDeviceId(); - logger.debug("Getting install/clean status for " + pkg); + logger.debug("Getting install status for " + pkg); var listPkgCmd = this.adbCmd + " shell pm list packages -3 " + pkg; exec(listPkgCmd, { maxBuffer: 524288 }, function(err, stdout) { var apkInstalledRgx = new RegExp('^package:' + pkg.replace(/([^a-zA-Z])/g, "\\$1") + '$', 'm'); installed = apkInstalledRgx.test(stdout); - var cleanInstalledRgx = new RegExp('^package:' + - (pkg + '.clean').replace(/([^a-zA-Z])/g, "\\$1") + '$', 'm'); - cleanInstalled = cleanInstalledRgx.test(stdout); - cb(null, installed, cleanInstalled); + cb(null, installed); }); }; ADB.prototype.installApp = function(cb) { - var installApp = false - , installClean = false; + var installApp = false; this.requireDeviceId(); if (this.apkPath === null) { @@ -1487,11 +1540,15 @@ ADB.prototype.installApp = function(cb) { this.requireApk(); - var determineInstallAndCleanStatus = function(cb) { - logger.info("Determining app install/clean status"); - this.checkAppInstallStatus(this.appPackage, function(err, installed, cleaned) { + var determineInstallStatus = function(cb) { + if (this.appMD5 === null) { + installApp = true; + return cb(); + } + + logger.info("Determining app install"); + this.checkAppInstallStatus(this.appPackage, function(err, installed) { installApp = !installed; - installClean = !cleaned; cb(); }); }.bind(this); @@ -1499,29 +1556,13 @@ ADB.prototype.installApp = function(cb) { var doInstall = function(cb) { if (installApp) { this.debug("Installing app apk"); - this.installApk(this.apkPath, cb); - } else { cb(null); } - }.bind(this); - - var doClean = function(cb) { - if (installClean && this.cleanApp) { - this.debug("Installing clean apk"); - this.installApk(this.cleanAPK, cb); - } else { cb(null); } - }.bind(this); - - var doFastReset = function(cb) { - // App is already installed so reset it. - if (!installApp && this.fastReset) { - this.runFastReset(cb); + this.installAppApk(this.apkPath, cb); } else { cb(null); } }.bind(this); async.series([ - function(cb) { determineInstallAndCleanStatus(cb); }, - function(cb) { doInstall(cb); }, - function(cb) { doClean(cb); }, - function(cb) { doFastReset(cb); } + function(cb) { determineInstallStatus(cb); }, + function(cb) { doInstall(cb); } ], cb); }; diff --git a/app/android/AndroidManifest.xml.src b/app/android/AndroidManifest.xml.src deleted file mode 100644 index 058ef1bc3..000000000 --- a/app/android/AndroidManifest.xml.src +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/android/Clean.apk b/app/android/Clean.apk deleted file mode 100644 index 63f2221a67c2e306a67536e0aa32b557ff08ad2b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3875 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 D;-=el diff --git a/app/android/ScreenShooter.jar b/app/android/ScreenShooter.jar deleted file mode 100644 index 63c16475db5cf736e036a65881f17adabe23e7fe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7430 zcma)B2UJtp+6`TLON1ar6qKQNq)6|fhtPWnO-O*ydzFq9=~bk6kS3rAh)7X-QJMsh z-UNp#{`mYI-^@7gy_0p5m9@UT?>XPS=bUe!tp&lsB?17h003H=nX-U?YlHw?fVzUN z43~M|NCiV9F&ZgoZXIKF$`yudKe z;ET{#*2BpRK+5)~8T_Q8Z&NNZP=z4d>HzxvB~OKYn?FjxNd7{##0*pLJO02es`!1|9E8EY$LgrhUUl^gD8 ziALw>+xt>af$zev#j%jn0&%cpIZLoO-=$)!M20dL!DT2?;;vCgI_dBy;d`&yDGtWc z=R-0DomvYaNyy=6>X1yNbd-~9)=q=^k>;1tp)X+dp&al4e=0-nvA5gN(cM+k9p7Hm z#qMVjz}Cx-g6TLv+@r@73Y10osI5Hm) zPir<{ME&YPd0iz62Vn3J@%~W%u?djS&&>H)7Rd8KfzQVtblzbFG=G_9?I$YRV@|kK zp`G1}2R2XMcLy&d3|C$~pz>HI(i5%9RG1l!S+}ZlTst|;mwID&;$WXwMFTPF2pbMU zZtR#5d9cQ-+JWn%GsBv)kq$Q}T+N<5DD@8IsLO_F#XT;sit(14h;$HM%Gnqq(qJ~1?_%I! zV7)fMz5VbyqEAY?P7TV@f?WbvpvR&_R!ZwBEOZlk{8Y=FspSMB={F+k3A`j2!1FG$l&JxfO20efo6g%GHraoT9w7NyJAw zesjs?JB+oCfhv&+T(F+a%E3%)2Yn7VR9tQvlitMg{#rV$k@!89>GD*j5q?n^HlFWr zO+Gn!|8acCF2oV1*=jfxd7mv!ytMyDPI1AGIH*K_=5hmJuXp@xv%nz2RcpcWNILeIG5{`+9&G%DjXg;ViF{tE!88fyw*`< zPm7-2II^m{!(Q{xOGUC`F|qr{xS6rBx?&QI_dcSgE;c z=cKNF)&jkqmeDP$=5uaG^ZqI3AR94gY7cJ_TCa>{izO=g>fCT?QX;HkXB=iJ$xlsX zZHzXVxb^vpP>Q=AJrx8G)FkKn$1rwUsJN@Q=dwei%l&N)5`DjsEeOLuHk9Awi*`N1_?oGLQj!J(ss}T57p+cf3LB0+A-t zSyIuA)W&JQ&4xux-=ajck#X7GR3@nyiC3Qvs+x{$yR*@0GaJEGSO@a;*4Yo@e}oWn+g=XS~4Ic^~_1eqZ0zdSzQD)?|b4G&fbNhY=sY zYP>pRb+IKZEMQj>EF$M57o9iYi=$Oeod1$7^b4aO9l;*2Cge_}BfuQfUd9;kMLcBp zmR}7)N2h)>u36Wk6&aECo5MM{&yJj=PbR2>E+Telaz$xo?nKO>=S23jj!1-adUt~G zY@PIL4K0xSeCUJo9g0{732sdA^rYyl=qwtiZ*z@yH>$^WL^QN56?CPiX}OhW?qNV1 zrBzc6Jjq?4kzcJ$&RVRCy4N0-(v)qPIoNwhQN{4Fy7th-!u|?38Cu;yEc(br)8JG> zv$=M6>5bQv(B7GQ%PL)y$~l3JIKd2SfZG=B`VOhdtUF`BJIgCy+r)Oa{i;9+u>?Ff zH^Q5cED4<&i)PGYnQ9*0d@wxlrlE)ceh0(1S<#bE{=jE2-Y1G@kv8!BnDEa0t_!}= zm$P?wBXaSk6PksLJkdrtjnwlQDi`rn$PbWCQdL3n<9;8W{KpJsqO%8;ET-}Ud#Q~* zX$z;jpsnaR@z67F^WyxF&n-D%T_$qN&d0N&ifv3w7c82wPiWiozN+Sa*!ICTWgExT zMSWSCrPjNR?V~Q?9&$ki+9E_%V~ow7N4M&|6p>5(iu0p*_`YEeO^CEh;Q|21gaE*e zf84O;5gzt%#P{7g4(1EJ1_OR|x`LZjME+t@7K0gBre<91@rwLIgsX(DNI1#6>&Z_$W_v|D&^z9tg+aYENv+lC%v-G?yO|9j4RlLjICd>bY<;jki`}(OW)%uF36{)%5tLV~X zwT0sH=`K`@Jo@Y5iKFAx- zD&Ev{6m7#8@~RNg@y2}C?{wTX>E<7ybWOBt%Ij@`4h1)8!g(U=AQS9#ijO}vGLzw` zByCh@v1l~ps7!^k`hR6`3bCWXN$EQ{Ni;bv;@o`h>d1p7fR*urae}}cqQhlinP|7R z!eD;vrV&umHBNAi?l8m&B7!0uS{k(Z4L@hVxn3bYhQ4`8}VsF{ zr>yG>*^M{eg_W_xkQkjDBS}VKLq|`U(9Tb*n@wFivNqS4Mq}=(EfD#Lw(QsykNE-N zVM`50im?IG{JywcS-#=Zc@5EvI9$=2#b^C1Iw??@|4B~Lo$1^V@kGy}lAD>9r8$dcEI7}^ zM@V)Xd(UK8x~*>C=h!AeCz3#NXseq9IOX{{nUqHD_}y&Hm%fZKdyN*%&gsUFa4)5K z(Q7#@FGD4K{I;cjEI)r2Hw?dAeh^-8gtMDH3W@%U)QFyYQuFx|5`YB&@ccLErQ%Td zLG=I2n3g)ETN;?H4lk(7Y%1_XO@_p*s>&4RhNP+PW?4sRCe0;~@1wlgz4i!>AHPgH zCN!H3Z$9|=CM($A-!}luIbJ0QOP1*-6C7(dghYrqxI1~x>@HgdM{*wwn%MVotTD(J z0p+4lv_4cfe%aazb9p<+QXkbnRbEj2m1XW@yhE^|yk2>IVN*hXVM>wiMz4GtsUdV= zK+r6}9UUyAZJV=@=ND{&m|-888qatTxSQj?!EEA4sP;AImh)cnr#Bc{vOrZ&T=UtFJpR9%3%zy<(R@BskM{{gLrvbFu8_~M}Vpkva&yAWbRd#{wVemUtE zP1hG6-3VcO@F{^vS)8})j>h|HR<$f%74~lPef@kUqQ?RkfWU`Kb4?Emw2z1HKJ(nY zySnveXUKdZ0FW*%D?`xW6APE^D#T_I1VgczVpgI;JS1Seakl(6@_Qz)DrHyD{AO(F zy%2DXHD_d%0vN)d$|}!V$;(EZwf~u1e8fwzlOmR3JgI=ah9M<&N+V~`o(=eUf{L>4;@DBsiQ|2XSL#x=0gboB{87@6Gr zUQqL+0Lk{F4$bR_{-O~c_ntZ#3tK8C@RIn#eB)lA(wVrd>bx^sHOWZz2bqy8+dy-^ zuodaP{&YLNkx(-B7mJ>h?uxUW1}v0dgoJfp?kV=Lbx{e!94$)? zbOgCn6)9{IIBf^V(I!0}e6s?B4el6auL>&ym-;DpUZISeL#6~vq9A5%R#)n$R2tM| z3&>iMH~RCiXX+(x>y;)$BYPXlQCWg5O3ieh-eTiy zZF;};3}5|p&RxYAEtm|aM6BZw@XyXQRl$*MB~v+R-k{d$rp+zz&Y~3ekmTw5h}*Eh z;S#r)3qdSJsSNSrv+|aV+6s0g>sEF2%=MALR=S4yl32ypSMJ?hwJ?un^>3I2B~$fb zJCp%%yrc~t$dIIDiLAYgd6rvKdP26)hCU8$#2Z&w(+_gPdKz@fp%YGF$0DT;dwG}k zR$$2SVqjE?115;TB_Y!IZq)i|O4L!A&Q^HH1|D5V9G;-R;!%-2+VIA-&ZuZiHcXJN zk7|utaQHaFK3!kzQM5*(YfwhLMi3drQ|aMuB)KIDq@FJ;;0kH05_!LUyA9{1_?o@v zVXByu$VQm*ZMF8BjADn&(G&2FKW5?g3QW2U(y@vM07#Jl00RHRJc9mfPC~!W$?t1f zik>kV2&1^Tzhxub?aXIFjqK(MQ}2?M?!iq=2$C1MW{rg>pRq+RjMFB*Ip_%B$uB5~ zJURn0RgSWIb+Xf_s`_DMh%dr>LRs7QEkFtw4N*Lk2))eiCD@uB>JUUVgnc&0jM+(tS2*lArOZUitB7nLzb?BxJJsH^2=r)mTPCP46EqX>YkO_?cvgrK4^A0+X^@;? z=RT?_jqXbORiGMb7PMeXmR=W|0qIS8es6Vjo1Y~7Eln@vbRQZ*VeS-#CEkPc_8D;t$1rsnY0rx{w@*I?B+?I-*H|Eyo^5m3 z3fE7)jSR~mRW(`{^q5qO;T{tKaYlV_hL{2scpN3uL}shs_hU4q1L>p zm2}&SmT2eoc;VK!rf9a*5~wG5#hN@D!D92BE)2Vvu=peAkSO8ULe!~j`FyVuyX447 zIBBRcQH7t9m1K`A9&xWTD{wIJ#z6kC#aQf>+-k{W8Nc|OM5%KN6OZmWkS?~y8}|v- zD2K-sS$KHvt|>Mzh%uOdfHZwD5L4|88My}Q)ju{0mZZ&hlzeCF7>Tl$eBR?pWVCZH z;kR@NdT&&o+N4v#GV>6PVZ5GRf!~^=O~mmQwWZC>fR5fRn}uc8G|NxLLVC~pm|cd8 zXSCqW%MPPmyRAY7MjVE*!j5!`;y$w~B`X|*R%=rPPnwD*+*H`(AcUwA}5w}-y z7jM||4S<%~20%$mA-0>ewW`p24of*&RyUqQjnFDy&W*rWo^W)`SQ}b&(Zfbl|yA0-|`E{kiS^SNM7q8VGvLhnOA8TzWGgK89McvMab3ijBy1BS#EZ%4hs3WY^gxtEIdaDm z<(}E$bf{>(#;cU;WmTdCRZk=LA2IQsNw~4!9@pTsqCzCAM0*7V$tXCf@D)Xt$+dp8 zEB#DgOe$LpmFY`pEsF$8Ejn9inJcM7C_je=8M40EVSbTKWwCSRMJ98QHufMBR(eXl z^CCq>bNF%r_hB#CuGSgYdorAS2xRg~GQ(_Yj8^!~srZB%O2jka_z=!2`+%l! zd~if?jLKhG`T#ZzQhd}3r;I**=3PT!rCKn@v|Py=mfnq<9-_f$Pj1t0y3=b1)MmC! zsh+D{05e@OeeiB1Avn(0W705=Onij(F z*r5N4_3TK}lm!a6BF-zjE#f3{J`CxT9wh38t;vrB8-}^vLQ6cz^s1F3mt>i+OYZ7D zwlJOhss+Kq2IBm)XL7lDTs8{8w#>KRKWvfj4$2ScuRWA+fN!lZnB(&M59q%+D*x%` z-)xER?c2@iE(`L1nG`>6`S())n@#Z(ApRZjhiUQ8xNo+_PaOPDxSuA*KjXjI7(emJ zzr=qxHGXjX+Sd5NQW)I(j~st7IezBv*9R{@DZMU_Vt#r!KZfHVgp zzPE4B