-
Notifications
You must be signed in to change notification settings - Fork 5.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[supply][action] add download_apk_from_google_play
action (and corresponding supply
methods)
#21315
Merged
getaaron
merged 3 commits into
fastlane:master
from
AliSoftware:supply/download-apk-from-google-play
Jul 6, 2023
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
There are no files selected for viewing
124 changes: 124 additions & 0 deletions
124
fastlane/lib/fastlane/actions/download_universal_apk_from_google_play.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
require 'supply' | ||
require 'supply/options' | ||
|
||
module Fastlane | ||
module Actions | ||
class DownloadUniversalApkFromGooglePlayAction < Action | ||
def self.run(params) | ||
package_name = params[:package_name] | ||
version_code = params[:version_code] | ||
destination = params[:destination] | ||
cert_sha = params[:certificate_sha256_hash] | ||
|
||
client = Supply::Client.make_from_config(params: params) | ||
|
||
UI.message("Fetching the list of generated APKs from the Google API...") | ||
all_universal_apks = client.list_generated_universal_apks(package_name: package_name, version_code: version_code) | ||
matching_apks = all_universal_apks.select { |apk| cert_sha.nil? || apk.certificate_sha256_hash&.casecmp?(cert_sha) } | ||
|
||
all_certs_printable_list = all_universal_apks.map { |apk| " - #{apk.certificate_sha256_hash}" } | ||
if matching_apks.count > 1 | ||
message = <<~ERROR | ||
We found multiple Generated Universal APK, with the following `certificate_sha256_hash`: | ||
#{all_certs_printable_list.join("\n")} | ||
|
||
Use the `certificate_sha256_hash` parameter to specify which one to download. | ||
ERROR | ||
UI.user_error!(message) | ||
elsif matching_apks.empty? | ||
# NOTE: if no APK was found at all to begin with, the client would already have raised a user_error!('Google Api Error ...') | ||
message = <<~ERROR | ||
None of the Universal APK(s) found for this version code matched the `certificate_sha256_hash` of `#{cert_sha}`. | ||
|
||
We found #{all_universal_apks.count} Generated Universal APK(s), but with a different `certificate_sha256_hash`: | ||
#{all_certs_printable_list.join("\n")} | ||
ERROR | ||
UI.user_error!(message) | ||
end | ||
|
||
UI.message("Downloading Generated Universal APK to `#{destination}`...") | ||
FileUtils.mkdir_p(File.dirname(destination)) | ||
client.download_generated_universal_apk(generated_universal_apk: matching_apks.first, destination: destination) | ||
|
||
UI.success("Universal APK successfully downloaded to `#{destination}`.") | ||
destination | ||
end | ||
|
||
##################################################### | ||
# @!group Documentation | ||
##################################################### | ||
|
||
def self.description | ||
"Download the Universal APK of a given version code from the Google Play Console" | ||
end | ||
|
||
def self.details | ||
<<~DETAILS | ||
Download the universal APK of a given version code from the Google Play Console. | ||
|
||
This uses fastlane `Supply` (and the `AndroidPublisher` Google API) to download the Universal APK | ||
generated by Google after you uploaded an `.aab` bundle to the Play Console. | ||
|
||
See https://developers.google.com/android-publisher/api-ref/rest/v3/generatedapks/list | ||
DETAILS | ||
end | ||
|
||
def self.available_options | ||
# Only borrow _some_ of the ConfigItems from https://github.com/fastlane/fastlane/blob/master/supply/lib/supply/options.rb | ||
# So we don't have to duplicate the name, env_var, type, description, and verify_block of those here. | ||
supply_borrowed_options = Supply::Options.available_options.select do |o| | ||
%i[package_name version_code json_key json_key_data root_url timeout].include?(o.key) | ||
end | ||
# Adjust the description for the :version_code ConfigItem for our action's use case | ||
supply_borrowed_options.find { |o| o.key == :version_code }&.description = "The versionCode for which to download the generated APK" | ||
|
||
[ | ||
*supply_borrowed_options, | ||
|
||
# The remaining ConfigItems below are specific to this action | ||
FastlaneCore::ConfigItem.new(key: :destination, | ||
env_name: 'DOWNLOAD_UNIVERSAL_APK_DESTINATION', | ||
optional: false, | ||
type: String, | ||
description: "The path on disk where to download the Generated Universal APK", | ||
verify_block: proc do |value| | ||
UI.user_error!("The 'destination' must be a file path with the `.apk` file extension") unless File.extname(value) == '.apk' | ||
end), | ||
FastlaneCore::ConfigItem.new(key: :certificate_sha256_hash, | ||
env_name: 'DOWNLOAD_UNIVERSAL_APK_CERTIFICATE_SHA256_HASH', | ||
optional: true, | ||
type: String, | ||
description: "The SHA256 hash of the signing key for which to download the Universal, Code-Signed APK for. " \ | ||
+ "Use 'xx:xx:xx:…' format (32 hex bytes separated by colons), as printed by `keytool -list -keystore <keystorefile>`. " \ | ||
+ "Only useful to provide if you have multiple signing keys configured on GPC, to specify which generated APK to download", | ||
verify_block: proc do |value| | ||
bytes = value.split(':') | ||
next if bytes.length == 32 && bytes.all? { |byte| /^[0-9a-fA-F]{2}$/.match?(byte) } | ||
|
||
UI.user_error!("When provided, the certificate sha256 must be in the 'xx:xx:xx:…:xx' (32 hex bytes separated by colons) format") | ||
end) | ||
] | ||
end | ||
|
||
def self.output | ||
# Define the shared values you are going to provide | ||
end | ||
|
||
def self.return_value | ||
'The path to the downloaded Universal APK. The action will raise an exception if it failed to find or download the APK in Google Play' | ||
end | ||
|
||
def self.authors | ||
['Automattic'] | ||
end | ||
|
||
def self.category | ||
:production | ||
end | ||
|
||
def self.is_supported?(platform) | ||
platform == :android | ||
end | ||
end | ||
end | ||
end |
167 changes: 167 additions & 0 deletions
167
fastlane/spec/actions_specs/download_universal_apk_from_google_play_spec.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,167 @@ | ||
describe Fastlane do | ||
describe Fastlane::FastFile do | ||
describe "download_universal_apk_from_google_play" do | ||
let(:client_stub) { instance_double(Supply::Client) } | ||
let(:package_name) { 'com.fastlane.myapp' } | ||
let(:version_code) { 1337 } | ||
let(:json_key_path) { File.expand_path("./fastlane/spec/fixtures/google_play/google_play.json") } | ||
let(:destination) { '/tmp/universal-apk/fastlane-generated-apk-spec.apk' } | ||
|
||
let(:cert_sha_1) { '01:02:03:04:05:06:07:08:09:0a:0b:0c:0d:0e:0f:10:11:12:13:14:15:16:17:18:19:1a:1b:1c:1d:1e:1f:20' } | ||
let(:download_id_1) { 'Stub_Id_For_Cert_1' } | ||
let(:cert_sha_2) { 'a1:a2:a3:a4:a5:a6:a7:a8:a9:aa:ab:ac:ad:ae:af:b0:b1:b2:b3:b4:b5:b6:b7:b8:b9:ba:bb:bc:bd:be:bf:c0' } | ||
let(:download_id_2) { 'Stub_Id_For_Cert_2' } | ||
let(:cert_sha_not_found) { '00:de:ad:00:be:ef:00:00:ba:ad:00:ca:fe:00:00:fe:ed:00:c0:ff:ee:00:b4:00:f0:0d:00:00:12:34:56:78' } | ||
let(:cert_sha_invalid_format) { cert_sha_1.gsub(':', '') } | ||
let(:cert_sha_too_short) { '13:37:00:42' } | ||
|
||
before :each do | ||
allow(Supply::Client).to receive_message_chain(:make_from_config).and_return(client_stub) | ||
end | ||
|
||
def stub_client_list(download_id_per_cert) | ||
response = download_id_per_cert.map do |cert_hash, download_id| | ||
Supply::GeneratedUniversalApk.new(package_name, version_code, cert_hash, download_id) | ||
end | ||
allow(client_stub).to receive(:list_generated_universal_apks) | ||
.with(package_name: package_name, version_code: version_code) | ||
.and_return(response) | ||
end | ||
|
||
context 'when no `certificate_sha256_hash` is provided' do | ||
it 'finds and download the only generated APK' do | ||
stub_client_list({ cert_sha_1 => download_id_1 }) | ||
expected_ref = Supply::GeneratedUniversalApk.new(package_name, version_code, cert_sha_1, download_id_1) | ||
expect(FileUtils).to receive(:mkdir_p).with('/tmp/universal-apk') | ||
expect(client_stub).to receive(:download_generated_universal_apk) | ||
.with(generated_universal_apk: expected_ref, destination: destination) | ||
|
||
result = Fastlane::FastFile.new.parse("lane :test do | ||
download_universal_apk_from_google_play( | ||
package_name: '#{package_name}', | ||
version_code: '#{version_code}', | ||
json_key: '#{json_key_path}', | ||
destination: '#{destination}' | ||
) | ||
end").runner.execute(:test) | ||
|
||
expect(result).to eq(destination) | ||
end | ||
|
||
it 'raises if it finds more than one APK' do | ||
stub_client_list({ cert_sha_1 => download_id_1, cert_sha_2 => download_id_2 }) | ||
expect(client_stub).not_to receive(:download_generated_universal_apk) | ||
expected_error = <<~ERROR | ||
We found multiple Generated Universal APK, with the following `certificate_sha256_hash`: | ||
- #{cert_sha_1} | ||
- #{cert_sha_2} | ||
|
||
Use the `certificate_sha256_hash` parameter to specify which one to download. | ||
ERROR | ||
|
||
expect { | ||
Fastlane::FastFile.new.parse("lane :test do | ||
download_universal_apk_from_google_play( | ||
package_name: '#{package_name}', | ||
version_code: '#{version_code}', | ||
json_key: '#{json_key_path}', | ||
destination: '#{destination}' | ||
) | ||
end").runner.execute(:test) | ||
}.to raise_error(FastlaneCore::Interface::FastlaneError, expected_error) | ||
end | ||
end | ||
|
||
context 'when a `certificate_sha256_hash` is provided' do | ||
it 'finds a matching APK and downloads it' do | ||
stub_client_list({ cert_sha_1 => download_id_1, cert_sha_2 => download_id_2 }) | ||
expected_ref = Supply::GeneratedUniversalApk.new(package_name, version_code, cert_sha_2, download_id_2) | ||
expect(FileUtils).to receive(:mkdir_p).with('/tmp/universal-apk') | ||
expect(client_stub).to receive(:download_generated_universal_apk) | ||
.with(generated_universal_apk: expected_ref, destination: destination) | ||
|
||
result = Fastlane::FastFile.new.parse("lane :test do | ||
download_universal_apk_from_google_play( | ||
package_name: '#{package_name}', | ||
version_code: '#{version_code}', | ||
json_key: '#{json_key_path}', | ||
destination: '#{destination}', | ||
certificate_sha256_hash: '#{cert_sha_2}' | ||
) | ||
end").runner.execute(:test) | ||
|
||
expect(result).to eq(destination) | ||
end | ||
|
||
it 'errors if it does not find any matching APK' do | ||
stub_client_list({ cert_sha_1 => download_id_1, cert_sha_2 => download_id_2 }) | ||
expected_error = <<~ERROR | ||
None of the Universal APK(s) found for this version code matched the `certificate_sha256_hash` of `#{cert_sha_not_found}`. | ||
|
||
We found 2 Generated Universal APK(s), but with a different `certificate_sha256_hash`: | ||
- #{cert_sha_1} | ||
- #{cert_sha_2} | ||
ERROR | ||
|
||
expect { | ||
Fastlane::FastFile.new.parse("lane :test do | ||
download_universal_apk_from_google_play( | ||
package_name: '#{package_name}', | ||
version_code: '#{version_code}', | ||
json_key: '#{json_key_path}', | ||
destination: '#{destination}', | ||
certificate_sha256_hash: '#{cert_sha_not_found}' | ||
) | ||
end").runner.execute(:test) | ||
}.to raise_error(FastlaneCore::Interface::FastlaneError, expected_error) | ||
end | ||
end | ||
|
||
context 'when invalid input parameters are provided' do | ||
it 'reports an error if the destination is not a path to an apk file' do | ||
expected_error = "The 'destination' must be a file path with the `.apk` file extension" | ||
expect { | ||
Fastlane::FastFile.new.parse("lane :test do | ||
download_universal_apk_from_google_play( | ||
package_name: '#{package_name}', | ||
version_code: '#{version_code}', | ||
json_key: '#{json_key_path}', | ||
destination: '/tmp/somedir/' | ||
) | ||
end").runner.execute(:test) | ||
}.to raise_error(FastlaneCore::Interface::FastlaneError, expected_error) | ||
end | ||
|
||
it 'reports an error if the cert sha is not in the right format' do | ||
expected_error = "When provided, the certificate sha256 must be in the 'xx:xx:xx:…:xx' (32 hex bytes separated by colons) format" | ||
expect { | ||
Fastlane::FastFile.new.parse("lane :test do | ||
download_universal_apk_from_google_play( | ||
package_name: '#{package_name}', | ||
version_code: '#{version_code}', | ||
json_key: '#{json_key_path}', | ||
destination: '#{destination}', | ||
certificate_sha256_hash: '#{cert_sha_invalid_format}' | ||
) | ||
end").runner.execute(:test) | ||
}.to raise_error(FastlaneCore::Interface::FastlaneError, expected_error) | ||
end | ||
|
||
it 'reports an error if the cert sha is not of the right length' do | ||
expected_error = "When provided, the certificate sha256 must be in the 'xx:xx:xx:…:xx' (32 hex bytes separated by colons) format" | ||
expect { | ||
Fastlane::FastFile.new.parse("lane :test do | ||
download_universal_apk_from_google_play( | ||
package_name: '#{package_name}', | ||
version_code: '#{version_code}', | ||
json_key: '#{json_key_path}', | ||
destination: '#{destination}', | ||
certificate_sha256_hash: '#{cert_sha_too_short}' | ||
) | ||
end").runner.execute(:test) | ||
}.to raise_error(FastlaneCore::Interface::FastlaneError, expected_error) | ||
end | ||
end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
module Supply | ||
# A model representing the returned values from a call to Client#list_generated_universal_apks | ||
class GeneratedUniversalApk | ||
attr_accessor :package_name | ||
attr_accessor :version_code | ||
attr_accessor :certificate_sha256_hash | ||
attr_accessor :download_id | ||
|
||
# Initializes the Generated Universal APK model | ||
def initialize(package_name, version_code, certificate_sha256_hash, download_id) | ||
self.package_name = package_name | ||
self.version_code = version_code | ||
self.certificate_sha256_hash = certificate_sha256_hash | ||
self.download_id = download_id | ||
end | ||
|
||
def ==(other) | ||
self.package_name == other.package_name \ | ||
&& self.version_code == other.version_code \ | ||
&& self.certificate_sha256_hash == other.certificate_sha256_hash \ | ||
&& self.download_id == other.download_id | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are these correct?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. That spec tests methods we expect to be defined on the
AndroidPublisher::AndroidPublisherService
internal client (not to be confused with theSupply::Client
).Those two particular ones that I added there are documented here in the
AndroidPublisherService
ruby doc:AndroidPublisher::AndroidPublisherService#list_generatedapks
AndroidPublisher::AndroidPublisherService#download_generatedapk
And those are the ones that I call in the
Supply::Client
corresponding wrapper methods here and here, respectively.So all that is correct, and consistent with how the rest of the spec and that file is written 🙂
(Plus, that spec/test passes on CI, while it would be red if those were not correct)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah I see. Thanks! I'm much less familiar with the Android code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No worries! Took me some time to familiarize myself with the AndroidPublisher API and Supply code and specs as well when I wrote that PR 😅 🙂