In the ever-evolving landscape of enterprise mobile application deployment, organisations have increasingly sought greater flexibility over how their apps are managed and distributed. Historically, businesses were able to deploy APKs directly from EMM solutions, though as the industry migrates over to AMAPI this is becoming increasingly more difficult to achieve without the partnership of OEMs.
In place, Google wants all organisations leveraging the Play Store for app distribution, which although for the most-part is reasonable, causes headaches for a subset of organisations.
Understanding the desires of organisations, Google introduced an alternative to uploading APKs to Google Play, allowing organisations to host their APKs externally to Google's infrastructure while still leaning on the store for the delivery. This guide delves into the process for setting up external hosting for private apps, allowing organisations greater control of their APK distribution at a cost of some functionality within Play itself. These limitations are, per Google:
- Externally hosted apps can only be published to production. Closed releases for externally hosted apps aren't supported.
- Publishing externally hosted apps is not available through the Managed Google Play iFrame.
- IT admins can't remotely install externally hosted apps on devices with work profiles. Work profile users must install them manually from Managed Google Play.
- Android Auto second-screen projection is disabled. This is because all Auto-targeted apps must go through a specific review to ensure that they’re not distracting to drivers.
In addition, applications deployed in this manner are not scanned/vetted by Google Play, however may still succumb to on-device scanning. Anything potentially harmful will be actioned one way or another, but may take longer to detect than for apps distributed through Play directly.
ExternallyHosted.py
script from Google.On Mac homebrew is required for this guide, it can be installed as follows from terminal:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
I found setting up the requirements to be laborious, not least because the script Google provides relies on Python 2.x for the since-deprecated distutils
called by it. When pulling in python through homebrew, the options to select 2.x are no longer present.
A workaround for this is to install pyenv
and then call for a version via that instead:
brew install pyenv
pyenv install 2.7.18
I went with python 2.7.18 with no particular reasoning in mind; it was a newer 2.x release that fit the requirement. Use a 2.x release you're comfortable with.
After which, install openjdk & openssl:
brew install openjdk openssl
For AAPT, this can either be installed through an existing Android Studio install via SDK manager, or using the command line tools directly.
AAPT will need to be exported to PATH
for easy reference, which I did by editing my .zshrc
file to include the following line:
vim ~/.zshrc
i
to INSERT:export PATH=$PATH:/Users/jasonbayton/Library/Android/sdk/build-tools/33.0.0
wq
+ ENTER
to save.Your PATH
will differ, based on what version of the SDK you're running, or where you've downloaded the standalone command line tools. Update it accordingly above.
You can also add python to your PATH
in the same way if desired, I chose to call it directly via the install directory homebrew dictated.
Before continuing, make sure the PATH
updates have been loaded in with source ~/.zshrc
in terminal, otherwise you'll get errors suggesting PATH
has not been set (AAPT can't be found, etc).
Finally, download the python script from Google via GitHub, and make sure it's executable with:
curl -o externallyhosted.py https://raw.githubusercontent.com/google/play-work/master/externally-hosted-apks/externallyhosted.py
sudo chmod +x /path/to/downloaded/externallyhosted.py
Done. Scroll down to Hosting the APK below.
This process will work for Windows via WSL also, so consider this the Windows guide if you're familiar with WSL. If not, I'm happy to take a PR to add Windows instructions in. Use the Edit this page link at the bottom to modify and submit.
Install the dependencies:
sudo apt update
sudo apt upgrade
sudo apt install -y python2.7 openssl openjdk-21-jdk aapt zipalign
Change directory (CD
) to where you're going to work from, then:
Pull down the script, and make sure it's executable:
wget https://raw.githubusercontent.com/google/play-work/master/externally-hosted-apks/externallyhosted.py
sudo chmod +x externallyhosted.py
Pull down or place your local APK into your working directory for simplicity, otherwise adjust the --apk=
config in the script below to reflect the stored location of your local APK (e.g. /home/jason/release-apks/my-project.apk
). For me working in a LXD Ubuntu container, I just grabbed my hosted version:
wget https://cdn.bayton.org/download/org.bayton.external.apk
Done.
Google don't document any hard and fast rules for where an APK should be hosted, but they do make references to unavailability of applications if reliant on an as-yet unconfigured (or not enabled) VPN solutions, suggesting they're OK with access being limited.
For this, I popped my test APK in my CDN. It's obviously very public. In production, I'd likely either put it on a private cloud instance, or implement Google's suggested authentication referenced at the end of this doc.
Once your environment is set up, you're ready to run the command.
The generic sample code Google offers is as follows:
python externallyhosted.py --apk=<path/to/your.apk> --externallyHostedUrl="<https://yourserver.com/app.apk>" > metadata.json
On my machine, factoring in the system-unique environmental decisions I made above, it's this:
MacOS
/Users/jasonbayton/.pyenv/versions/2.7.18/bin/python2.7 ./externallyhosted.py --apk=/Users/jasonbayton/Desktop/org.bayton.external.apk --externallyHostedUrl="https://cdn.bayton.org/download/org.bayton.external.apk" > org.bayton.external.metadata.json
Ubuntu
python2.7 externallyhosted.py --apk=org.bayton.external.apk --externallyHostedUrl="https://cdn.bayton.org/download/org.bayton.external.apk" > org.bayton.external.metadata.json
Which then output the following JSON, piped to the file org.bayton.external.metadata.json
:
{
"icon_filename": "res/uF.xml",
"file_sha256_base64": "df2kr163h1/GXJKSP7YplkXvca5m7o6aNdkyLou6mRE=",
"file_sha1_base64": "iGuUXjuRR6gNp8CtyQEyRUCsoYY=",
"package_name": "org.bayton.external",
"application_label": "BAYTON",
"icon_base64": "AwAIAMABAAABABwAoAAAAAYAAAAAAAAAAAEAADQAAAAAAAAAAAAAAAsAAAAbAAAAJQAAADIAAAA/AAAACAhkcmF3YWJsZQANDWFkYXB0aXZlLWljb24ABwdhbmRyb2lkAAoKYmFja2dyb3VuZAAKCmZvcmVncm91bmQAKipodHRwOi8vc2NoZW1hcy5hbmRyb2lkLmNvbS9hcGsvcmVzL2FuZHJvaWQAgAEIAAwAAACZAQEBAAEQABgAAAACAAAA/////wIAAAAFAAAAAgEQACQAAAACAAAA//////////8BAAAAFAAUAAAAAAAAAAAAAgEQADgAAAADAAAA//////////8DAAAAFAAUAAEAAAAAAAAABQAAAAAAAAD/////CAAAAQYAA38DARAAGAAAAAMAAAD//////////wMAAAACARAAOAAAAAQAAAD//////////wQAAAAUABQAAQAAAAAAAAAFAAAAAAAAAP////8IAAABAgAJfwMBEAAYAAAABAAAAP//////////BAAAAAMBEAAYAAAAAgAAAP//////////AQAAAAEBEAAYAAAAAgAAAP////8CAAAABQAAAA==",
"uses_feature": [
"android.hardware.faketouch"
],
"version_code": "1019",
"certificate_base64": [
"MIIDaTCCAlGgAwIBAgIEbN93DjANBgkqhkiG9w0BAQsFADBlMQswCQYDVQQGEwJHQjEOMAwGA1UECBMFR3dlbnQxEDAOBgNVBAcTB05ld3BvcnQxDzANBgNVBAoTBkJBWVRPTjEMMAoGA1UECxMDRU5HMRUwEwYDVQQDEwxKYXNvbiBCYXl0b24wHhcNMjIxMTExMTcyOTMzWhcNNDcxMTA1MTcyOTMzWjBlMQswCQYDVQQGEwJHQjEOMAwGA1UECBMFR3dlbnQxEDAOBgNVBAcTB05ld3BvcnQxDzANBgNVBAoTBkJBWVRPTjEMMAoGA1UECxMDRU5HMRUwEwYDVQQDEwxKYXNvbiBCYXl0b24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCRoVCVksJqVeD27W/kziuazVcJcVXkb1o1j/yFVjkF6oZBiPLxqmnU9o38QAuU6O58LcRvQckJ9wdqYepCKrWG3XMjAek1yZ9sVvdCzQCgANjukM9kht7zMQMW7IMHWXskJQ0v98zPLeknuqn8aWjAjaq3BGMYgoe/K6a1+BSqOmuU7OlPn6+QUS5yrnamHKkscdKc5UDYcw7/dsnCmvT6Rtom9Ulk5YP3NBqQh4+/c4YLDenChiCefmMpq0eQokCrMKc8PRaygPKn777EKLj/99zamdcXjr8jm+4nNLh5afBaqzJiLTA3a9ezYxJY8lV+hQSatvkoDcFlRql0fYqFAgMBAAGjITAfMB0GA1UdDgQWBBQyqLncr5PiypN9OWobcsQqM2cPRjANBgkqhkiG9w0BAQsFAAOCAQEAT9AMZbcn6buAd1jAT+YBl93ERq9X3+NFp7Tf02pJ2XSPEq8wsJHzAO6m1OXGUrv6/+r4krCIgU+83met+H279cadkBDR+JAEJvh3YAFKpaZ/yCRIXTCWquOhM7z6yS/hWZCquhuxs9iJGv6w7AzVaw5YwyjSjMMV1eI5pF6N+XMK0QSACerooc7STI7Zb1wA5mGMj2fEzvaKdFpvnJMQMJVwWGf/DunC/cryfVN9+XNVShrJXklqJzdy/i0iNlte0sJB4AmLKXGHgV2iwmcaHK0XgMUGWhk7CY8Bml3kS9Wygg7a/vSKlIt6kzXDPKxRmHfrXci8WR3mNrut0Xbilg=="
],
"file_size": 385820,
"externally_hosted_url": "https://cdn.bayton.org/download/org.bayton.external.apk",
"version_name": "1.0.1.9' platformBuildVersionName='13' platformBuildVersionCode='33' compileSdkVersion='33' compileSdkVersionCodename='13",
"minimum_sdk": "19"
}
There's nothing above that can't be extracted from the APK by anyone who pulls it from a device, so I have no problem not obfuscating any of this data.
With the shiny JSON file in hand, publishing can now take place.
Things to keep in mind:
In my testing, the latter requirement wasn't accurate. I have deployed an externally hosted app to several organisations wherein my developer account does not have admin permissions on the bind and the application was easily found within the iFrame of those EMM environments.
To further clarify the requirements, you'll need a full developer account. It costs $25 as a one-time fee. Do not attempt to use the developer account associated with the organisation itself, identified in the list of developer accounts associated with any logged in account with admin rights to the organisation/enterprise ID, as you will no have permission to upload applications within this account.
Check the app is indeed live for the organisation/enterprise ID you shared it with. I'm using the AMAPI API explorer as my enterprise is not associated with an EMM directly, but you should find the app available in managed Google Play for assignment.
And we're done. Congrats! 🎉
When making changes to the external APK file, I popped into the Google Play console and created a new release with the updated JSON metadata file. This updated things like version number/code and any target API requirements within the Play Console immediately, and updated the SHA values for the modified APK.
Google recommends validating the token they send with the download request to confirm the request is legitimate. If you intend to limit who can download the APK from your servers, this is a good and reasonably straight-forward option for doing so.
It is not however something I'll be covering off here. See the docs above for information on this final, optional configuration.
The most time-consuming aspect of this is system setup. Once that's out of the way generating the relevant file and uploading to Play takes just minutes, and the application becomes available to devices shortly after.
If you're interested in learning more, feel free to get in touch. Thanks for reading!