From fe45b3385146710209e95661cfff8983c25d6b3b Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Tue, 20 Oct 2020 19:23:50 +0200 Subject: [PATCH 01/10] use case-insensitive file extension comparison for Apk.isApk() foo.APK is valid and installable, though not recommended. Without this, foo.APK would be copied to /sdcard/Downloads, which seems wrong --- app/src/main/java/org/fdroid/fdroid/data/Apk.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/data/Apk.java b/app/src/main/java/org/fdroid/fdroid/data/Apk.java index 284d54304..8bf1c7ad7 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/Apk.java +++ b/app/src/main/java/org/fdroid/fdroid/data/Apk.java @@ -10,10 +10,10 @@ import android.os.Build; import android.os.Environment; import android.os.Parcel; import android.os.Parcelable; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import android.text.TextUtils; import android.webkit.MimeTypeMap; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.fasterxml.jackson.annotation.JacksonInject; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; @@ -24,6 +24,7 @@ import java.io.File; import java.util.Collections; import java.util.Date; import java.util.HashSet; +import java.util.Locale; /** * Represents a single package of an application. This represents one particular @@ -598,6 +599,7 @@ public class Apk extends ValueObject implements Comparable, Parcelable { * @return true if this is an apk instead of a non-apk/media file */ public boolean isApk() { - return this.apkName == null || this.apkName.endsWith(".apk"); + return apkName == null + || apkName.substring(apkName.length() - 4).toLowerCase(Locale.ENGLISH).endsWith(".apk"); } } From 5a0092d42e155d8b8f51354147fbaf6b11553773 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Mon, 19 Oct 2020 15:58:20 +0200 Subject: [PATCH 02/10] use shared method for getting full installed path for media files --- .../main/java/org/fdroid/fdroid/data/Apk.java | 6 ++- .../installer/FileInstallerActivity.java | 18 +++---- .../views/AppDetailsRecyclerViewAdapter.java | 2 +- .../fdroid/installer/FileInstallerTest.java | 47 +++++++++++++++++ .../installer/InstallerFactoryTest.java | 50 +++++++++++++++++++ 5 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 app/src/test/java/org/fdroid/fdroid/installer/FileInstallerTest.java create mode 100644 app/src/test/java/org/fdroid/fdroid/installer/InstallerFactoryTest.java diff --git a/app/src/main/java/org/fdroid/fdroid/data/Apk.java b/app/src/main/java/org/fdroid/fdroid/data/Apk.java index 8bf1c7ad7..f0553d7ce 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/Apk.java +++ b/app/src/main/java/org/fdroid/fdroid/data/Apk.java @@ -588,8 +588,12 @@ public class Apk extends ValueObject implements Comparable, Parcelable { return path; } + public File getInstalledMediaFile(Context context) { + return new File(this.getMediaInstallPath(context), SanitizedFile.sanitizeFileName(this.apkName)); + } + public boolean isMediaInstalled(Context context) { - return new File(this.getMediaInstallPath(context), SanitizedFile.sanitizeFileName(this.apkName)).isFile(); + return getInstalledMediaFile(context).isFile(); } /** diff --git a/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java b/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java index 7896ac3fe..23b804d0f 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java @@ -6,13 +6,13 @@ import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.core.app.ActivityCompat; -import androidx.fragment.app.FragmentActivity; -import androidx.core.content.ContextCompat; -import androidx.appcompat.app.AlertDialog; import android.view.ContextThemeWrapper; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.FragmentActivity; import org.apache.commons.io.FileUtils; import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.R; @@ -148,10 +148,10 @@ public class FileInstallerActivity extends FragmentActivity { private void installPackage(Uri localApkUri, Uri canonicalUri, Apk apk) { Utils.debugLog(TAG, "Installing: " + localApkUri.getPath()); - File path = apk.getMediaInstallPath(activity.getApplicationContext()); - path.mkdirs(); + File path = apk.getInstalledMediaFile(activity.getApplicationContext()); + path.getParentFile().mkdirs(); try { - FileUtils.copyFileToDirectory(new File(localApkUri.getPath()), path); + FileUtils.copyFile(new File(localApkUri.getPath()), path); } catch (IOException e) { Utils.debugLog(TAG, "Failed to copy: " + e.getMessage()); installer.sendBroadcastInstall(canonicalUri, Installer.ACTION_INSTALL_INTERRUPTED); @@ -169,7 +169,7 @@ public class FileInstallerActivity extends FragmentActivity { private void uninstallPackage(Apk apk) { if (apk.isMediaInstalled(activity.getApplicationContext())) { - File file = new File(apk.getMediaInstallPath(activity.getApplicationContext()), apk.apkName); + File file = apk.getInstalledMediaFile(activity.getApplicationContext()); if (!file.delete()) { installer.sendBroadcastUninstall(Installer.ACTION_UNINSTALL_INTERRUPTED); return; diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java index 5673b2a79..6bfe1d2e1 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java @@ -604,7 +604,7 @@ public class AppDetailsRecyclerViewAdapter } }); } else if (!app.isApk && mediaApk != null) { - final File installedFile = new File(mediaApk.getMediaInstallPath(context), mediaApk.apkName); + final File installedFile = mediaApk.getInstalledMediaFile(context); if (!installedFile.toString().startsWith(context.getApplicationInfo().dataDir)) { final Intent viewIntent = new Intent(Intent.ACTION_VIEW); Uri uri = FileProvider.getUriForFile(context, Installer.AUTHORITY, installedFile); diff --git a/app/src/test/java/org/fdroid/fdroid/installer/FileInstallerTest.java b/app/src/test/java/org/fdroid/fdroid/installer/FileInstallerTest.java new file mode 100644 index 000000000..0fb3976a2 --- /dev/null +++ b/app/src/test/java/org/fdroid/fdroid/installer/FileInstallerTest.java @@ -0,0 +1,47 @@ +package org.fdroid.fdroid.installer; + +import android.content.ContextWrapper; +import androidx.test.core.app.ApplicationProvider; +import org.fdroid.fdroid.Preferences; +import org.fdroid.fdroid.TestUtils; +import org.fdroid.fdroid.data.Apk; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.shadows.ShadowLog; + +import java.io.IOException; +import java.util.Enumeration; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +@RunWith(RobolectricTestRunner.class) +public class FileInstallerTest { + public static final String TAG = "FileInstallerTest"; + + private ContextWrapper context; + + @Before + public final void setUp() { + context = ApplicationProvider.getApplicationContext(); + Preferences.setupForTests(context); + ShadowLog.stream = System.out; + } + + @Test + public void testInstallOtaZip() { + Apk apk = new Apk(); + apk.apkName = "org.fdroid.fdroid.privileged.ota_2010.zip"; + apk.packageName = "org.fdroid.fdroid.privileged.ota"; + apk.versionCode = 2010; + assertFalse(apk.isApk()); + Installer installer = InstallerFactory.create(context, apk); + assertEquals("should be a FileInstaller", + FileInstaller.class, + installer.getClass()); + } +} diff --git a/app/src/test/java/org/fdroid/fdroid/installer/InstallerFactoryTest.java b/app/src/test/java/org/fdroid/fdroid/installer/InstallerFactoryTest.java new file mode 100644 index 000000000..a455fd57d --- /dev/null +++ b/app/src/test/java/org/fdroid/fdroid/installer/InstallerFactoryTest.java @@ -0,0 +1,50 @@ +package org.fdroid.fdroid.installer; + +import android.content.ContextWrapper; +import androidx.test.core.app.ApplicationProvider; +import org.fdroid.fdroid.Preferences; +import org.fdroid.fdroid.data.Apk; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import static org.junit.Assert.assertEquals; + +@RunWith(RobolectricTestRunner.class) +public class InstallerFactoryTest { + + private ContextWrapper context; + + @Before + public final void setUp() { + context = ApplicationProvider.getApplicationContext(); + Preferences.setupForTests(context); + } + + @Test + public void testApkInstallerInstance() { + for (String filename : new String[]{"test.apk", "A.APK", "b.ApK"}) { + Apk apk = new Apk(); + apk.apkName = filename; + apk.packageName = "test"; + Installer installer = InstallerFactory.create(context, apk); + assertEquals(filename + " should use a DefaultInstaller", + DefaultInstaller.class, + installer.getClass()); + } + } + + @Test + public void testFileInstallerInstance() { + for (String filename : new String[]{"org.fdroid.fdroid.privileged.ota_2110.zip", "test.ZIP"}) { + Apk apk = new Apk(); + apk.apkName = filename; + apk.packageName = "cafe0088"; + Installer installer = InstallerFactory.create(context, apk); + assertEquals("should be a FileInstaller", + FileInstaller.class, + installer.getClass()); + } + } +} From 4bb158ef770c103d5e8a5c67db90fb96d8796954 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Mon, 19 Oct 2020 16:10:45 +0200 Subject: [PATCH 03/10] handle installing OTA files separately from generic .zip files It is valid to include .zip files in a repo, but only OTA ZIP files should be installed into the OTA dir. --- .../main/java/org/fdroid/fdroid/data/Apk.java | 31 +++++-- .../java/org/fdroid/fdroid/data/ApkTest.java | 76 ++++++++++++++++++ .../fdroid/fdroid/installer/ApkCacheTest.java | 58 +++++++++++++ .../resources/Norway_bouvet_europe_2.obf.zip | Bin 0 -> 12098 bytes .../org.fdroid.fdroid.privileged.ota_2110.zip | Bin 0 -> 34428 bytes 5 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/org/fdroid/fdroid/data/ApkTest.java create mode 100644 app/src/test/java/org/fdroid/fdroid/installer/ApkCacheTest.java create mode 100644 app/src/test/resources/Norway_bouvet_europe_2.obf.zip create mode 100644 app/src/test/resources/org.fdroid.fdroid.privileged.ota_2110.zip diff --git a/app/src/main/java/org/fdroid/fdroid/data/Apk.java b/app/src/main/java/org/fdroid/fdroid/data/Apk.java index f0553d7ce..89b1cd72b 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/Apk.java +++ b/app/src/main/java/org/fdroid/fdroid/data/Apk.java @@ -17,14 +17,18 @@ import androidx.annotation.Nullable; import com.fasterxml.jackson.annotation.JacksonInject; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; +import org.fdroid.fdroid.BuildConfig; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.Schema.ApkTable.Cols; +import org.fdroid.fdroid.installer.ApkCache; import java.io.File; +import java.io.IOException; import java.util.Collections; import java.util.Date; import java.util.HashSet; import java.util.Locale; +import java.util.zip.ZipFile; /** * Represents a single package of an application. This represents one particular @@ -551,10 +555,12 @@ public class Apk extends ValueObject implements Comparable, Parcelable { } /** - * Get the install path for a "non-apk" media file - * Defaults to {@link android.os.Environment#DIRECTORY_DOWNLOADS} + * Get the install path for a "non-apk" media file, with special cases for + * files that can be usefully installed without PrivilegedExtension. + * Defaults to {@link android.os.Environment#DIRECTORY_DOWNLOADS}. * * @return the install path for this {@link Apk} + * @link Inside OTA Packages */ public File getMediaInstallPath(Context context) { @@ -579,11 +585,22 @@ public class Apk extends ValueObject implements Comparable, Parcelable { } else if ("video".equals(topLevelType)) { path = Environment.getExternalStoragePublicDirectory( Environment.DIRECTORY_MOVIES); - // TODO support OsmAnd map files, other map apps? - //} else if (mimeTypeMap.hasExtension("map")) { // OsmAnd map files - //} else if (this.apkName.matches(".*.ota_[0-9]*.zip")) { // Over-The-Air update ZIP files - } else if (this.apkName.endsWith(".zip")) { // Over-The-Air update ZIP files - path = new File(context.getApplicationInfo().dataDir + "/ota"); + } else if ("zip".equals(fileExtension)) { + try { + File cachedFile = ApkCache.getApkDownloadPath(context, this.getCanonicalUrl()); + ZipFile zipFile = new ZipFile(cachedFile); + if (zipFile.getEntry("META-INF/com/google/android/update-binary") != null) { + // Over-The-Air update ZIP files + return new File(context.getApplicationInfo().dataDir + "/ota"); + } + } catch (IOException e) { + // this should happen when running isMediaInstalled() and the file isn't installed + // other cases are probably bugs + if (BuildConfig.DEBUG) e.printStackTrace(); + } + return path; + } else if ("apk".equals(fileExtension)) { + throw new IllegalStateException("APKs should not be handled in the media install path!"); } return path; } diff --git a/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java b/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java new file mode 100644 index 000000000..b20314926 --- /dev/null +++ b/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java @@ -0,0 +1,76 @@ +package org.fdroid.fdroid.data; + +import android.content.ContextWrapper; +import android.os.Environment; +import android.util.Log; +import android.webkit.MimeTypeMap; +import androidx.test.core.app.ApplicationProvider; +import org.apache.commons.io.FileUtils; +import org.fdroid.fdroid.installer.ApkCache; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.Shadows; +import org.robolectric.shadows.ShadowLog; +import org.robolectric.shadows.ShadowMimeTypeMap; + +import java.io.File; +import java.io.IOException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +@RunWith(RobolectricTestRunner.class) +public class ApkTest { + public static final String TAG = "ApkTest"; + + private static ContextWrapper context; + + @Before + public final void setUp() { + context = ApplicationProvider.getApplicationContext(); + ShadowMimeTypeMap mimeTypeMap = Shadows.shadowOf(MimeTypeMap.getSingleton()); + mimeTypeMap.addExtensionMimeTypMapping("apk", "application/vnd.android.package-archive"); + mimeTypeMap.addExtensionMimeTypMapping("obf", "application/octet-stream"); + mimeTypeMap.addExtensionMimeTypMapping("zip", "application/zip"); + ShadowLog.stream = System.out; + } + + @Test(expected = IllegalStateException.class) + public void testGetMediaInstallPathWithApk() { + Apk apk = new Apk(); + apk.apkName = "test.apk"; + apk.repoAddress = "https://example.com/fdroid/repo"; + assertTrue(apk.isApk()); + apk.getMediaInstallPath(context); + } + + @Test + public void testGetMediaInstallPathWithOta() throws IOException { + Apk apk = new Apk(); + apk.apkName = "org.fdroid.fdroid.privileged.ota_2110.zip"; + apk.repoAddress = "https://example.com/fdroid/repo"; + assertFalse(apk.isApk()); + copyResourceFileToCache(apk); + File path = apk.getMediaInstallPath(context); + assertEquals(new File(context.getApplicationInfo().dataDir + "/ota"), path); + } + + @Test + public void testGetMediaInstallPathWithObfZip() throws IOException { + Apk apk = new Apk(); + apk.apkName = "Norway_bouvet_europe_2.obf.zip"; + apk.repoAddress = "https://example.com/fdroid/repo"; + assertFalse(apk.isApk()); + copyResourceFileToCache(apk); + File path = apk.getMediaInstallPath(context); + assertEquals(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), path); + } + + private void copyResourceFileToCache(Apk apk) throws IOException { + FileUtils.copyInputStreamToFile(getClass().getClassLoader().getResource(apk.apkName).openStream(), + ApkCache.getApkDownloadPath(context, apk.getCanonicalUrl())); + } +} diff --git a/app/src/test/java/org/fdroid/fdroid/installer/ApkCacheTest.java b/app/src/test/java/org/fdroid/fdroid/installer/ApkCacheTest.java new file mode 100644 index 000000000..6c5c33dbf --- /dev/null +++ b/app/src/test/java/org/fdroid/fdroid/installer/ApkCacheTest.java @@ -0,0 +1,58 @@ +package org.fdroid.fdroid.installer; + +import android.content.ContextWrapper; +import android.util.Log; +import androidx.test.core.app.ApplicationProvider; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.shadows.ShadowLog; + +import java.io.File; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +@RunWith(RobolectricTestRunner.class) +public class ApkCacheTest { + private static final String TAG = "ApkCacheTest"; + + private ContextWrapper context; + private File cacheDir; + + @Before + public final void setUp() { + context = ApplicationProvider.getApplicationContext(); + cacheDir = ApkCache.getApkCacheDir(context); + ShadowLog.stream = System.out; + } + + @Test + public void testGetApkCacheDir() { + Log.i(TAG, "path: " + cacheDir); + assertTrue("Must be full path", cacheDir.isAbsolute()); + assertTrue("Must be a directory", cacheDir.isDirectory()); + assertTrue("Must be writable", cacheDir.canWrite()); + } + + @Test + public void testGetApkDownloadPath() { + assertEquals("Should be in folder based on repo hostname", + new File(cacheDir, "f-droid.org--1/org.fdroid.fdroid_1008000.apk"), + ApkCache.getApkDownloadPath(context, + "https://f-droid.org/repo/org.fdroid.fdroid_1008000.apk")); + assertEquals("Should be in folder based on repo hostname with port number", + new File(cacheDir, "192.168.234.12-8888/sun.bob.leela_2.apk"), + ApkCache.getApkDownloadPath(context, + "http://192.168.234.12:8888/fdroid/repo/sun.bob.leela_2.apk")); + assertEquals("Should work for OTA files also", + new File(cacheDir, "f-droid.org--1/org.fdroid.fdroid.privileged.ota_2110.zip"), + ApkCache.getApkDownloadPath(context, + "http://f-droid.org/fdroid/repo/org.fdroid.fdroid.privileged.ota_2110.zip")); + assertEquals("Should work for ZIP files also", + new File(cacheDir, "example.com--1/Norway_bouvet_europe_2.obf.zip"), + ApkCache.getApkDownloadPath(context, + "https://example.com/fdroid/repo/Norway_bouvet_europe_2.obf.zip")); + } +} diff --git a/app/src/test/resources/Norway_bouvet_europe_2.obf.zip b/app/src/test/resources/Norway_bouvet_europe_2.obf.zip new file mode 100644 index 0000000000000000000000000000000000000000..11bb8e4eedc4ea73719e86ea293cc8d746ad8d9e GIT binary patch literal 12098 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 zξB0RX7~S&yBugSEM%qq(h(qn@pUDXqzWdf9&)>>SKp%&m+~jg4sCtgZe}?$cOb zJ2tIAE=5B*Gd0_&!nnjFuP8n(H6u4ZCDp1zNh7h?EZ@+2*gzppGcipKN?El0sTG85ekski%M$f)zd8mj4l$XHylb)uW+1$Dm zH4a`d8B0&oGTeF{jU4EQ%LQ{KY1La|#z*(2T}pJeZI^udJ0^M!yynr1VRrR4;`qXa zq1R(s$KiJviB((N+==x463v_>TY)i>a!jlA3H1-Q+1y4o5+a?S%S0eucEcYm1>QG+ ze&*>y`Q7)#eUj<0jK8)tr^=yLS#R<6``_E^1UI(yC+c4(YYf{r4l>@*`B8 z!mikuK@aOK)=SsL-UGY*bynd`HkQayr#-JMP{aC?)r;wb9 zxw(NS<`3vZ+QJAx%G$%!{x{`rVCayf9?F7xXJnTfx#>3=(x~%Sz=n| z;fbyQLS|+Kz{SPQ%R4ApIIpb!zyJdH8wvza2MFMokQ9QRnAN}>nUL0y)s)l$+L++9 z6qF-WL(WP)hV=+?0jdgokF!abu!LqZIQVEt5@W;pj11(H8#*X-^TIRmv}WSN%bSdM z(^7y0E)k9oK_ltDB{m#&Mmk;Q=zdLAgvW2@vTU2swYn`kI^U(RJ94|Sbwpph+z}pE z{@!9Xb5pD~kU|i5{|C4={}CT}7z_y?q2SN(jFo&fL!`l={+ofDYxP^*$%~h0C4Zsh zWYP>}$pa#=C%2GLm1PnvCy~454QGIUk9{XjQC>daIdwL=@6wFaHHV4iK;eu9aCf03 zBeZ`Mi>}U~-`(p(^A+^G5TY<#!Gx)zaC9e}DGhDK!0x1pP}@P-xJQe&Nt%#At|1pu z@~d9jq;nh!eZ?9VIXG5o__PumUA+yYP12DS{cPjk{L7UAxBV$@eR?!iH4jaTNSBN% z^yiB3YfAFtFP^W1&JWxkYUlORwa&{XSwZTEy4XcH;k4q+(PfR#xcC=25LQX)5`mp6 zRC)ZiertonYR6QENUOv>x}z$eHzVo3$BF2;%V|^9UC?NfO~Yg^aupEvx89iSi{2;; z?!d(v^0zV5JA|s3OJduh!fX3 zuugz+>&)&}Y0M6Tl8GjtR`<Q9rqHNlUryJa) z(>cy`3FNpOx`;`YgDdn2{oGqT-v@^Id|-thtri^;~1zC#arSik#7V)nKe|=^_w;ntZvC#H*PqBYq8uM9vYhm6X}Kt=Mm%d5<2TW`)xAEs z$>aX=zr_!`B=Ka}=b!VQo!DoVuDp`X$Ny|y!qQ|qR>&Y9pL;Cf>&UH8=ay!9W-h~K zw}+Ti79m>Vg;}}g-d3JPX|%>iiYD^?(bX`GlI^AMeBSd&s(0lxmnvkKRbB7X~Mm<+I^Lz834aES%%FdQ4lCK@s_4^ z%CZ+hU<&BhZT()e8lIBLJNxu(Uu0Qrdy;E}D&OIQNm31G*Hg|qAeAf@WbvV2OMm0| zb?U|TqY5ot*4y&XsAo*yF0Xp&{PYM&h=~Q0|CO~W8`hA9{)%GeB(empGhu6GWK}`Y zJU`m7%e&=h+{K2k+SM!Mu&yViPs*L~(nT5*4CCVObU*Rbx*MI7u`6Vn7abRF-x>(B*)i@Bj=w;=3ib&+<1 z3XgswLU)5Y+>uMAI&LDA3hgd^hBbu2O~Xdh8;RqJVInGK?B(#aM!b*J>*+Y3Ed4aE zQ|}iI343&SsOimjk9eq3r znbS{iNPz{JdiB$TJ*l-h;=_Rk;0FYMbgS?O>C8rc)3ap*2Vn42A_JpRA|djma%3_^ zGlApMl7T@7_|r#&*T~$bX=UEiM3dKCBE9?o z?lF9cY#SzmQHPr__gr{Z@L66@aUA{l;PNL;tDzl9K8KgY98nK)%b?Mxo^8L+JD3^V zZ4E}L%oEP6Vl`C!b*ToxqvD&s3+ThC%Q#{CZ*7!kkJB2J_Bq#3n0hz)cq79J4(pTl?Z#+h-Xn4BaeHMAgLzPLMB4U;X7&b~gN}BHGrTBMk6rt>B zwjp zZ!&qyc`#egl53lfh{wt$fD(;JTI)x2h| zBwE9~2hQLhGEDU(4<8*XomnBbs0YsM4@UTTpU$(&e?tUkg>IIuS>xEXg$mXySS5!Q zlM*@c*)|S+d@?A=dtfjo?D6F7j|q6UHkI+Sh!y)t#i?j^{jr-D!m^xYQb135tCSzBeZo)3Q8u#lH(T4FQkAh)-C0iUUU^?l z6i*P8`GH@%hN}5AITTN zj=e?ozGW_M3P5xbr4eBS;hkVWn(OK5QF)}1krFh;w9pxOKr$@rpuat4%{@O~9 z8M1~xs#{Qkg$}QU?ICcvR2+V-CUl``$TH3zZefCg0H>OA(hY8EBTt9MloswBWvjjF zrzK=9&m1$PW=}?|dnB#V4M}+IA8;gB>uch1DH798lO|=56gzA^wuB@T@5anG?Gfzo z=K%`if8nF=N-g(4CYTjY@gso5VH&XQvmtCD<~Za`_)&@^+b@X=$hQH2=3#;(Jz+9$ zCqq{SX#+dufeS(oKoW8v>>_3z%v9!6gS8#U(cokE$Mn0n3ZN5C3jTE-{{syM{wE03 zNJyekS~GkgUx;34L&%w*hj6Z2se;HnpcjSvm^o{DnQ6#2zpMMH?4j&sI zk!Frbe~0KjZtK9WDsT)yxG&OAatvJ@q0RJ+71=2l5dcRRu@8u{u2LiEEoC)+4fsKI z3}FHI7?suq^Br)EY@#G$3_Ks&6y|7Fpf1S5Zwi^Da$o>VTWI1BJfH^t%HM{+Px?Lp zIS6Eb>GDDIUGRAU?S+F$;pqV7b*k#)=Hs~GZvYU^@bJkB>;&!j2=5K{g(Sg-01^mx z1zCVVfi(T?`0dz!DiHIk31DL%zzx6_X5;u#Oh6$A;A2q~5d6UU0y9F*g4*6yu#4~p zh~U~H>~P(xZk08Z5m@?w%PhlK0G(Rl>ai@-Suun1C|@-U2m4 z@dF$?qX2gI8&(0m2c#B0V^maMcIVeo4ydt@)ri>HVg*L?V?LEnwoc-x{~3bX15{Vy z5ZDekP!_NlTo)7y$b@YL5QF9qE&>w>$l`x1<-|9MLm%t3?3e@`(Az_JLH$7nBL=yE z-t$iztqxoS&<+Q>16<%Q0tf*}r0uL(u6dz-^#jZk@dH#QIfBAJ#L*H@Wf&s?OF^>$ zM8tI45|M;G58Mu#g6a&o0-hIM5_XC7jPSJpS^%8)8}XkeC!Jf1Gbvz48cT&&E*R>H zyNy?s_Xg+Qvt^K77C_ZO(@CB#8_W3<^VO$B;g;+L!5o3_WUme>;bcGNjfp~6$<9_8 zJ6UfduQ9M4lSTj-QrnA<4vu66gHvCYb#^+P1E% zm1@5MU?yi%qh)qs#wD<>Pz_b-GYHjMVG6J}ptO3D(9`|z`{w0y5=UWjY!(b2YAs)q z>jmyg!P=U`EH$mQHn0E5D=jbvq^;66>~)O^Y6gz7S8tp~}GwrTg zj1|n>lb0UYyI6%7f>-BTHeWL49yfBA-6E^GyOOTZLyLV5op*zMvGpfE)p91!cp+y2 zCMn9(2FDi0bb;_#{Ri@_&4WS_2O+=P`0EPPKgWa|H6WR&woe%&qeGtU(I2mjKZ&VB zyPO+~SBGD`RL&#qak~9zCAY%?HqII+8YKkBBagRlTq?U^#&Wwfq3O!KHbLnF?`8+Y zO-4T+v4yRgr_z3nNEZy*jXI_t*p7Q=X^9NOymTN=T0T7!y(zAMpp$Qil|@%;*i#*U zkRQhA#%oEIKt2>wu(#U0tvx5lk$soDP6=1p&!4Ud*h6m*VH(oFotr9WjB3?>ooPEl)EbR&#U2jP| z^$tvlm5)-6YIrl$?U%r=)|x(($O=VJ6f8Z?Z`b$Xd$V)p&zun~OdAX)i|+1LOEGXu z#qSE65ot%2j?yVXS=OZqpB+Z%OLm4T<=$aP+W&$KR&~2+NbF$opZ7mvQ9H(4@7#@% zKoXR5sGXR0DEqmok>JpmWU`>sqDw08gz*eBosOJ9(Rr_<=FKN=Z1asuKBjKr$hi>A z6TO~!k!?ym8b;sq((7r2k4qW2(!9{iS(l?|il_1x6aJnbex(KimM+g+FBay+KJt+> z@SSuv1#HMHw(fvxlUv$+<{AbnEpT^Y1i^ZCFyT(iHw$3 z&iJ5K(mivBXvg_rzJLR#v3jf)7(Sb`F@fI3-tz3^ZEoep3-on&{z55fp}Rg(evmav zO`3bLwS78Dd5!uP3`cIH@JA=54LUoYV_p3e7WC$o`6d%YDS-?4t%MRuItd#wquGQ_ zSgR_WMK0O0$7uO22N95MEf(H;g7p^h^07O&v>1!*3_~A~lJCr0fka0`_~(digeOG@ zPb$yc>??U@pJ4qi52F!#V#X!+BSTnr)bA<%S&4Okf|$S4to($k#IBpC!8I3LV3T)D zU8R!JQXd2KxXKTyEyE3u#*e|GM^an;I+tA9-DlROG##Dp4k_!84mV&ij+zk@eWTEd zt9Z?iKS7>kM`Vec@-VM%C>641sjI3v`Ny;IywX~X$VOlmY|uM&&^fV3bQn{XiWiwg zbSRsq{;`7{d=s}B?Y9BG%l%Zve#1_dx(Dm&=DH5!lpLN1hxMyON*_eGs7zh;bMe1V z_25--EAIhX2jSvxgn)bxk+5)IvzmE%)Ti z@?Fu|&BvWh=Tmf#gKt#R;SA`AtL%L?Lg@-ZV-rFHU>98#9CH*yt@X5Xr8GjAwrr50 z;;dey$%-&(vv=Ijhm$zj3t1EudYNfwsmI-kmC%LJdP|#TArK)3F%V_5o>--95zsR+ zu$kc@bRkITdna|NEC$wxfGjj(5n`p)$9I}yF2BuqA!^h9TT9Sh4J1f2IM=lzDx|%34`_5o% zblvf$XK8NiNYkprUv&bIge;BHtSy)hZg8@f+GG{$;YMdDnU$hS_F4B|uzht5ml_=Z zUBK{7OvHf)@+C@pxK4DwY{kNfd?&S4+%O!7xH0ck)xBiqELKS{iEWp2yqG5G^3@fK ztpqIhQv0G`LBk@j*zJ?xt|FLvD>!+uAp(C1xX``B(ijEC5;~Y&JDm&B#(dMF{)q__ z?!bK!X&A3hEljQSa0I||HEtO93?>B9MPh0*Wn@EiAT8q2kuX>@IV==(~M3=5$Jf7B}o)fnxqP=CYDdJ20MDb!ZWJ}K64?gQ8d@(C;4g2l7ousX`FnaS*JoloE4PGpdltlIp@2=R+CLPTsrcZIq}Ty zlHz!uVWD;+Uh0UuIije_WuhVXZQvk)cOyv?r}{h$Uta@oWX1}`L9zb@4EG(${Mb;# z6MdI?mY+g6>hb;9+pO>sYU&g(-GFvKDBIE{6GEnKZqPtS@ULDjV-}cjZlD#AXi=RF z`8=vWu*#Qn9Ry<1#f*i&48KC|@sZ4DLDo_e+R(V@>r&*STVBuOaDojSv>AfNRy|fnTo`*`=Ew=98my;rxJmX~jbLrXEsx z(WK>7TZbJKxf{({yoh`4sOqq%&_(yeOm?|rc58rs_E_&GukF-Xes-5ait=C>>RaL7 zo1nUT(J0@e`3Qto$j zd??g|L}n{9pX~$|%rdxvt~Rf8=Chztq*Gq>_gVGHnDHX{C$W6|&l!(F2iQYli;1&l zKkBLU+BvShKo||$!sH9b_Lzq1_wwcJ=b1iTin^vT9ZV;?^Em+=_D^HImn23!{Ap=*M1IVhwcjZH{ z=+mhub5x;@PbiAbJXx-E%{0}$c&4~UAE1~R-|DJ^NW{;qwd5}t=-BUyJSB-crpFdO zrz*-9G!Z;7MnsOoJZ&_mS7|iw5O0j00=0?qVV~!52!{Un4kIV20Z!91XbMZ@*%cl!au-=o)xdo3N-h|#+ z(d}1K+nMww5@4F3!^m_4%6dbwCJk(=qb&(>@~D;riQU5(g#*p=Ox$I8l~6g zsT(3#3&3ast`x$UY%moxYtKJH0cN}}XH26TWL0_@qtVK}W|SM^Ys948Zud-;joC@Q z(GZoA6r)jX`-@2!!&d1Ekv1kDedY&z3CtRA8e6iT#y33G7~_g@D>BU!?{lOj=~B)+ z@1es#1NRV(STOt0**TOg2lk0_;px_)Ea|+#D}58Iv*Z|VI zHK1m$89Dc}h1r}l%9^|19YjMsCQdk%B{Qo7XAZklQfE8G6gf@7T9IU`88PlB;X2JH zpd^n1{p4PCJ4i}a0nv@;9%w%~!{$C`-gVnjNbVB_VGmzISi?FE$4qf*wTA|Fv;1X6 zi%(zPYF&_3Ct(4hLM?fK`ci}JPbwS1z6;hx^FGhjZs67gH<crY!-&hD0=Hmc*_0LN5U7Y%Vq$v5%Lewx;O ze13gCAs4vl@2ZV-cia^meQ{#qL+(;7k@&h$N~Z7nD%w2$_%<-muC?#|@h(q|h>end z0XaBb%XO6jJwvg*CoPh*m)Qv7-b}&pL^F+Dw1#)K{JIOdA>-TycSY3n=9fIF0wJh( z_VK_{U)n@%FdXWfY(1HDoH7$;#dUtQ!CNg~Ue%|i;xD6HnBlzLmbfo;3_i_cn;H-L zck*Pxq(`x1Zn&{5jkyx+?K_lAuo2eAU*@bS@!KloNxlu^VnCaC+9_3);r?8;rvY_y z+xS>8BPoQXL{N&JEC}lleBOqKLq8=3O_)j~B{jn{U4Z|GxyNQEhIcLRW%T1QipRP` zilcGetyA=-s+`PsHRmxsstq?p_f_s=zoy1@FXuS0bLWcG^?+a}Hf>h*J>14}l1N@m zt!|H+zP~}@CI$S%BrlfLhqa!O+Pdr>_!CFr)XUlDG`2i|p~2d`tA*%2MYk$tlG6h< z^9rZYMSewrPdE!;xpc@3SS)@fM+FrfhtI$ANjK|+!TCbJ#YYP)2 z0faGv!_6CZn34p#n^8l+H9Se|R(kUv-n#4tG_v&FhR7T6C!rA<#XT3P(ZfF+k8ye- zr?X(xprx9e5!ABAZMp#vxR0(4WL`^j>Lz%h9GkmxY#F!4sN3Uqkm1s%p0adouQTP~ zMv=r;e>QUo_>!03p9%P8OE8GH%3*~+keXug+=?@A*NaEEA2qty_o_Z<`x~WwDzPVrChd@+NWNYx1wUAYiD|DK&i|;i6|eRgpwm9k^tHp{bat} z;(lFG%zu+kCb@%e)V#FDP=wtU8MC?;3*rJFN~a3bCl@lVL^>p*Dz8gO%z&|0=|X#> zGb9Qxz<*#Awp0dbahyyu70{*kG%buo0Y=%^8rP!_i@1kDr#IkQjhcpMQmA zJRzkiPYlW~Uo0RhUamIjotbfVgEiw|S=nXQ9du){b=AA{J{Pz6bfg^2X@Af zn1Jx7l+D!ZqHPSiX;?zJ=meOgtRI5Mo)$7mszE$hYd@g|c4?>Uv$HvCp>SbT7}?N8<&Sehb3u?4&sY%lF${t~O}NuCx^3dG(!4l&k($@>bwUGxoTvyDpf(SM!V|m_C^bk zI@M(LMD+ZA*+anvbvp;4$TQg@VPFZL8MCai^d7>~9wvf9)o~}h@A&B`+2jS6aJ+<> zS>AKhfm7J)lB>Vk3$`VZ9gw=x3=!^_!&y7+q{_$#LXVNnS z6{D@25bi_KbIVD}N0)(Je=Rl-zmJ~mAnSfM(g~Q91rS0CZ}`5ShtI$S9`6#dsza@Z zQP)T5;H>r+1Te_Qy87eXYek`Cfw)EW$fAr2TJ$zkG`G03{vJKntB)W zvTTR?^%;oH zOMjtg9zG!#5Jv{9ZLWo2v7f65XVOI(eSBf7lOILxX<)f@Z+oY@)Tr@Zl)3H~HvvXC zsrIqYWW(WrfHbdI-Zt%OCYILYIngwS?5&jJX|?m{*#j(pCVHZ;F|tCD(&7GSBh4sP z*!r2;1)~TUgb}l_s_Twhb9!hH3D|WF>QG=1iR-l*rEJ#y;i1gW_yjmGZZvYq};-RhKex4M;O1gFmm3 z;c}`TWIG;uNzWsHXrc8>_4cV9-RyJzb{>*%Mc(n4*?qo7f^_>fOj_BitJ{JxWg@UGGL_UPNCeZ zQ0Y;{L~0vslaLH@5!ATC4O09)i{uLYTs=rgxB$Z1IA`n>+jiAekiK@5KtcRs1c{C( z$iK*<=2h~TXW>Cal)g6@Oa++sj;ei#&kz+{;>lP9J&O_q`GAFFMICgs(JtKl^W6Q8 zhWY$#H;Fj9^ZY?5?X^Ge!*d0a8+cjiCWtHl-Xe8RU=HO|aFec{>k7ateD?-Du1#1V zLPZFV6br+OMeL(DY2!kN^fzQDNC<@_w76Bl`R+$Myi!K@?_C7$OY%#g+8>7d)c&0N zZa*Fe?Z>bv{+*_f8hnOc`52GQ{%C%_Gm_yDbPP%=kzdQf>}ok7EJV*;Xm2ZwMcwJs zCTWtkQBJ#I2Jc=Oa}N3HwKb{IcE_)(xX5PZis5e_$LydtluY7_9wZ9bDQLkjckz7- zwC+wdVjW3DN#bbcZt3PW=#w1xU^H1IxZwU!byE$+mD{*}wH!peCktWG%fWq!1C!PQ z{&s}{sM@$lTv-~T9zhw!!|;C(>mEiQ3=tIyzr8Z|@TEdmtA;@c;yfYIx%3K1sVJ|C zJ_NliI_CH&O&FZLo{k+!JOhARjZ@mK_7IK7*_37gkSwxdjp9j#+&dWwQmxFXLQa$- zucYl}rtV=(9}Np~E?RuNkBl*%Mb;v$)a0l>=3!lJ!kHv+@2d}M^=gqfJs;kc$VA0% zpn@S}l{}P=SUs;XxqMFxEgt9GrZ&vZIazZ};dexEkLoY>m+%;iq!Vms4wlBel$(lpKG9DV3U$F7B1y}exu?IGmH}D5x9yaXHQF92Y}7@*cWOuR`?EAM!>iu`jU5NFC5P2>+i#N*98D(bIIc@xJ0E}vP?gdh}Q zkVKU@c)MT}ckz9+{4UZ;I>yUbs6s2iSx+)#_J*ioyEa75Ec>|>^__E{3t^rKe-!0V zD>XPw2%^atKo;AbfEwgOc{`DM`j3*p|8zUgpy*S`1%VYoMC41%!YV%llyK8H$yJ$` z{XmEsmtrn`` z(vc=8GrNtcjzn@E?8%bliFre`R-0T5S{$mOJq9GnjCK|_A}im}6r{}uM(K#favnNL z5JBQ9Mi^NTuy=&E#Dn$8cNh%zExA_A0+o-?TXabqOO|8rUfP(vsici!9CJ@0aGbRp zkOyGi^O#$J1hpf+9Xw8?fyqEYY3#0q`QC{bc^boHp%_;LEUVr*Zk%LzIhL#^k}C<5 zz1nxh)>aL~bjL~~PW%Qll?-3$*zmR+4{7cQhro^ICxmM&T!85Aq?)Iu zib%aRiGT&HbPY73N-Z_DXO=CeCDr?jt2aZZgt;`IQ$ZxQihSa(9qxljh;ihfbdO!H& znWH$y%hzA}{23ZdPp%s#?p%DO(l3T(j#fa`$VEXfV_fj?y6;8u*V!>0eoKyZGtPo> zPMDG!b9^+i%d}Rj(X6pru#TM_$vn8m{E8)|^<$;-En2F}`e>aEN&F~QQ}7v*i}7yR z@SGD&;5}TX-Cyx3l)8De0!}51rfJ2TQdsdixE0Wf?He3I1KrEa{QH8S#v~X!RUR;y zEbG&2Iky2*9^03APw(28V;AAsEks$?!)uAKX)JDkV@!8GhEr*MNh-Tds`)#Jgz3Xv zS-?IjS7)^8ciEnpAR$Q>6%=W;UF*ZxJoUyE6*jGXnZ1LfAu|{4AmAze8GxYYJsu4e z>9}BN&Q$b;ysOOS_#8_*CBG1jk}VxcmH>C4A@^#=XscL&hn7$rG+cM-u8#5b>Yi+9S^vf(PP z(D2&v+LYEwpYmoC8tSbKh>j3G!Q0l7v2B9Z>Y#pQr#{2r%|#I0vUmk6-H+K7OX4zf z0%G)2$`vwl>C6W4-AQu^eBvsngm*^>>t0JZK?-3E@%Y?Eq2G~QR?AxZ(7h8r@}50^ zK&Dmj95b5tM7&VNY*#^rcw$Z^#E8+mw}h)OO6`NE)U7i}#8|*`xS+l3aN{JPR&O;9 zy-oz&v;wBvxK{b|=H2B1%2-XsW8pYoJRucDXS$Dtr1nZ*-;|R~pp>ZmCbdyjOd?e=`?6t$l+4iwUwSoq*ru}f%%FFvZe z9jLck@l!Uab;?U6ku9|@*V?^nd00n3?B5A}aw1e&F-*5^cM@+LpWKC|_viqp&c!+5 z-)2g(q!0wyeUtlp>gRJqVu45g`di$ptQJHgH*8wYp?pf#!g!%^=3RB- zzwJKo*L9wyR9^*g_G;`8EXTw7Aq7Lh!K%YA)~vf>v8aiELVI1az;zF{2TLC}3cgI$ zDSzWu(Qe*cXKaL=T_K?1Ebg{61NF)Im`^Klzhi4|{XU5{k7nbHD#-kI5C;r{Uj4kgBElh>4&+i<} zc3$^%fmzTvjaqARS~Db#(k5xso&{MA8KOQaTqxy7+~VUk9H{5r&^=;NbjV4 zqMqzkgNU|sxLNTOdLEr@busj1I#rGM;x%kzb~;dAMg-*XqS!n&wwI?}RyR zz?n*T4;U|0H)c(J$NG(?sjJ({y*xhsRGfd4Eg~TCN3zbx=&!fkLM?8(V2_U`+siP1 zz$kc-;EkT>#>gpxmr5Dlp%)UR7M`!i648Ll`DtxWon(x^5)GS?-Hs9rVXAHK+_PDL zJF7eKD-kHUWE3zIGL=>Y-K&h%Br>Uk?sU1x$ z;_Vt3V?hm=6gpjq;p(qs)C$LXUs=V}VEfyX(U#{R1kK^f&U;#`)=O=#qDW-N@(t%q z!2o+I4W`ur=S3>T_~c#-y(Yt&-+i-3pa(IqdP6}HK2d$|YmHgTy0q1W$;;i`B3u0g zCDPopKYD5~5JF{9=Ot>SWI6jp8&CdNsRWzx8Te*o2faAt|ZuAY|?>IQ6jgbQrmuxQY=s05UZUNL1W{Wi-- z?w8U9+i&3B65N#durF4HbulG<>yi?rEd)Rg;3jNk9UJ0IvN%dB-{6Ac4<5W+WjLhTbZH1ipRx?|T52;8-XE{E&;(q_g# zE@1V3^J@j(GgNg3;!)Jl?pRZ`DMzo%PA={w2}6TtpFhN@@sQ`QdFJjEDbceJ+I=(m z5!>h>BoI9P?ca!fy6GDEHYpOM%P^grp3zCOMtF6oLaI9jLKyNoYsNH(#fzn{HPhZ5 zSr0H9_9^C&hcCs?L!R^(8l4}U`cg=w9_mD3tL!j{&vD2BH5v^b0w_EK*AJo=dQ{~PUpM|S zrKlMROPxh-uWp zjeR)u+^tyi?NgYd$`e69k!fsTgDG|ZToXS?m3mK?GlJwop)qKRR{ zR&Rbb?^0xDU010at*^b{VuJcxw`xAPLVw;=V48d0K{%fy5|NvdSryarWL9$84>8jW zF>Neo-kdou0UbPh46>Er7z7@?SntzkjM*kZFa}`7Ugu}CQ@n|6X(8i?8Py9V-q}Hj zpq69N&@lf5$rx>qr^qkLMgc!EHLntxR_p0xtgcuTPrC}pbn3DUwCvkQtV2u+>vvm* zJh;-R;s>?3?l;rWy?5vji_c_J7<}{gGP521v&Cz=MLQ0OJU>0J2KZb;>XHh1rHEoq zvE4`xGaeKUq0Lre0cs9f3Id^<05%W~RA_kkTa-R5XwscC>yMNXunBu#Q;J^)jS*PP zKJcWx(l80t;S~E!vi9+rz+G07%^I_q4Ikq)0aGcmSV*9OCN0O*{z2skG&1k_f(>sR zHWk%EHxI)BHz)dfc*biY zNSq6zmgUZ?pM7~m9qy-OFWQkpl{@`JCqlmT+wV%9fBIHsG~epVKX7m)+pic9I_?h+{c*p9 zy0zb@hZ<`{!v*F@RdFTg-Jho(>H|NzwXTQOziaj#->Cygt0kV(Bc%*^jb}IST&nF( zN|@}J%AghK;33CJ8sSDdQUaNCQfqg!cxg~ckQ<|1iM5@Y`@6&dKcALGt*eZw z79Z=KdG6r#S?Mg79BbT(VE0fYwqBfxdr;B3ySnjt^Eh2i1~KXDDF8Jpkm4CSBluRw zFmaH^LpGSrANPqF&4IQXywoOsFbmz4w5A+I6nDpXr>C)BXI1-Zofo_aV#C@XT0$e# zFaxHc3yoC~AU{N3r%-6vaM71=1jMq@pyUJEI8%7g1(6r{xFwBceM%IuT~E}|Ar}s7 z<_-Bx7LRmfipUz2%AFJ{a$t&!HV~hYXjorWh(L zNe?y3bK*Oa`Wzm`mj8eSLTqU@T%03aac;-X5;MnLmu+2tq*&90cvzCo z*hw{CNXH%Gt4LJ?lk%T%uX7aUA8J?vYYg!z1I{xdZS!FokToPc9haI`MF7pOPGBL( zS+(SE+=1BG5UiQ!)$Tttq~n|cx34VRlqxb0ihPpD!Au6GSf7QJFBJ&(0W)T+3=F2~Gp{<@A6R0t zAE$~5O8tG8iL9KTZ_e+MmJt&tzi0<{pR2D~Xw2w(MhtZ=+#F=DUBOP69G4Q=)pTLPI^u@s@4FC7O=m%d^ZZFsm_5Q!U z$nO8-i&%f@4`1{a@-7tYi$49s7o|XbHI>5-xshu9z@F8>_RAan<5y)Qid%Pn&Om6l zy*&Bg=fT^<_C%j3?}Lm-|MdfgGM!Ott=hEN`1&*j5m*~0&xexhsT)R&`lODBxSRB+ z-5EAZ9CKmaZGxMOZIUa+a61=dxT3+!eK;e;V9S{jxQ7&Ga{>z zOTA(hr^}gADwx;TbEmgdGRJ!VOm^+QSWU~9pZ@B2Km8|Y? z2d9;-eHOrVD=uMNKp3I!zx9S>M)H^+$HV&4mjpSjZ{)(xLO`4# zP2|D*ZlFa;i2Q=XdF2~jlj>GD%S`7Y(q52d9S-6S66l+XYSCD&i9VYa9xgt z2ECSRweN`OhL*>us$?2FuaZVT%+@`OFYUzqhw^}q>G`lrH*v4w*z7vK6J}bh#kVdX z=RHE71a)>ml6PuZAL-7CYxG$=br_;CTgt^H?NmR&fwTQ3-a2nIJCgBZ;}e>{5M3Z=nZvCGej_TS5VxrJR38vn!nH z{bSjh<)Z$_@S~kEh$ost`c-V;ds}Pw>|sPG(IvPL*!-)Qk{AbIFWk}*lqqEegv#3R zxiBkF4j0ta{ebk9s}P0cy#T2Z%-wA51m3B@#IdQZ+Y*_y!PbPZC{rK->jG2?3_JRI z(=ur@3+S#RjZ4DdC#O^zl$CdSP$Gue0#9E2ITD(#fe_(!3UhN=!#B8;|W=d2xR`J*AU zx`O7&E~bmvSjU5ul{4o-GO2Z+G^uPI)%?&0af8~4XZh21h<1dEDJ*QAB6?nS6)}oi zRR(KUGEO*j7MqF@GI=3(1;vjQ(cDJNYcY52>-%MxYrlE9oG!Lo<`D>tX>SH2TTpl@ zu-}_P$Yl*lF48C0p^JNKCuWF}&|xP-Zo-SB2)y4Pvjj_=+4vrWFv5LaEj!1Z7hYir zK$+M$s@cwH2#{Uuhsh+FGibOGKF@3)|B&=Ly2HBW4H97n$($bQXNQz*WXsf39G0Jm zcnq3M8o4g*omiGYT%j{Wsu%RH=3Kvgh`;g!ih_}@$rcUi;eYQB6BwhQLJ$CeU!npa z{na0O{Qd&cf8h@ef9Vf@xRK({3jiP}{f9q1`o;zUj_f9XDB|w%GyL_e1^}RIk#Bgf zDULdtj6=e3q9^1us21ORhcPJCE|iZCnW-n)ggrPlb&}PFf2VbPAV>Y~eHLkQ*6ZW_ z%i1}ClPA3R{iK~6o5Y!1i-wGsg|7Bl0l4<}7v3BpA0Pcd3bSj(HCqcZnT^8tA}3^3 z51Fe~xuxt)!cgv=wOIEKL-(Q&Vy~&Hg>^;FJRWF;I>CuqQAI#j& zClQh~(u|PlUD56txcS1S98Oz#L*8TD_icZN^Lq>D>(Gu5*9=!xMo_A;v`;;@sQ)9H{6JqEL4CFmO9#}A_mBN_YeTWsXigTUF<6a zpj(}aCq^`G*TzYQXo1~DR9OK(4QLCkAbYPPk!z&T z5k{rDx-!v37H-O=F-jf(p9_G`-j_2q1n^=RwHL?gFvL+F7;bR55&_eP6@M9 z%3^3O=w58K*_(8C@t8xw@gmp`4b5eLhk6LC4A8xkGZvGHLiF1(z0~iru+a~4AL9`w z;D^zZM~{kA^wql+W@pcuLq)!{^^07LP93Y9lyZNieFrW4_OSdLN0$T< zDN`L2%g2hma%ctEiSg;y(#Q^h;Rd=@k*%#QpRLqP9Ih&Mx6m|fVUYuR4^oc>{x2s|*%Qx0u zkK?q96uqrv>533S^nni^kMx4|F*TX)4u^*~qLC%ey&pXRgCM&zvma{|I)2uknsv$W ze3uf3rV{h>2Ydw*;N*BEl|c$a3D<43tP@`3g)|L3FPthfvl*wqW(&zk6hgg|8^t{jj;uOLOq`CsTgDVy%=Q#eA=-pVZ>>NBlLYzTvsxs9^@3s zlc0~THNdn#Lp{V|B}N@H#k~;K!`%0)DfCW;J$eY{qw_e(NgLt(U4S`+r9sE#>FEb- zDA|`!G6y-{!9mIIVNV;rxuIiYX!{V{H^!0cMZ^YjLm*1OPF>}SWK6jkXLS*Mkr>kpM~KuCjz zv34tAtsAjW4m?7tLjnz>8F<-(EYiX&ni+tVS{ybTD4aYazBL46kT>x_f0QODhduRxGuHH@Xe!J>3-aiVA6=kDiRpoJFLmHd z8sc7%dww7xh!X@=gOB9bGNO~)Z^MCuBY8W0(Ls}6gh?7D;$P7Glk3l4W1wgFL0b%7 z%7&3f@OMJr;lRU>i^#ZjPu>+l)R0r)2vfJ53N9jq?qtQ|<@nQnJ!Jiy9R%?eG_twH z%Gq#^vVPi^2at-b)?n9_YQK76Ijs(rPDj8{A(HKN{Q^T z?67?S95wsfSJ<%#I&&=#tkd;;IBG`FuW;fKv|WycfG3aY7%j^EQu9BG64+}iEYgif z671kJsBNC0_#e0u5T2ksin1f8UHSQI&R_8{2FF9Y3NF8@ZcB$3_QV{sl>yoCk{s_L zu{#RkL5E>NPRD!%EK4T67eB>s@>2u}+<%OWP>3&=d8%Tzlb(k;(FMw3q-GekIbkfv z+w?REzV9$$mMdLC)Qx(iUMdfEm5r8LVrY*kP^$DDo*v%yA<2x)dKwQ@+5ETUJx&-!*v|&a>`l?nfd3vk=oRE#P~Mtc3_|hLWb~>%cWdW6fyIGfQGla=>AZxs8e_x zISo%5c9dV^ImAi#lYw4x(WzwQq&rPX-YD9=IL|{u4Ggv zj)6VJpO0X8dGi{O<#{!-{9^O=+1G8|+F(hB3Q6}&;o&wVa^(W;2{MJ=_sx%+?&Y-) zEP__dKo%)PgLmH*){QtS%Qf)mrYodqk=HI9eBbRSoS*&({8|(uyq&84C~)(b7h@_Kyma4SmO%(-HbRyRDJ{6?)%Fy0$y2xpr^z8TWhQ z@uS?e)?G4gm)=@YrMmKym5sIFhYnUE?a9Uhx&_4C%vSfv>m#1;0(S+NH%HwDo;xJB zaEwhfaQFbEwa>{BCPLYn@R56aZ!hjdSxH}Po19~g9Fub`660a(8mBz=(#MTHVcO}H2-(lrJ(zEqO5kb{?F5P%txD>E8%^Z@@;mS?$@ zKbA|PLU?e@)tSbO7Ha?fiq5z`r6Cy&0Qe>9e?7zX_#Z6k{;M@z>@WSXrrVX?XaWGx z^#8f03;0zgFz2ZRuLKhCmkqc`A6n1%kri>E;bOVrxYL`Nd}SB6zo9u@AiO?&5r3Qu zx^+)swqokT6n^He=X}vxh58lkVweSPvBxzb$#}aA5ZNc!coDK&4#wcP(lgPa!CtyH zIqp^lnhxTA5$&z5JL*i^5X(C}p&ON{>Z5mj{$CH^RM+c}e8;)j+BF709p!9?T$WDQ zE+e?jeXW$!yXJIlO1?K|(z(-GHS z#YH7_krY{{>!5{Vf`yfioqu(nyOICa%*@8$|D{6dMSU%j1;rH;EhJxGG&vRKb=Jeo zTBj3$tOH-)<+RyhEIR}?w@4W{6L`Q{Z7KOHCqSm1b%D+_1Y{9FHPywsJ-k=p}C91_Ez8rs_)$ZpOKH-k&C2g!F-iP7}zpW)0h{nBZ<|dy^8|>P3!d7)1h~}A99N)I<2}dfi za<-U)Ccl3_YO!_E_7pGHy^9Dji=*@&xfHFBF$b5!X#mo{0x|*e4ZLiO3>uuF#|YHM zaT&Q4dN45Zn_56MS}`46ZLGNH&UzEfAa=eqO0n0{WJcjZN=QORNQ%DX?)gk%E{nA+ zMpi#UvD4I-%)-;X$r-X7b9J;6Fo=RcbmkPo_u?BGbNlH8Z89$2wV9vJ0`sj+IS-%6 zuIEOn;9cLic9wW7OXcXacvi`-Ak!9or_Z+wYP1I)4`MOvW-A4g(?Qx~KRJ{0iO}RM zcih=0hfK?-Q`+R1efL2#UTlJB3Qhw(iAKX{D+wt8$JnC24YX3b&h`Tg~wF`Rci(p-WnZLwiVXPcI3H zQEQ`U`%JKvw8G>R*{Qw5e$o*Gy-x!<7!*ESZoUg9yH9`A*!e|-PU!F^GiL@2MTe&<8p6sa=#dT5htqizS-wauvaua0Xcs=wEKcg=3O^!QlBNDz%0DO}%AO|_v$ z&d!>sBy4vB&j^?d$t=82=}l1ET(``vWWJ$fVKfczXd2AxZ7t7zXo-OJK(Eue7F0>t z#q)?`W`M$==*Qrz_pXG?&u-bWQV=rxz}u!Psjm&qIZwUljs-CpR38pNr}<%#XDqL- zXfaeWVkLTikT;N5$uB%Ty4A*#v7qoao}gvAEX}tds{r_Y5l`IX|z zknF0yAMr+7N&^k6hGoc1Ezzk3?g0>LLG}Ac^g>P|hslw29!7Q@havD?swhc{+UB^o|P>?N?u}B^Myoi8p%ZjzJt(zWN3zyHSoPuPbNj1Am>~tc}^!F zgf7YQOasz&FI@&}@NIJ5Q&UZ;nBBkLA}5EMl?jAXTQ{1eTr`Pd$><&a)NVSz?Sfsv zy$E6(tP8ibivzS<-%eTz)1J2UUp^e1T z;UzxrRnOf_b^bi0xX>K*6?M8|6Ly7JFG>5bgojFVH)PBrU`Z581?OQoBtg^>HU-sC zBfMY4?u%o63!Irw?zp!+Q+-Xl$?euU?(3o6ndO1jQy+Y?yW|I zM7sjiJs_q>HIaf_Xk4!xPa3!g3A00hLgKx!3@mms>FfcTgg~lNE%VO`WM(jjk3t4(* z;SLiOJC!K2Ty|;nFS@hiPGwVSo^;ZQCx<_^SsxJHgR7%e$aVukklMkUb-4$nU#hA` zw_hgy4g*`)n|!e{;h<)PY`jdNO>us^4mQ_Alj=#LbD(xu!Cg<;?v~2SmEa!eSKX2O zQa2l5`f4Xi*(4gOrt=wsd?{y3V83IHu{MHyU5q``2IsyO-iE>O^~F=}q{Ai`mvQHn zt11-0)bA+)9RPv$22XHX?=epjS~Lt0)(xPa6N>`GhQqjM0iXrL3h$ve)u^U4DCR5> z(52AA20TS~D!PC&XvGVcH1Az;`Vx+7KGVQszy;A2vcZt|ZY65N4+6*uVj}6FXSx6o zDtZtFpMkGi-nani=Ar0>*rB()U=SHp;bqTYC<{(^<0~04AT&`|HtIc|+UCzo>%0Pk zpGcn;cbpM@kJ>b#HG~a0MD8hw!|SiiDB-a6gFk?4S!_L2-BreVA0MjJ{o>jc0}+FAsy zLCs$9Or}{e3i!B#j9d0Uo78o}5KBq;H#lu&b@e&V=C@hjiyxf~@=Ud}jw<_@gLb;~ zZg*UGt)Kg z8w4pehvTP?`yg7(RY5F0Hj3Rwffx`mr$V*KSIq<`V$?O>0x5xk&4pA;n4Hs!$fLcd z4Gc8-?WUeb?ko^Ep$(MS0}d}-6z57Sd-p>(d!YMm78YcFX$dVn`kTiNR&dQ6wO&6s9KZTOzLm^WL`>2w@p5NDK|8=}{#h-9xLoG7|({W(|jd4Z<$kuQtd zm4rgimBEgbBa4;}hx;;a_QE&$6N>#M|NeGQLf^E@?H*2M&!0uy1|jJc3llDzl)lc8 zJ$+$*2)4@BHVq}dbprEu&x(D#Lk{1N-aQGeY>g9Pa=0Ifaj9;)Uu8dgoS@vh8!L(& z!-dxGKew{4z-{#|d%eMO0tBfpB)c!UTYXj;c`)3vYk6rPy@B96>!|7-eCjx)q7GlS zz?O^R2SJ54?w>w%6AF_Pg~cx9WCDU%756{L%(CQuLi@2F7}vP}OoX%w#tNtpd)ZyK z>q@YzD`Vhl=Cb33mx-$J0HX@0>=HjLGzKdc$TqgrygE+0i9i+#H3 zU%T%%K1qVaM?580YWZ3*ZPSH-&_&*+bn}A<+#sZ|x>(Ev6#yC%tJJ-1E`$~h*EV(4 z=_xlvA2l&dRzE;CKvpdZKYwA5>c^KAk!VH=wl z-H`8<^Vhfy#d^6>y3{~cTR@KK<^J3WDCUvAvrbp03=bcMEb7Dx3$i?G zLbKdJaLJvC#eYQ_+69P$a*Q49#iD`c6b_AEkjW1X(uT_mFhN4YB=E0u0aE6d3_>fK*o#8GQqMsSfFR@nkq1|QLe;%! zjR#oI{roBU^(9v?4nqJjlP_4A4<>gE4|sm{jPCBMcHz&p>=!IPlLGaMea^=4I7TcFT31~Qr=Eyivb~-s^X-lM6~Y&3Q>Iuoe`B1EGA}Y9y&;8SWc>z9^_8_p`TJlUA&>q0aWr8nARWF!7X9b~sEk#CR*?8wMb7;~NF1##gTg={h?JekLRw$UHbaY@LZD zny)wB8noX}9%vr`ASqUTqnq8n`(&0o=ScvMG<5rUDG=({2O0|38713>)OllH6aw|ILam8~}iw^t*HX zziiq`z5~3g4-VK(Ds8@)VV!`pmv6u?_I&HYXVM)KJ$|Ux58j+U(hzy-!+f5N2#w5v zkS?m5;MW$Hj5w&~)YrmfDA|*}4l7luNVv^`B;LTY@=r;@WxQhcWGG~|slcF90vHrU z`=_@Gux7>sfvfiwIFKacl9i|59D5AM0Ft5ObcQo?bLP(Jj+>0U6R%dD9ag+Zy2f<& zc%7h)v zVz-aS*%aGj%l!*H&1N*HO>Z)D%;3 zK&uGJ;baWktA6J62t($S1e>g&rSNa{-CPW=_klJZSGuqEWA1X;GSeB$e3PArA4@#> zn0=tcm~z~AXPC~={OEPHHaw8;zt?=#D~70dyoNI|8Uf(K-VxWweG@n>=&C=`);x&O zdHPgZhGslFNd0)wH4KGNHDvVjAIbGX4KR;QfzZcg-|tO?iG&*3+jHtJp-H{#f`24V zw;NFA8uL@Ky3YW0c^}k;Nj{8AcoPg2#$OH``O5HY;O;Y&3mjU!e=3=Pa?^1Nm4I}X zK`9Qd6={~BxVbeYXoBg=0x3VSWsi+1Nj0D|1Uy2bzxDMy z9lxUDM-)X5Shoiw5pvLOfvcH{h#=CpXy7!+kf&WLq53Qk#q6gFhR_R!ETNF23}*pz zv$yGXWoIW#^Bwmz+jweo4&&|dD>s+(FW;ZS&m#rVsJ{okSYYqaRx%ionF=Lb&aJ-8 z7>qzb8A2#>vPi*o!D}~{bvfwWbqm+Z*N(rK*ZAa>j zjuOyq@^=asy)w_Xv4&7T?j7_HI$c~hK725uWK_;&EkFnD!e#^7{aF*yO+mN;0w_O862JRW6$p{o^?W&KD-~HxR@(@Skvuc{^Y-2D;HS2tVT`e|v-ug~ z@{I7uFZK=krw>~c64@M|b!W_S!eiAC=-dNR2T%OUc8Uk`R-481h)5XRVZRPd!%$Jo zDX~EKU1??mguAvC(oG{acw(d;3;a605zU?(d}(J6j>YK_9s;-4)YRryH>Fw` zZT@dg!l;xUIj}Ry{i)X%5!-{eQP<~RU5N+vupFqlx`iT2vU$_0J zK56Z5;`^Zrhjvu0yD?n#sn{hvpIaoXoHlDcO;m13A{jQ$UwFE-h`dxMk_swFI?{^u zN7D)nVMEI<>qwq1i|%N>Pet-EMJ%gu+Vjj^rlK@``=0AbB@NmK_f#9O?DjMzL*!?b znn1!2H)s_>^z#w+a-ENlTE%{kxT}bAYkrN}cP?495nF!o%mAQ&PmSjHW{rrPqn(yA zVBE*7sAdxZXtD0JaIA(Au}qrxa*w%}EbfPtBXnOoH30JntZwj+7j-CVXr+hBGFA50 z2s*dK5(#X9_7u3U1!hAwkv(mn4$G4QV>G0*pnTGfY!5;U-$m(!3539_l6V?*pn*6r zO5uv`GAWy(e_B9fLxb}CA=h{FIueH(LXUxs@kG2#A=wB|~>zWxAoimG#`ev)uFCL8`jyxa&~H+zK7b z9siyN<3WH8t;*;>xK|0;NiR2aXs>#U^rB#Pr3u;cK4_?g=+^$V*oSSLhR@;-xDzaa z9|8BCo9Z>S%G`AY0s>_q+EVp57*_0iOc(aV1_hs5LR9<7Hp+JsdOy?_(*X<83p5*bh|zAsL7y zN_0*K`r~b@Dv!H~>$Z8)qoiIAB-IBR|75LIA*g?L*|`+BSKAuSdtMHYL=sB;q<#BtiVq{o+lLj8w8l|(pgLk7BPX)fd zB(R8Tqn6hb^e@4UP!}@_jo$!V0AwW&tt`3-y2Qe?WKWw2=t&ZxC~Q7tj^BR3*&?Ak zODNj)C{5}Er%D-|*U^ZKXGDw?sIMWg5Wf0$`LI+ODwulq(M&F74}~nBPm0)vBd2d{ z{=ghX$$)gx_;?>JgARxbbU1-3Jf*5XMzg!EmwBn87bgDnMugW+gya<_r&}jk8V6>gn|238Xz0zDi# zjm-v3CgmoApA%qxFej_S>N8a&Pj~P8sXzv8GE{}}^rF1Pjdf?JxIt41h{H3pEf`Lc4TLqD#=5Uk_Km12UP+DHr{-mabnwV^3y`MmH# zh-^IdgkG+rRmz@k(Rk`*KUo$$I19`*^BRypUh*Iw8-iF6j~r7J@CEt}2DYgx zP#<16U`-OWGyUQD83~kwElv)MhL(iJ6k0&IWdq5;KcdQ$twF1%!(37W9*q&g;3Z+< zC?>E@5guO+oiao|3nP+ulFucBP=G4b6BrO`aQb9ETM1+tUE?f1zIa_?^u077l~0S1 zEh9rVz=)w(JwJkBWN52SUZ%!_7Wn&-n&FEi4sx@ODy7byc_!aSJYcdoGj;5zSVDdk z3ux-b>%#Yd_>p)d+x*6*%?amIy>4(rDq$S`g}P{B$6;k}cs6sVimUF#dMz2@$mFh~ zq1*`TXyLMB?wxM04xh8E3lIF;1d{SDGQ`iK#yC+0+3lTNchbwh~f(b3t@5ZQCp%-)NXOD(}JAAJbP+Y zY>L`rox`gbe^v_0l&u?OW^LfgwFTwvBxxvkRNtP$flc**1|yE5eO(y3irwMWt#w8> z9S(WxSqBP#_p*Ahe(lyeI2ExTUN7Dki#`;jM>lpClq_1v!314TsMuQV;;-suKbz+hK` z;6hv^9EhrfBnIq|y@p5qb9&@(#0+3beFwR|s0iN}m4tRB z2t)swpav8!0q4^xWIr5x-tRnG;)>kT0`#A;atvgp){fQ1xtfGPYzumifH!y7lr&Zj z@WvCs?3=2ODDcd1Hg^ajs=B=LJ6yXlT!ViHsGa54xk$G*o1R{iZT&$nBFpwk6re%* z0&$7C7aU?h>11D8bN9WHErg2#$uMYrVX3tB%9{+y$-)zDMhEZxc@ghfaWh z2)*4wgA{&GnkWpY`4)cdMrGpQLmxb$n(I>!*~OIP(uI+5?72Hsck&m>XEl&7up_MQ zX7j6~hThF-s5H7Heh=XeD%KvKxZL#SjQIs|Pn4~%$7;)C*ZHO_f?Hdh+{17sY;Mw* zFh*JGDWW|DEhi3#XwX1Bo97jwZOPRRUqn1hkivcx63O;;8{;S6O3haj#v;-MVcw{_un+WMP~`WkFSJtuyfx8d4QRZ)>DD19?iERk*MAD%tqM`}K)#05VHjk>_{B`r7-6l!AF7^~KW# z_wz!F4i>Etv3nl;F34Bb!}cEX$6p2WJ z_v7rj%#*_Y2A@0If>$}c;vvMgYS}~nl)Uj^^6fN_YoRObCzrV~tN?yDW?_{kIaUYs z(X1tt0^UPowLP|_ay+3u`zZ1T-(448ntsiK<7PqNr2{K4xDDW39e^=y2b1y*+yrXW zXG|JHf!+d`P(0;R@TU!*efxwU@Cc9{2j#H+=D)?H-v+OdKjC!liE)7yy77toLAfgH;dK99U0ak-!1~{9tK=xWnpRN;_T|;W@Tz->ud(VAe_jasG1C${4@!ijGAZ%|J46k z69R&NW_@F3V(DUL=HzZ>Z!1NzYS&UeyBwr{5H!ovCl zO7Jc@D8C+*-wDcZ02PFS@*6?<9iaT8KVlL=1%aUaeDXi?CjODH36!7xN4k&T3;ig1D?qp_f zW%iGJ06dzB_=%>;aBx3Q0w+2q8YjcRyTFP1U!6Z0^JmYIvX3Q>1INn8TE>IMs>cJz z8viVLtZl5Ge5`J)d8}oudaQXo8XQsmD;0p0^q(W<>KWc9VPN3=N zR0A%frWsU|_{VYZ-+(`lH~kTn{|6xt zR2u@GFq*&Y*@)b>r^(aJjNIJeKPMBmAgH|<)ZPMWPXx7n0=4IY+S@?wk$;2(e#QR^ zB!b!#{s`p!5zhT1oC0c#0uT}Z)6A@l{>fxAvNd(EH|ni-0ANx5b4ZROnh$G($PXGK zj}tSH44xL{iys$?7D~w%^8RhsfaeT2K`?lyW})OSe&;`J*3`_I{8yZdGk}B#JmWhi zY9?C$nD^;_7+weYMCUJS{HHBWrh(0KBA;oZ?hhMnny8zo0ucVwxX8skoqDTHjBI8%EkP)qhzy7i|2IRXA*|9z=u_C4i)w)a2hFcZ0>nUkHBvx76ZhUCsx zV3V~o0^k$=^Eh}UEsX3foZYN?s*HPTU99Z?;!ytL0Bnt{%x>Ywf0t!>?{uip6@v7Z{d zU68!h+5kYt{_`N%r)LK4gqLhrC;m(QRsXB{|7*Ul5jzQW+rhzqEF2)oA&7AS0RKPM z4o(0I05iZGUoMk%QMl;3W>g6#V+n&yjzv zyCebs5&w?}0Nr2mfR`-*bg-bIja*!u0I;zCu>=4IoL!uLy_@}mAqx0U<}U{IUl^!h zAtC)`r2v43H*$0|us56{7_?CZ@kNF$E+u!74{f6&s^Y{4Jzu~+7O+LKUlTY{?zWv|i6a9v7?C|&a#J}O2{Y^f}Z}>KUlTZ2^zSH02 zll_M8{NeBM$$!H)a{7CGir?_f|0bXEH+<*6$*1}a-{o)esei*aasGRJn&0rv{wDv` zZ}?V!lTZ7*`n&u+KHcx?|2O%+J3q4hn|%7;@E!jq|MhS9X0Csa&+r?*<=^Bp{)X@L zH~CDz;XAwiJ^t_P$KF<4>hJJ>x4!bSlvVgUeCFTsC6xaTpXE1vM;Gxoe``PgD|+9m zWA`8a&#SA9%-ZwUx@KFv4J(I74g=bC$a5NGV-&vqpB(M!<=o@x7dgw-(~nPh?x<-~Ws;^ZM;4IWjXGGWZ|Ys=MMp z(+9WMH|tyC=kIN=w7Ghq>GHAz|5q^`isuM=AYoX2Iez-dsXcGjo;f=wc82Ynod16; z{@wSi|2v)C zPqI@F{C{`T_+g5`72Ugr|3`e?^Q_42sD&aG0E!2fo;zfDOPS z2N*cOq1#g{D=fjnz#s|?a80NUz-!|f-Ztt1DR}6Df&=Pym<^=`DTyVix=ERNiA9z8 zLmy$?#JNsCW&w>?1RAb`WZja+m8=X51*t{3nZ=-6%8TRki_-O=H`YU`0{CU-dKJ0A zLrw6-H^RnW$` Date: Mon, 19 Oct 2020 16:11:35 +0200 Subject: [PATCH 04/10] code formatting --- .../views/AppDetailsRecyclerViewAdapter.java | 30 +++++++++---------- .../java/org/fdroid/fdroid/TestUtils.java | 6 ++-- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java index 6bfe1d2e1..7cef1e286 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java @@ -9,20 +9,6 @@ import android.content.res.Resources; import android.graphics.Rect; import android.net.Uri; import android.os.Build; -import androidx.annotation.DrawableRes; -import androidx.annotation.LayoutRes; -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; -import androidx.core.content.FileProvider; -import androidx.core.os.ConfigurationCompat; -import androidx.core.os.LocaleListCompat; -import androidx.core.view.ViewCompat; -import androidx.core.widget.TextViewCompat; -import androidx.appcompat.app.AlertDialog; -import androidx.gridlayout.widget.GridLayout; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.LinearSmoothScroller; -import androidx.recyclerview.widget.RecyclerView; import android.text.Html; import android.text.Spannable; import android.text.Spanned; @@ -42,6 +28,20 @@ import android.widget.LinearLayout; import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.DrawableRes; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; +import androidx.core.content.FileProvider; +import androidx.core.os.ConfigurationCompat; +import androidx.core.os.LocaleListCompat; +import androidx.core.view.ViewCompat; +import androidx.core.widget.TextViewCompat; +import androidx.gridlayout.widget.GridLayout; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.LinearSmoothScroller; +import androidx.recyclerview.widget.RecyclerView; import org.apache.commons.io.FilenameUtils; import org.fdroid.fdroid.Preferences; import org.fdroid.fdroid.R; @@ -405,7 +405,7 @@ public class AppDetailsRecyclerViewAdapter whatsNewView = (TextView) view.findViewById(R.id.whats_new); descriptionView = (TextView) view.findViewById(R.id.description); descriptionMoreView = (TextView) view.findViewById(R.id.description_more); - antiFeaturesSectionView = view.findViewById(R.id.anti_features_section); + antiFeaturesSectionView = view.findViewById(R.id.anti_features_section); antiFeaturesLabelView = (TextView) view.findViewById(R.id.label_anti_features); antiFeaturesWarningView = view.findViewById(R.id.anti_features_warning); antiFeaturesListingView = view.findViewById(R.id.anti_features_full_listing); diff --git a/app/src/test/java/org/fdroid/fdroid/TestUtils.java b/app/src/test/java/org/fdroid/fdroid/TestUtils.java index ef5a46804..170f03f8a 100644 --- a/app/src/test/java/org/fdroid/fdroid/TestUtils.java +++ b/app/src/test/java/org/fdroid/fdroid/TestUtils.java @@ -7,9 +7,7 @@ import android.content.Context; import android.content.ContextWrapper; import android.content.pm.ProviderInfo; import android.net.Uri; - import androidx.test.core.app.ApplicationProvider; - import org.fdroid.fdroid.data.Apk; import org.fdroid.fdroid.data.ApkProvider; import org.fdroid.fdroid.data.App; @@ -97,13 +95,13 @@ public class TestUtils { } public static App insertApp(Context context, String packageName, String appName, int suggestedVersionCode, - String repoUrl, String preferredSigner) { + String repoUrl, String preferredSigner) { Repo repo = ensureRepo(context, repoUrl); return insertApp(context, packageName, appName, suggestedVersionCode, repo, preferredSigner); } public static App insertApp(Context context, String packageName, String appName, int suggestedVersionCode, - Repo repo, String preferredSigner) { + Repo repo, String preferredSigner) { ContentValues values = new ContentValues(); values.put(Schema.AppMetadataTable.Cols.REPO_ID, repo.getId()); values.put(Schema.AppMetadataTable.Cols.SUGGESTED_VERSION_CODE, suggestedVersionCode); From b316eab85d4f6fb4d2eb2d3b4f3bf64c70534d76 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Mon, 19 Oct 2020 16:59:32 +0200 Subject: [PATCH 05/10] post-install Intent to tell OsmAnd to import "installed" OBF OsmAnd will import map files from a file:// URL pointing to an OBF file, but this currently only works for file:// and not the proper content://. This uses a hack to disable the warning about file:// URIs but only for the final stage of installing the .obf file. Hopefully in the future, this can be changed to use a proper content:// URL as I suggested to them in this merge request: https://github.com/osmandapp/OsmAnd/pull/10043 --- .../main/java/org/fdroid/fdroid/data/Apk.java | 15 +++++--- .../installer/FileInstallerActivity.java | 37 +++++++++++++++++++ .../java/org/fdroid/fdroid/data/ApkTest.java | 11 +++++- .../fdroid/fdroid/installer/ApkCacheTest.java | 4 ++ 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/data/Apk.java b/app/src/main/java/org/fdroid/fdroid/data/Apk.java index 89b1cd72b..abb480930 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/Apk.java +++ b/app/src/main/java/org/fdroid/fdroid/data/Apk.java @@ -569,12 +569,15 @@ public class Apk extends ValueObject implements Comparable, Parcelable { String fileExtension = MimeTypeMap.getFileExtensionFromUrl(this.getCanonicalUrl()); if (TextUtils.isEmpty(fileExtension)) return path; MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); - String[] mimeType = mimeTypeMap.getMimeTypeFromExtension(fileExtension).split("/"); - String topLevelType; - if (mimeType.length == 0) { - topLevelType = ""; - } else { - topLevelType = mimeType[0]; + String mimeType = mimeTypeMap.getMimeTypeFromExtension(fileExtension); + String topLevelType = null; + if (!TextUtils.isEmpty(mimeType)) { + String[] mimeTypeSections = mimeType.split("/"); + if (mimeTypeSections.length == 0) { + topLevelType = ""; + } else { + topLevelType = mimeTypeSections[0]; + } } if ("audio".equals(topLevelType)) { path = Environment.getExternalStoragePublicDirectory( diff --git a/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java b/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java index 23b804d0f..b8e513170 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java @@ -5,8 +5,12 @@ import android.content.DialogInterface; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; +import android.os.Build; import android.os.Bundle; +import android.os.StrictMode; +import android.util.Log; import android.view.ContextThemeWrapper; +import android.webkit.MimeTypeMap; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; @@ -21,6 +25,7 @@ import org.fdroid.fdroid.data.Apk; import java.io.File; import java.io.IOException; +import java.lang.reflect.Method; public class FileInstallerActivity extends FragmentActivity { @@ -161,12 +166,44 @@ public class FileInstallerActivity extends FragmentActivity { Toast.makeText(this, String.format(this.getString(R.string.app_installed_media), path.toString()), Toast.LENGTH_LONG).show(); installer.sendBroadcastInstall(canonicalUri, Installer.ACTION_INSTALL_COMPLETE); + postInstall(path); } else { installer.sendBroadcastInstall(canonicalUri, Installer.ACTION_INSTALL_INTERRUPTED); } finish(); } + /** + * Run any file-type-specific processes after the file has been copied into place. + *

+ * When this was written, OsmAnd only supported importing OBF files via a + * {@code file:///} URL, so this disables {@link android.os.FileUriExposedException}. + */ + private void postInstall(File path) { + if (path.getName().endsWith(".obf")) { + if (Build.VERSION.SDK_INT >= 24) { + try { + Method m = StrictMode.class.getMethod("disableDeathOnFileUriExposure"); + m.invoke(null); + } catch (Exception e) { + e.printStackTrace(); + } + } + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); + String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension("obf"); + intent.setDataAndType(Uri.fromFile(path), mimeType); + if (Build.VERSION.SDK_INT >= 23) { + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + if (intent.resolveActivity(getPackageManager()) != null) { + startActivity(intent); + } else { + Log.i(TAG, "No Activity available to handle " + intent); + } + } + } + private void uninstallPackage(Apk apk) { if (apk.isMediaInstalled(activity.getApplicationContext())) { File file = apk.getInstalledMediaFile(activity.getApplicationContext()); diff --git a/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java b/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java index b20314926..96bda89ec 100644 --- a/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java +++ b/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java @@ -2,7 +2,6 @@ package org.fdroid.fdroid.data; import android.content.ContextWrapper; import android.os.Environment; -import android.util.Log; import android.webkit.MimeTypeMap; import androidx.test.core.app.ApplicationProvider; import org.apache.commons.io.FileUtils; @@ -58,6 +57,16 @@ public class ApkTest { assertEquals(new File(context.getApplicationInfo().dataDir + "/ota"), path); } + @Test + public void testGetMediaInstallPathWithObf() { + Apk apk = new Apk(); + apk.apkName = "Norway_bouvet_europe_2.obf"; + apk.repoAddress = "https://example.com/fdroid/repo"; + assertFalse(apk.isApk()); + File path = apk.getMediaInstallPath(context); + assertEquals(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), path); + } + @Test public void testGetMediaInstallPathWithObfZip() throws IOException { Apk apk = new Apk(); diff --git a/app/src/test/java/org/fdroid/fdroid/installer/ApkCacheTest.java b/app/src/test/java/org/fdroid/fdroid/installer/ApkCacheTest.java index 6c5c33dbf..1069e0d6d 100644 --- a/app/src/test/java/org/fdroid/fdroid/installer/ApkCacheTest.java +++ b/app/src/test/java/org/fdroid/fdroid/installer/ApkCacheTest.java @@ -54,5 +54,9 @@ public class ApkCacheTest { new File(cacheDir, "example.com--1/Norway_bouvet_europe_2.obf.zip"), ApkCache.getApkDownloadPath(context, "https://example.com/fdroid/repo/Norway_bouvet_europe_2.obf.zip")); + assertEquals("Should work for OBF files also", + new File(cacheDir, "example.com--1/Norway_bouvet_europe_2.obf"), + ApkCache.getApkDownloadPath(context, + "https://example.com/fdroid/repo/Norway_bouvet_europe_2.obf")); } } From 021d5cc1ffda3b43e9a05d2a2d56431fd998fb7b Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Mon, 19 Oct 2020 17:00:29 +0200 Subject: [PATCH 06/10] EXTRA_CANONICAL_URL instance must always be a String --- .../java/org/fdroid/fdroid/installer/DefaultInstaller.java | 2 +- .../fdroid/fdroid/installer/DefaultInstallerActivity.java | 2 +- .../java/org/fdroid/fdroid/installer/FileInstaller.java | 2 +- .../org/fdroid/fdroid/installer/FileInstallerActivity.java | 3 ++- .../org/fdroid/fdroid/installer/InstallManagerService.java | 7 ++++--- .../java/org/fdroid/fdroid/installer/InstallerService.java | 4 ++-- app/src/main/java/org/fdroid/fdroid/net/Downloader.java | 4 +++- 7 files changed, 14 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstaller.java b/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstaller.java index 4b7d3edc1..4003d042a 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstaller.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstaller.java @@ -47,7 +47,7 @@ public class DefaultInstaller extends Installer { Intent installIntent = new Intent(context, DefaultInstallerActivity.class); installIntent.setAction(DefaultInstallerActivity.ACTION_INSTALL_PACKAGE); - installIntent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, canonicalUri); + installIntent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, canonicalUri.toString()); installIntent.putExtra(Installer.EXTRA_APK, apk); installIntent.setData(localApkUri); diff --git a/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstallerActivity.java b/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstallerActivity.java index b60b498c0..595718b83 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstallerActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstallerActivity.java @@ -67,7 +67,7 @@ public class DefaultInstallerActivity extends FragmentActivity { installer = new DefaultInstaller(this, apk); if (ACTION_INSTALL_PACKAGE.equals(action)) { Uri localApkUri = intent.getData(); - canonicalUri = intent.getParcelableExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL); + canonicalUri = Uri.parse(intent.getStringExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL)); installPackage(localApkUri); } else if (ACTION_UNINSTALL_PACKAGE.equals(action)) { uninstallPackage(apk.packageName); diff --git a/app/src/main/java/org/fdroid/fdroid/installer/FileInstaller.java b/app/src/main/java/org/fdroid/fdroid/installer/FileInstaller.java index 853ff8329..3d86123b6 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/FileInstaller.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/FileInstaller.java @@ -51,7 +51,7 @@ public class FileInstaller extends Installer { protected void installPackageInternal(Uri localApkUri, Uri canonicalUri) { Intent installIntent = new Intent(context, FileInstallerActivity.class); installIntent.setAction(FileInstallerActivity.ACTION_INSTALL_FILE); - installIntent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, canonicalUri); + installIntent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, canonicalUri.toString()); installIntent.putExtra(Installer.EXTRA_APK, apk); installIntent.setData(localApkUri); diff --git a/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java b/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java index b8e513170..ab3e5c9f1 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java @@ -58,10 +58,10 @@ public class FileInstallerActivity extends FragmentActivity { Intent intent = getIntent(); String action = intent.getAction(); localApkUri = intent.getData(); - canonicalUri = intent.getParcelableExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL); apk = intent.getParcelableExtra(Installer.EXTRA_APK); installer = new FileInstaller(this, apk); if (ACTION_INSTALL_FILE.equals(action)) { + canonicalUri = Uri.parse(intent.getStringExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL)); if (hasStoragePermission()) { installPackage(localApkUri, canonicalUri, apk); } else { @@ -69,6 +69,7 @@ public class FileInstallerActivity extends FragmentActivity { act = 1; } } else if (ACTION_UNINSTALL_FILE.equals(action)) { + canonicalUri = null; if (hasStoragePermission()) { uninstallPackage(apk); } else { diff --git a/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java b/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java index 7a7cc2c62..abe8a784e 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java @@ -10,10 +10,10 @@ import android.content.SharedPreferences; import android.content.pm.PackageInfo; import android.net.Uri; import android.os.IBinder; -import androidx.annotation.NonNull; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; import android.text.TextUtils; import android.util.Log; +import androidx.annotation.NonNull; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; import org.apache.commons.io.FileUtils; import org.apache.commons.io.filefilter.WildcardFileFilter; import org.fdroid.fdroid.AppUpdateStatusManager; @@ -68,7 +68,8 @@ import java.io.IOException; *

  • for a {@code String} ID, use {@code canonicalUrl}, {@link Uri#toString()}, or * {@link Intent#getDataString()} *
  • for an {@code int} ID, use {@link String#hashCode()} or {@link Uri#hashCode()} - *
  • for an {@link Intent} extra, use {@link org.fdroid.fdroid.net.Downloader#EXTRA_CANONICAL_URL} + *
  • for an {@link Intent} extra, use {@link org.fdroid.fdroid.net.Downloader#EXTRA_CANONICAL_URL} and include a + * {@link String} instance *

    * The implementations of {@link Uri#toString()} and {@link Intent#getDataString()} both * include caching of the generated {@code String}, so it should be plenty fast. diff --git a/app/src/main/java/org/fdroid/fdroid/installer/InstallerService.java b/app/src/main/java/org/fdroid/fdroid/installer/InstallerService.java index fe38c8326..ba2bde3bf 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/InstallerService.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/InstallerService.java @@ -74,7 +74,7 @@ public class InstallerService extends JobIntentService { if (ACTION_INSTALL.equals(intent.getAction())) { Uri uri = intent.getData(); - Uri canonicalUri = intent.getParcelableExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL); + Uri canonicalUri = Uri.parse(intent.getStringExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL)); installer.installPackage(uri, canonicalUri); } else if (ACTION_UNINSTALL.equals(intent.getAction())) { installer.uninstallPackage(); @@ -124,7 +124,7 @@ public class InstallerService extends JobIntentService { Intent intent = new Intent(context, InstallerService.class); intent.setAction(ACTION_INSTALL); intent.setData(localApkUri); - intent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, canonicalUri); + intent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, canonicalUri.toString()); intent.putExtra(Installer.EXTRA_APK, apk); enqueueWork(context, intent); } diff --git a/app/src/main/java/org/fdroid/fdroid/net/Downloader.java b/app/src/main/java/org/fdroid/fdroid/net/Downloader.java index c399013ad..11c7320f9 100644 --- a/app/src/main/java/org/fdroid/fdroid/net/Downloader.java +++ b/app/src/main/java/org/fdroid/fdroid/net/Downloader.java @@ -1,8 +1,8 @@ package org.fdroid.fdroid.net; import android.net.Uri; -import androidx.annotation.NonNull; import android.text.format.DateUtils; +import androidx.annotation.NonNull; import org.fdroid.fdroid.ProgressListener; import org.fdroid.fdroid.Utils; @@ -34,6 +34,8 @@ public abstract class Downloader { /** * Unique ID used to represent this specific package's install process, * including {@link android.app.Notification}s, also known as {@code canonicalUrl}. + * Careful about types, this should always be a {@link String}, so it can + * be handled on the receiving side by {@link android.content.Intent#getStringArrayExtra(String)}. * * @see org.fdroid.fdroid.installer.InstallManagerService * @see android.content.Intent#EXTRA_ORIGINATING_URI From c0344c1eed90a21f588346dec5d035d364ee9a2d Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Tue, 20 Oct 2020 16:10:55 +0200 Subject: [PATCH 07/10] handle .obf.zip by unzipping the map file then installing it --- app/src/main/AndroidManifest.xml | 3 + .../main/java/org/fdroid/fdroid/data/Apk.java | 8 +- .../installer/FileInstallerActivity.java | 47 ++----- .../fdroid/installer/ObfInstallerService.java | 122 ++++++++++++++++++ .../java/org/fdroid/fdroid/data/ApkTest.java | 2 +- 5 files changed, 146 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/org/fdroid/fdroid/installer/ObfInstallerService.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f3cbd6f17..397de5db1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -263,6 +263,9 @@ + , Parcelable { try { File cachedFile = ApkCache.getApkDownloadPath(context, this.getCanonicalUrl()); ZipFile zipFile = new ZipFile(cachedFile); - if (zipFile.getEntry("META-INF/com/google/android/update-binary") != null) { + if (zipFile.size() == 1) { + String name = zipFile.entries().nextElement().getName(); + if (name != null && name.endsWith(".obf")) { + // temporarily cache this, it will be deleted after unzipping + return context.getCacheDir(); + } + } else if (zipFile.getEntry("META-INF/com/google/android/update-binary") != null) { // Over-The-Air update ZIP files return new File(context.getApplicationInfo().dataDir + "/ota"); } diff --git a/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java b/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java index ab3e5c9f1..e084ec18e 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java @@ -5,12 +5,8 @@ import android.content.DialogInterface; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; -import android.os.Build; import android.os.Bundle; -import android.os.StrictMode; -import android.util.Log; import android.view.ContextThemeWrapper; -import android.webkit.MimeTypeMap; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; @@ -25,7 +21,6 @@ import org.fdroid.fdroid.data.Apk; import java.io.File; import java.io.IOException; -import java.lang.reflect.Method; public class FileInstallerActivity extends FragmentActivity { @@ -164,10 +159,11 @@ public class FileInstallerActivity extends FragmentActivity { } if (apk.isMediaInstalled(activity.getApplicationContext())) { // Copying worked Utils.debugLog(TAG, "Copying worked: " + localApkUri.getPath()); - Toast.makeText(this, String.format(this.getString(R.string.app_installed_media), path.toString()), - Toast.LENGTH_LONG).show(); - installer.sendBroadcastInstall(canonicalUri, Installer.ACTION_INSTALL_COMPLETE); - postInstall(path); + if (!postInstall(canonicalUri, apk, path)) { + Toast.makeText(this, String.format(this.getString(R.string.app_installed_media), path.toString()), + Toast.LENGTH_LONG).show(); + installer.sendBroadcastInstall(canonicalUri, Installer.ACTION_INSTALL_COMPLETE); + } } else { installer.sendBroadcastInstall(canonicalUri, Installer.ACTION_INSTALL_INTERRUPTED); } @@ -176,33 +172,16 @@ public class FileInstallerActivity extends FragmentActivity { /** * Run any file-type-specific processes after the file has been copied into place. - *

    - * When this was written, OsmAnd only supported importing OBF files via a - * {@code file:///} URL, so this disables {@link android.os.FileUriExposedException}. + * + * @return whether this handles sending the {@link Installer#ACTION_INSTALL_COMPLETE} + * broadcast. */ - private void postInstall(File path) { - if (path.getName().endsWith(".obf")) { - if (Build.VERSION.SDK_INT >= 24) { - try { - Method m = StrictMode.class.getMethod("disableDeathOnFileUriExposure"); - m.invoke(null); - } catch (Exception e) { - e.printStackTrace(); - } - } - Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); - String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension("obf"); - intent.setDataAndType(Uri.fromFile(path), mimeType); - if (Build.VERSION.SDK_INT >= 23) { - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - if (intent.resolveActivity(getPackageManager()) != null) { - startActivity(intent); - } else { - Log.i(TAG, "No Activity available to handle " + intent); - } + private boolean postInstall(Uri canonicalUri, Apk apk, File path) { + if (path.getName().endsWith(".obf") || path.getName().endsWith(".obf.zip")) { + ObfInstallerService.install(this, canonicalUri, apk, path); + return true; } + return false; } private void uninstallPackage(Apk apk) { diff --git a/app/src/main/java/org/fdroid/fdroid/installer/ObfInstallerService.java b/app/src/main/java/org/fdroid/fdroid/installer/ObfInstallerService.java new file mode 100644 index 000000000..8673b7fba --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/installer/ObfInstallerService.java @@ -0,0 +1,122 @@ +package org.fdroid.fdroid.installer; + +import android.app.IntentService; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.StrictMode; +import android.text.TextUtils; +import android.util.Log; +import android.webkit.MimeTypeMap; +import org.apache.commons.io.FileUtils; +import org.fdroid.fdroid.data.Apk; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * An {@link IntentService} subclass for installing {@code .obf} and {@code .obf.zip} + * map files into OsmAnd. This will unzip the {@code .obf} + */ +public class ObfInstallerService extends IntentService { + private static final String TAG = "ObfInstallerService"; + + private static final String ACTION_INSTALL_OBF = "org.fdroid.fdroid.installer.action.INSTALL_OBF"; + + private static final String EXTRA_OBF_PATH = "org.fdroid.fdroid.installer.extra.OBF_PATH"; + + public ObfInstallerService() { + super("ObfInstallerService"); + } + + public static void install(Context context, Uri canonicalUri, Apk apk, File path) { + Intent intent = new Intent(context, ObfInstallerService.class); + intent.setAction(ACTION_INSTALL_OBF); + intent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, canonicalUri.toString()); + intent.putExtra(Installer.EXTRA_APK, apk); + intent.putExtra(EXTRA_OBF_PATH, path.getAbsolutePath()); + context.startService(intent); + } + + @Override + protected void onHandleIntent(Intent intent) { + if (intent == null || !ACTION_INSTALL_OBF.equals(intent.getAction())) { + Log.e(TAG, "received invalid intent: " + intent); + return; + } + Uri canonicalUri = Uri.parse(intent.getStringExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL)); + final Apk apk = intent.getParcelableExtra(Installer.EXTRA_APK); + final String path = intent.getStringExtra(EXTRA_OBF_PATH); + final String extension = MimeTypeMap.getFileExtensionFromUrl(path); + if ("obf".equals(extension)) { + sendPostInstallAndCompleteIntents(canonicalUri, apk, new File(path)); + return; + } + if (!"zip".equals(extension)) { + sendBroadcastInstall(Installer.ACTION_INSTALL_INTERRUPTED, canonicalUri, apk, + "Only .obf and .zip files are supported: " + path); + return; + } + try { + File zip = new File(path); + ZipFile zipFile = new ZipFile(zip); + if (zipFile.size() < 1) { + sendBroadcastInstall(Installer.ACTION_INSTALL_INTERRUPTED, canonicalUri, apk, + "Corrupt or empty ZIP file!"); + } + ZipEntry zipEntry = zipFile.entries().nextElement(); + File extracted = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + zipEntry.getName()); + FileUtils.copyInputStreamToFile(zipFile.getInputStream(zipEntry), extracted); + zip.delete(); + sendPostInstallAndCompleteIntents(canonicalUri, apk, extracted); + } catch (IOException e) { + e.printStackTrace(); + sendBroadcastInstall(Installer.ACTION_INSTALL_INTERRUPTED, canonicalUri, apk, e.getMessage()); + } + } + + private void sendBroadcastInstall(String action, Uri canonicalUri, Apk apk, String msg) { + Installer.sendBroadcastInstall(this, canonicalUri, action, apk, null, msg); + } + + /** + * Once the file is downloaded and installed, send an {@link Intent} to + * let map apps know that the file is available for install. + *

    + * When this was written, OsmAnd only supported importing OBF files via a + * {@code file:///} URL, so this disables {@link android.os.FileUriExposedException}. + */ + void sendPostInstallAndCompleteIntents(Uri canonicalUri, Apk apk, File file) { + if (Build.VERSION.SDK_INT >= 24) { + try { + Method m = StrictMode.class.getMethod("disableDeathOnFileUriExposure"); + m.invoke(null); + } catch (Exception e) { + e.printStackTrace(); + } + } + + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); + String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension("obf"); + if (TextUtils.isEmpty(mimeType)) { + mimeType = "application/octet-stream"; + } + intent.setDataAndType(Uri.fromFile(file), mimeType); + if (Build.VERSION.SDK_INT >= 23) { + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + if (intent != null && intent.resolveActivity(getPackageManager()) != null) { + startActivity(intent); + } else { + Log.i(TAG, "No Activity available to handle " + intent); + } + sendBroadcastInstall(Installer.ACTION_INSTALL_COMPLETE, canonicalUri, apk, null); + } +} \ No newline at end of file diff --git a/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java b/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java index 96bda89ec..93c5124c1 100644 --- a/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java +++ b/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java @@ -75,7 +75,7 @@ public class ApkTest { assertFalse(apk.isApk()); copyResourceFileToCache(apk); File path = apk.getMediaInstallPath(context); - assertEquals(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), path); + assertEquals(context.getCacheDir(), path); } private void copyResourceFileToCache(Apk apk) throws IOException { From 15a024b06efa53ea2a7c9cc2bb95688e87a6c3f2 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Tue, 20 Oct 2020 16:11:58 +0200 Subject: [PATCH 08/10] update javadocs --- app/src/androidTest/java/org/fdroid/fdroid/AssetUtils.java | 6 +++++- app/src/main/java/org/fdroid/fdroid/data/Apk.java | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/org/fdroid/fdroid/AssetUtils.java b/app/src/androidTest/java/org/fdroid/fdroid/AssetUtils.java index 65a299b7d..95e12a021 100644 --- a/app/src/androidTest/java/org/fdroid/fdroid/AssetUtils.java +++ b/app/src/androidTest/java/org/fdroid/fdroid/AssetUtils.java @@ -1,8 +1,8 @@ package org.fdroid.fdroid; import android.content.Context; -import androidx.annotation.Nullable; import android.util.Log; +import androidx.annotation.Nullable; import java.io.File; import java.io.FileOutputStream; @@ -16,6 +16,9 @@ public class AssetUtils { private static final String TAG = "Utils"; + /** + * This requires {@link Context} from {@link android.app.Instrumentation#getContext()} + */ @Nullable public static File copyAssetToDir(Context context, String assetName, File directory) { File tempFile = null; @@ -28,6 +31,7 @@ public class AssetUtils { output = new FileOutputStream(tempFile); Utils.copy(input, output); } catch (IOException e) { + Log.e(TAG, "Check the context is from Instrumentation.getContext()"); fail(e.getMessage()); } finally { Utils.closeQuietly(output); diff --git a/app/src/main/java/org/fdroid/fdroid/data/Apk.java b/app/src/main/java/org/fdroid/fdroid/data/Apk.java index 5d17d68fa..be868e740 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/Apk.java +++ b/app/src/main/java/org/fdroid/fdroid/data/Apk.java @@ -618,6 +618,10 @@ public class Apk extends ValueObject implements Comparable, Parcelable { return new File(this.getMediaInstallPath(context), SanitizedFile.sanitizeFileName(this.apkName)); } + /** + * Check whether a media file is "installed" as based on the file type's + * install path, derived in {@link #getMediaInstallPath(Context)} + */ public boolean isMediaInstalled(Context context) { return getInstalledMediaFile(context).isFile(); } From 3b2b9ae1df5e7c3906e13c844ba3307dbc147fa0 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Tue, 20 Oct 2020 16:37:44 +0200 Subject: [PATCH 09/10] fix ApkVerifierTest when running on android-29+ fdroid/fdroidclient!856 --- .../fdroid/installer/ApkVerifierTest.java | 33 ++++++++++++++----- .../fdroid/fdroid/installer/ApkVerifier.java | 2 +- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/app/src/androidTest/java/org/fdroid/fdroid/installer/ApkVerifierTest.java b/app/src/androidTest/java/org/fdroid/fdroid/installer/ApkVerifierTest.java index d40cce4a9..ee18b006f 100644 --- a/app/src/androidTest/java/org/fdroid/fdroid/installer/ApkVerifierTest.java +++ b/app/src/androidTest/java/org/fdroid/fdroid/installer/ApkVerifierTest.java @@ -22,10 +22,10 @@ package org.fdroid.fdroid.installer; import android.app.Instrumentation; import android.net.Uri; import android.os.Build; -import androidx.annotation.NonNull; -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.ext.junit.runners.AndroidJUnit4; import android.util.Log; +import androidx.annotation.NonNull; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; import org.fdroid.fdroid.AssetUtils; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.compat.FileCompatTest; @@ -113,7 +113,7 @@ public class ApkVerifierTest { Apk apk = new Apk(); apk.packageName = "org.fdroid.permissions.sdk14"; apk.targetSdkVersion = 14; - String[] noPrefixPermissions = new String[]{ + ArrayList noPrefixPermissionsList = new ArrayList<>(Arrays.asList( "AUTHENTICATE_ACCOUNTS", "MANAGE_ACCOUNTS", "READ_PROFILE", @@ -129,8 +129,13 @@ public class ApkVerifierTest { "READ_SYNC_SETTINGS", "WRITE_SYNC_SETTINGS", "WRITE_CALL_LOG", // implied-permission! - "READ_CALL_LOG", // implied-permission! - }; + "READ_CALL_LOG" // implied-permission! + )); + if (Build.VERSION.SDK_INT >= 29) { + noPrefixPermissionsList.add("android.permission.ACCESS_MEDIA_LOCATION"); + } + String[] noPrefixPermissions = noPrefixPermissionsList.toArray(new String[0]); + for (int i = 0; i < noPrefixPermissions.length; i++) { noPrefixPermissions[i] = RepoXMLHandler.fdroidToAndroidPermission(noPrefixPermissions[i]); } @@ -177,7 +182,7 @@ public class ApkVerifierTest { Apk apk = new Apk(); apk.packageName = "org.fdroid.permissions.sdk14"; apk.targetSdkVersion = 14; - apk.requestedPermissions = new String[]{ + TreeSet expectedSet = new TreeSet<>(Arrays.asList( "android.permission.AUTHENTICATE_ACCOUNTS", "android.permission.MANAGE_ACCOUNTS", "android.permission.READ_PROFILE", @@ -193,8 +198,12 @@ public class ApkVerifierTest { "android.permission.READ_SYNC_SETTINGS", "android.permission.WRITE_SYNC_SETTINGS", "android.permission.WRITE_CALL_LOG", // implied-permission! - "android.permission.READ_CALL_LOG", // implied-permission! - }; + "android.permission.READ_CALL_LOG"// implied-permission! + )); + if (Build.VERSION.SDK_INT >= 29) { + expectedSet.add("android.permission.ACCESS_MEDIA_LOCATION"); + } + apk.requestedPermissions = expectedSet.toArray(new String[0]); Uri uri = Uri.fromFile(sdk14Apk); @@ -371,6 +380,9 @@ public class ApkVerifierTest { "android.permission.MANAGE_ACCOUNTS" )); } + if (Build.VERSION.SDK_INT >= 29) { + expectedSet.add("android.permission.ACCESS_MEDIA_LOCATION"); + } Apk apk = actualDetails.apks.get(1); Log.i(TAG, "APK: " + apk.apkName); HashSet actualSet = new HashSet<>(Arrays.asList(apk.requestedPermissions)); @@ -407,6 +419,9 @@ public class ApkVerifierTest { "org.dmfs.permission.READ_TASKS", "org.dmfs.permission.WRITE_TASKS" )); + if (Build.VERSION.SDK_INT >= 29) { + expectedSet.add("android.permission.ACCESS_MEDIA_LOCATION"); + } expectedPermissions = expectedSet.toArray(new String[expectedSet.size()]); apk = actualDetails.apks.get(2); Log.i(TAG, "APK: " + apk.apkName); diff --git a/app/src/main/java/org/fdroid/fdroid/installer/ApkVerifier.java b/app/src/main/java/org/fdroid/fdroid/installer/ApkVerifier.java index b97738fc7..b20db721d 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/ApkVerifier.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/ApkVerifier.java @@ -23,9 +23,9 @@ import android.content.Context; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.net.Uri; -import androidx.annotation.Nullable; import android.text.TextUtils; import android.util.Log; +import androidx.annotation.Nullable; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.Apk; From c157c3f04713f11589edec0e32b157980a4bc5d2 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Tue, 20 Oct 2020 16:51:22 +0200 Subject: [PATCH 10/10] fix Espresso test broken by androidx id change fdroid/fdroidclient!899 --- .../java/org/fdroid/fdroid/MainActivityEspressoTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/org/fdroid/fdroid/MainActivityEspressoTest.java b/app/src/androidTest/java/org/fdroid/fdroid/MainActivityEspressoTest.java index 071609401..2f1b5877f 100644 --- a/app/src/androidTest/java/org/fdroid/fdroid/MainActivityEspressoTest.java +++ b/app/src/androidTest/java/org/fdroid/fdroid/MainActivityEspressoTest.java @@ -7,10 +7,10 @@ import android.content.Context; import android.os.Build; import androidx.test.core.app.ApplicationProvider; +import androidx.test.filters.LargeTest; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.espresso.IdlingPolicies; import androidx.test.espresso.ViewInteraction; -import androidx.test.filters.LargeTest; import androidx.test.rule.ActivityTestRule; import androidx.test.rule.GrantPermissionRule; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -206,7 +206,7 @@ public class MainActivityEspressoTest { onView(withId(R.id.version)).check(matches(isDisplayed())); onView(withId(R.id.ok_button)).perform(click()); - onView(withId(R.id.list)).perform(swipeUp()).perform(swipeUp()).perform(swipeUp()); + onView(withId(android.R.id.list_container)).perform(swipeUp()).perform(swipeUp()).perform(swipeUp()); } @LargeTest