Skip to content

Add android_generate_apk_from_aab action #467

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

Closed
wants to merge 14 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ _None_
### New Features

- Add `ios_get_build_number` action to get the current build number from an `xcconfig` file. [#458]

- Add new `android_generate_apk_from_aab` action to generate an APK from a given AAB. [#467]

### Bug Fixes

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
module Fastlane
module Actions
class AndroidGenerateApkFromAabAction < Action
def self.run(params)
begin
sh('command', '-v', 'bundletool', print_command: false, print_command_output: false)
rescue StandardError
UI.user_error!(MISSING_BUNDLETOOL_ERROR_MESSAGE)
end

# Parse input parameters
aab_file_path = parse_aab_param(params)
apk_output_file_path = params[:apk_output_file_path] || Pathname(aab_file_path).sub_ext('.apk').to_s
apk_output_file_path = File.join(apk_output_file_path, "#{File.basename(aab_file_path, '.aab')}.apk") if File.directory?(apk_output_file_path)
code_sign_arguments = {
'--ks': params[:keystore_path],
'--ks-pass': params[:keystore_password],
'--ks-key-alias': params[:keystore_key_alias],
'--key-pass': params[:signing_key_password]
}.compact.flatten.map(&:to_s)

Dir.mktmpdir('a8c-release-toolkit-bundletool-') do |tmpdir|
sh(
'bundletool', 'build-apks',
'--mode', 'universal',
'--bundle', aab_file_path,
'--output-format', 'DIRECTORY',
'--output', tmpdir,
*code_sign_arguments
)
FileUtils.mkdir_p(File.dirname(apk_output_file_path)) # Create destination directory if it doesn't exist yet
FileUtils.mv(File.join(tmpdir, 'universal.apk'), apk_output_file_path)
end

apk_output_file_path
end

#####################################################

def self.parse_aab_param(params)
# If no AAB param was provided, attempt to get it from the lane context
if params[:aab_file_path].nil?
all_aab_paths = Actions.lane_context[SharedValues::GRADLE_ALL_AAB_OUTPUT_PATHS] || []
aab_file_path = if all_aab_paths.count == 1
all_aab_paths.first
else
Actions.lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH]
end
else
aab_file_path = params[:aab_file_path]
end

# If no AAB file path was found, raise an error
if aab_file_path.nil?
UI.user_error!(NO_AAB_ERROR_MESSAGE)
elsif !File.file?(aab_file_path)
UI.user_error!("The file `#{aab_file_path}` was not found. Please provide a path to an existing file.")
end

aab_file_path
end

MISSING_BUNDLETOOL_ERROR_MESSAGE = 'bundletool is not installed. Please install it using the instructions at https://developer.android.com/studio/command-line/bundletool.'.freeze
NO_AAB_ERROR_MESSAGE = 'No AAB file path was specified and none was found in the lane context. Please specify the `aab_file_path` parameter or ensure that the `gradle` action has been run prior to this action.'.freeze

#####################################################
# @!group Documentation
#####################################################

def self.description
'Generates an APK from the specified AAB'
end

def self.details
'Generates an APK file from the specified AAB file using `bundletool`'
end

def self.available_options
[
FastlaneCore::ConfigItem.new(
key: :aab_file_path,
env_name: 'ANDROID_AAB_FILE_PATH',
description: 'The path to the AAB file. If not specified, the action will attempt to read from the lane context using the `SharedValues::GRADLE_ALL_AAB_OUTPUT_PATHS` and `SharedValues::GRADLE_AAB_OUTPUT_PATH` keys',
type: String,
optional: true,
default_value: nil
),
FastlaneCore::ConfigItem.new(
key: :apk_output_file_path,
env_name: 'ANDROID_APK_OUTPUT_PATH',
description: 'The path of the output APK file to generate. If not specified, will use the same path and basename as the `aab_file_path` but with an `.apk` file extension',
type: String,
optional: true,
default_value: nil
),
Copy link
Contributor

Choose a reason for hiding this comment

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

Now that we decided to not create intermediate folders if they don't exist, we should add a check to ensure the parent dir exists. Otherwise we'd still have the action run bundletool but only fail afterwards while trying to move the file to the destination, instead of failing early.

Suggested change
),
verify_block: proc { |p| UI.user_error!('The parent folder for the destination does not exist. Please create it first.') unless p.nil? || File.directory?(File.dirname(p)) }
),

