diff --git a/CHANGELOG.md b/CHANGELOG.md index 3647bda05..2c4307393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_generate_apk_from_aab.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_generate_apk_from_aab.rb new file mode 100644 index 000000000..2f54a71ee --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_generate_apk_from_aab.rb @@ -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 + ), + 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 diff --git a/spec/android_generate_apk_from_aab_spec.rb b/spec/android_generate_apk_from_aab_spec.rb new file mode 100644 index 000000000..2816b9883 --- /dev/null +++ b/spec/android_generate_apk_from_aab_spec.rb @@ -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