Skip to content
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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
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
1 change: 1 addition & 0 deletions fastlane/spec/unused_options_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
upload_to_play_store
upload_to_play_store_internal_app_sharing
upload_to_testflight
download_universal_apk_from_google_play
puts
println
echo
Expand Down
1 change: 1 addition & 0 deletions supply/lib/supply.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require 'supply/client'
require 'supply/listing'
require 'supply/apk_listing'
require 'supply/generated_universal_apk'
require 'supply/release_listing'
require 'supply/uploader'
require 'supply/languages'
Expand Down
33 changes: 33 additions & 0 deletions supply/lib/supply/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,39 @@ def latest_version(track)
return latest_version
end

# Get the list of Universal APKs generated by Google from the AAB for a particular version code
#
# @param [Fixnum] version_code
# Version code of the app bundle.
# @raise [FastlaneError] (Google Api Error) If no APK was found for the provided `package_name` and `version_code`
# @return [Array<GeneratedUniversalApk>]
#
def list_generated_universal_apks(package_name:, version_code:)
result = call_google_api { client.list_generatedapks(package_name, version_code) }

result.generated_apks.map do |row|
GeneratedUniversalApk.new(package_name, version_code, row.certificate_sha256_hash, row.generated_universal_apk.download_id)
end
end

# Download a Universal APK generated by Google for a particular version code
#
# @param [Supply::GeneratedUniversalApk] generated_universal_apk
# The GeneratedUniversalApk object retrieved from a call to `list_generated_univeral_apks`
# @param [IO, String] destination
# IO stream or filename to receive content download
#
def download_generated_universal_apk(generated_universal_apk:, destination:)
call_google_api {
client.download_generatedapk(
generated_universal_apk.package_name,
generated_universal_apk.version_code,
generated_universal_apk.download_id,
download_dest: destination
)
}
end

#####################################################
# @!group Modifying data
#####################################################
Expand Down
24 changes: 24 additions & 0 deletions supply/lib/supply/generated_universal_apk.rb
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
2 changes: 2 additions & 0 deletions supply/spec/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
expect(subject.class.method_defined?(:get_edit_listing)).to eq(true)
expect(subject.class.method_defined?(:list_edit_apks)).to eq(true)
expect(subject.class.method_defined?(:list_edit_bundles)).to eq(true)
expect(subject.class.method_defined?(:list_generatedapks)).to eq(true)
expect(subject.class.method_defined?(:download_generatedapk)).to eq(true)
Comment on lines +43 to +44
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these correct?

Copy link
Contributor Author

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 the Supply::Client).

Those two particular ones that I added there are documented here in the AndroidPublisherService ruby doc:

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)

Copy link
Collaborator

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.

Copy link
Contributor Author

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 😅 🙂

expect(subject.class.method_defined?(:update_edit_listing)).to eq(true)
expect(subject.class.method_defined?(:upload_edit_apk)).to eq(true)
expect(subject.class.method_defined?(:upload_edit_deobfuscationfile)).to eq(true)
Expand Down