Skip to content

Commit

Permalink
[supply][action] add download_apk_from_google_play action (and corr…
Browse files Browse the repository at this point in the history
…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
AliSoftware committed Jul 6, 2023
1 parent f00b39b commit 5030f31
Show file tree
Hide file tree
Showing 7 changed files with 352 additions and 0 deletions.
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)
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

0 comments on commit 5030f31

Please sign in to comment.