FastlaneCore::ConfigItem.new(
key: :keystore_path,
env_name: 'ANDROID_KEYSTORE_PATH',
description: 'The path to the keystore file (if you want to codesign the APK)',
type: String,
optional: true,
default_value: nil,
verify_block: proc { |p| UI.user_error!("Keystore file path `#{p}` is not a valid file path.") unless p.nil? || File.file?(p) }
),
FastlaneCore::ConfigItem.new(
key: :keystore_password,
env_name: 'ANDROID_KEYSTORE_PASSWORD',
description: 'The password for the keystore (if you want to codesign the APK)',
type: String,
optional: true,
default_value: nil
),
FastlaneCore::ConfigItem.new(
key: :keystore_key_alias,
env_name: 'ANDROID_KEYSTORE_KEY_ALIAS',
description: 'The alias of the key in the keystore (if you want to codesign the APK)',
type: String,
optional: true,
default_value: nil
),
FastlaneCore::ConfigItem.new(
key: :signing_key_password,
env_name: 'ANDROID_SIGNING_KEY_PASSWORD',
description: 'The password for the signing key (if you want to codesign the APK)',
type: String,
optional: true,
default_value: nil
),
]
end

def self.return_type
:string
end

def self.return_value
'The path to the APK that has been generated'
end

def self.authors
['Automattic']
end

def self.is_supported?(platform)
platform == 'android'
end
end
end
end
156 changes: 156 additions & 0 deletions spec/android_generate_apk_from_aab_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
require 'spec_helper'

describe Fastlane::Actions::AndroidGenerateApkFromAabAction do
let(:aab_file_path) { 'Dev/My App/my-app.aab' }
let(:apk_output_file_path) { 'Dev/My App/build/artifacts/my-universal-app.apk' }

def expect_bundletool_call(aab, apk, *options)
expect(Fastlane::Action).to receive('sh').with('command', '-v', 'bundletool', { print_command: false, print_command_output: false })
allow(File).to receive(:file?).with(aab).and_return(true)
expect(Fastlane::Action).to receive('sh').with(
'bundletool', 'build-apks', '--mode', 'universal', '--bundle', aab,
'--output-format', 'DIRECTORY', '--output', anything,
*options
)
expect(FileUtils).to receive(:mkdir_p).with(File.dirname(apk))
expect(FileUtils).to receive(:mv).with(anything, apk)
end

context 'when generating a signed APK' do
let(:keystore_path) { 'Dev/My App/secrets/path/to/keystore' }
let(:keystore_password) { 'keystore_password' }
let(:keystore_key_alias) { 'keystore_key_alias' }
let(:signing_key_password) { 'signing_key_password' }

it 'calls the `bundletool` command with the correct arguments' do
allow(File).to receive(:file?).with(keystore_path).and_return(true)
expect_bundletool_call(
aab_file_path, apk_output_file_path,
'--ks', keystore_path, '--ks-pass', keystore_password, '--ks-key-alias', keystore_key_alias, '--key-pass', signing_key_password
)

output = run_described_fastlane_action(
aab_file_path: aab_file_path,
apk_output_file_path: apk_output_file_path,
keystore_path: keystore_path,
keystore_password: keystore_password,
keystore_key_alias: keystore_key_alias,
signing_key_password: signing_key_password
)

expect(output).to eq(apk_output_file_path)
end
end

context 'when generating an unsigned APK' do
it 'calls the `bundletool` command with the correct arguments' do
expect_bundletool_call(aab_file_path, apk_output_file_path)

output = run_described_fastlane_action(
aab_file_path: aab_file_path,
apk_output_file_path: apk_output_file_path
)

expect(output).to eq(apk_output_file_path)
end
end

describe 'parameter inference' do
it 'infers the AAB path from lane context if `SharedValues::GRADLE_AAB_OUTPUT_PATH` is set' do
aab_path_from_context = 'path/from/context/my-app.aab'
Fastlane::Actions.lane_context[Fastlane::Actions::SharedValues::GRADLE_AAB_OUTPUT_PATH] = aab_path_from_context

