-
Notifications
You must be signed in to change notification settings - Fork 5.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[supply][action] add
download_apk_from_google_play
action (and corr…
…esponding `supply` methods) (#21315) * [Supply] Listing & downloading generated APKs Add methods on the Supply client to list and download Universal Generated APKs from the Google Play Console — typically generated and code-signed by Google from the AAB you uploaded e.g. via the `upload_to_play_store`/`supply` action * [Action] download_universal_apk_from_google_play Very useful when you use [Play App Signing](https://support.google.com/googleplay/android-developer/answer/9842756) and thus don't necessary have the signing key to produce the APK yourself. In those cases, you typically only upload an AAB to GPC, then let Google code-sign and generate signed APKs from that AAB. * Add `download_universal_apk_from_google_play` to `unused_options_spec` exceptions Because most of the options that are detected as not being used directly by the action are in fact just passed along to Supply::Client.make_from_config
- Loading branch information
1 parent
f00b39b
commit 5030f31
Showing
7 changed files
with
352 additions
and
0 deletions.
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