expect_bundletool_call(aab_path_from_context, apk_output_file_path)
run_described_fastlane_action(
apk_output_file_path: apk_output_file_path
)

Fastlane::Actions.lane_context[Fastlane::Actions::SharedValues::GRADLE_AAB_OUTPUT_PATH] = nil
end

it 'infers the AAB path from lane context if `SharedValues::GRADLE_ALL_AAB_OUTPUT_PATHS` is set with only one value' do
aab_paths_from_context = ['first/path/from/context/app.aab']
Fastlane::Actions.lane_context[Fastlane::Actions::SharedValues::GRADLE_ALL_AAB_OUTPUT_PATHS] = aab_paths_from_context

expect_bundletool_call(aab_paths_from_context.first, apk_output_file_path)
run_described_fastlane_action(
apk_output_file_path: apk_output_file_path
)

Fastlane::Actions.lane_context[Fastlane::Actions::SharedValues::GRADLE_ALL_AAB_OUTPUT_PATHS] = nil
end

it 'does not infer the AAB path from lane context if `SharedValues::GRADLE_AAB_OUTPUT_PATHS` has more than one value' do
aab_paths_from_context = ['first/path/from/context/app.aab', 'second/path/from/context/app.aab']
Fastlane::Actions.lane_context[Fastlane::Actions::SharedValues::GRADLE_ALL_AAB_OUTPUT_PATHS] = aab_paths_from_context

expect do
run_described_fastlane_action(
apk_output_file_path: apk_output_file_path
)
end.to raise_error(FastlaneCore::Interface::FastlaneError, described_class::NO_AAB_ERROR_MESSAGE)

Fastlane::Actions.lane_context[Fastlane::Actions::SharedValues::GRADLE_ALL_AAB_OUTPUT_PATHS] = nil
end

it 'infers the output path if none is provided' do
inferred_apk_path = File.join(File.dirname(aab_file_path), "#{File.basename(aab_file_path, '.aab')}.apk")
expect_bundletool_call(aab_file_path, inferred_apk_path)

output = run_described_fastlane_action(
aab_file_path: aab_file_path
)

expect(output).to eq(inferred_apk_path)
end

it 'infers the output file name if output path is a directory' do
in_tmp_dir do |output_dir|
inferred_apk_path = File.join(output_dir, "#{File.basename(aab_file_path, '.aab')}.apk")
expect_bundletool_call(aab_file_path, inferred_apk_path)

output = run_described_fastlane_action(
aab_file_path: aab_file_path,
apk_output_file_path: output_dir
)

expect(output).to eq(inferred_apk_path)
end
end
end

describe 'error handling' do
it 'errors if bundletool is not installed' do
allow(Fastlane::Action).to receive('sh').with('command', '-v', 'bundletool', any_args).and_raise

expect do
run_described_fastlane_action(
aab_file_path: aab_file_path,
apk_output_file_path: apk_output_file_path
)
end.to raise_error(FastlaneCore::Interface::FastlaneError, described_class::MISSING_BUNDLETOOL_ERROR_MESSAGE)
end

it 'errors if no input AAB file was provided nor can be inferred' do
expect(Fastlane::Action).to receive('sh').with('command', '-v', 'bundletool', any_args)

expect do
run_described_fastlane_action(
apk_output_file_path: apk_output_file_path
)
end.to raise_error(FastlaneCore::Interface::FastlaneError, described_class::NO_AAB_ERROR_MESSAGE)
end

it 'errors if the provided input AAB file does not exist' do
expect(Fastlane::Action).to receive('sh').with('command', '-v', 'bundletool', any_args)
allow(File).to receive(:file?).with(aab_file_path).and_return(false)

expect do
run_described_fastlane_action(
aab_file_path: aab_file_path,
apk_output_file_path: apk_output_file_path
)
end.to raise_error(FastlaneCore::Interface::FastlaneError, "The file `#{aab_file_path}` was not found. Please provide a path to an existing file.")
end
end
end