diff --git a/lib/android_apk.rb b/lib/android_apk.rb index a2d198c..6fd4c02 100644 --- a/lib/android_apk.rb +++ b/lib/android_apk.rb @@ -19,6 +19,8 @@ require_relative "android_apk/aapt2/dump_badging" require_relative "android_apk/aapt2/dump_resources" +# @!attribute [r] aapt2_badging_result +# @return [AndroidApk::Aapt2::DumpBadging::Result] # @!attribute [r] label # @return [String, NilClass] Return a value which is defined in AndroidManifest.xml. Could be nil. # @!attribute [r] default_icon_path @@ -42,6 +44,10 @@ # @deprecated no longer used # @!attribute [r] icon_path_hash # @return [Hash] Application icon paths for all densities that are human readable names. This value may contains anyapi-v. +# @!attribute [r] app_signature +# @return [AndroidApk::AppSignature] An object contains lineages and certificate fingerprints +# @!attribute [r] meta_data +# @return [Hash] Named hash of meta-data tags in AndroidManifest.xml. Return an empty if none is found. class AndroidApk FALLBACK_DPI = 65_534 ADAPTIVE_ICON_SDK = 26 @@ -56,12 +62,6 @@ def logger end end - NOT_ALLOW_DUPLICATE_TAG_NAMES = %w( - application - sdkVersion - targetSdkVersion - ).freeze - DPI_TO_NAME_MAP = { 120 => "ldpi", 160 => "mdpi", @@ -83,25 +83,20 @@ module Reason extend Forwardable - delegate %i(label default_icon_path test_only test_only? package_name version_code version_name min_sdk_version target_sdk_version icons labels) => :@aapt2_result - - alias icon default_icon_path + attr_reader :aapt2_badging_result, :icon_path_hash, :app_signature - attr_reader :icon_path_hash + delegate %i(label default_icon_path test_only test_only? package_name version_code version_name min_sdk_version target_sdk_version icons labels meta_data) => :@aapt2_badging_result + alias icon default_icon_path alias sdk_version min_sdk_version - # An object contains lineages and certificate fingerprints - # @return [AndroidApk::AppSignature] a signature representation - attr_reader :app_signature - # Do analyze the given apk file. Analyzed apk does not mean *valid*. # # @param [String] filepath a filepath of an apk to be analyzed # @return [AndroidApk] An instance of AndroidApk will be returned if no problem exists while analyzing. # @raise [AndroidApk::ApkFileNotFoundError] if the filepath doesn't exist - # @raise [AndroidApk::UnacceptableApkError] if the apk file is not acceptable by commands like aapt - # @raise [AndroidApk::AndroidManifestValidateError] if the apk contains invalid AndroidManifest.xml but only when we can identify why it's invalid. + # @raise [AndroidApk::Aapt2Error] if the apk file is not acceptable by commands like aapt + # @raise [AndroidApk::InvalidApkError] if the apk contains invalid AndroidManifest.xml but only when we can identify why it's invalid. def self.analyze(filepath) AndroidApk.new( filepath: filepath @@ -114,7 +109,7 @@ def initialize( raise ApkFileNotFoundError, "an apk file is required to analyze." unless File.exist?(filepath) @filepath = filepath - @aapt2_result = Aapt2::DumpBadging.new(apk_filepath: filepath).parse + @aapt2_badging_result = Aapt2::DumpBadging.new(apk_filepath: filepath).parse # It seems the resources in the aapt's output doesn't mean that it's available in resource.arsc icons_in_arsc = ::AndroidApk::ResourceFinder.decode_resource_table( diff --git a/lib/android_apk/aapt2/dump_badging.rb b/lib/android_apk/aapt2/dump_badging.rb index 0bc9f6b..e1db935 100644 --- a/lib/android_apk/aapt2/dump_badging.rb +++ b/lib/android_apk/aapt2/dump_badging.rb @@ -3,77 +3,121 @@ class AndroidApk module Aapt2 class DumpBadging - # @!attribute [r] label - # @return [String, NilClass] Return a value which is defined in AndroidManifest.xml. Could be nil. - # @!attribute [r] default_icon_path - # @return [String] Return a relative path of this apk's icon. This is the real filepath in the apk but not resource-friendly path. - # @!attribute [r] test_only - # @return [Boolean] Check whether or not this apk is a test mode. Return true if an apk is a test apk - # @!attribute [r] package_name - # @return [String] an application's package name which is defined in AndroidManifest - # @!attribute [r] version_code - # @return [String] an application's version code which is defined in AndroidManifest - # @!attribute [r] version_name - # @return [String] an application's version name which is defined in AndroidManifest - # @!attribute [r] min_sdk_version - # @return [String, NilClass] an application's min sdk version. The format is an integer string which is defined in AndroidManifest.xml. Legacy apk may return nil. - # @!attribute [r] target_sdk_version - # @return [String, NilClass] an application's target sdk version. The format is an integer string which is defined in AndroidManifest.xml. Legacy apk may return nil. - # @!attribute [r] labels - # @return [Hash] an application's labels a.k.a application name in available resources. - # @!attribute [r] icons - # @return [Hash] an application's relative icon paths grouped by densities - # @deprecated no longer used + # @!attribute [r] raw_result_lines + # @return [Array] Raw outputs of aapt2 dump badging class Result - attr_reader :label, :default_icon_path, :test_only, :package_name, :version_code, :version_name, :min_sdk_version, :target_sdk_version, :icons, :labels + attr_reader :raw_result_lines - def initialize(variables) - # application info - @label = variables["application-label"] + def initialize(raw_result_lines:, parsed_variables:) + @raw_result_lines = raw_result_lines + @parsed_variables = parsed_variables + end - @default_icon_path = variables["application"]["icon"] - @test_only = variables.key?("testOnly='-1'") + # @return [String, NilClass] Return a value which is defined in AndroidManifest.xml. Could be nil. + def label + @parsed_variables["application-label"] + end - # package + # @return [String] Return a relative path of this apk's icon. This is the real filepath in the apk but not resource-friendly path. + def default_icon_path + @parsed_variables["application"]["icon"] + end - @package_name = variables["package"]["name"] - @version_code = variables["package"]["versionCode"] - @version_name = variables["package"]["versionName"] || "" + # @return [String] an application's package name which is defined in AndroidManifest + def package_name + @parsed_variables["package"]["name"] + end - # platforms - @min_sdk_version = variables["sdkVersion"] - @target_sdk_version = variables["targetSdkVersion"] + # @return [String] an application's version code which is defined in AndroidManifest + def version_code + @parsed_variables["package"]["versionCode"] + end - # icons and labels - @icons = {} # old - @labels = {} + # FIXME: don't return empty but return nil as it is cuz it's valid. + # @return [String] an application's version name which is defined in AndroidManifest or empty + def version_name + @parsed_variables["package"]["versionName"] || "" + end - variables.each_key do |k| - if (m = k.match(/\Aapplication-icon-(\d+)\z/)) - @icons[m[1].to_i] = variables[k] - elsif (m = k.match(/\Aapplication-label-(\S+)\z/)) - @labels[m[1]] = variables[k] - end - end + # @return [String, NilClass] an application's min sdk version. The format is an integer string which is defined in AndroidManifest.xml. Legacy apk may return nil. + def min_sdk_version + @parsed_variables["sdkVersion"] + end + + # @return [String, NilClass] an application's target sdk version. The format is an integer string which is defined in AndroidManifest.xml. Legacy apk may return nil. + def target_sdk_version + @parsed_variables["targetSdkVersion"] + end + + # @return [Boolean] Check whether or not this apk is a test mode. Return true if an apk is a test apk + def test_only + @parsed_variables.key?("testOnly='-1'") end alias test_only? test_only + + # @return [Hash<{String => String}>] A hash whose keys are names of meta-data and values are of them. + def meta_data + return @meta_data if defined?(@meta_data) + + @meta_data = (@parsed_variables["meta-data"] || []).each_with_object({}) do |h, acc| + acc[h["name"]] = h["value"] + end + end + + # @return [Hash] an application's labels a.k.a application name in available resources. + def labels + @parsed_variables["labels"] + end + + # @return [Hash] an application's relative icon paths grouped by densities + # @deprecated no longer used + def icons + @parsed_variables["icons"] + end end + MULTIPLE_ELEMENTS_TAG_NAMES = %w( + meta-data + ).freeze + BOOLEAN_ELEMENT_TAG_NAMES = %w( + testOnly='-1' + application-debuggable + ).freeze + SINGLE_VALUE_ELEMENT_TAG_NAMES = %w( + application + application-label + package + sdkVersion + targetSdkVersion + ).freeze + + NOT_ALLOW_DUPLICATE_TAG_NAMES = %w( + application + sdkVersion + targetSdkVersion + ).freeze + + # @return [String] def self.dump_badging(apk_filepath:) - stdout, stderr, status = Open3.capture3("aapt2", "dump", "badging", apk_filepath) - stdout if status.success? + stdout, stderr, status = Open3.capture3("aapt2", "dump", "badging", "--include-meta-data", apk_filepath) if status.success? stdout else - if stderr.index(/ERROR:?\s/) # : is never required because it's mixed. - # *normally* failed. The output of aapt2 dump is helpful. - # ref: https://cs.android.com/android/platform/superproject/+/master:frameworks/base/tools/aapt/Command.cpp;l=860?q=%22dump%20failed%22%20aapt - raise UnacceptableApkError, "This apk file cannot be analyzed using 'aapt2 dump badging --include-meta-data'. stdout = #{stdout}, stderr = #{stderr}" + if stderr.index(/ERROR:?\s/i) # : is never required because it's mixed. + if stderr.include?("failed opening zip") + raise InvalidApkError, "This apk file is an invalid zip-format or contains no AndroidManifest.xml" + elsif stderr.include?("failed to parse binary AndroidManifest.xml") + raise InvalidApkError, "AndroidManifest.xml seems to be invalid and not decode-able." + else + # *normally* failed. The output of aapt2 dump is helpful. + # ref: https://cs.android.com/android/platform/superproject/+/master:frameworks/base/tools/aapt/Command.cpp;l=860?q=%22dump%20failed%22%20aapt + raise Aapt2Error.new(message: "This apk file cannot be parsed using 'aapt2 dump badging --include-meta-data'", stdout: stdout, stderr: stderr) + end else # unexpectedly failed. This may happen due to the running environment. - raise UnacceptableApkError, "'aapt2 dump badging --include-meta-data' failed due to an unexpected error." + raise Aapt2Error.new(message: "'aapt2 dump badging --include-meta-data' failed due to an unexpected error.", stdout: stdout, stderr: stderr) end end end @@ -81,42 +125,49 @@ def self.dump_badging(apk_filepath:) # @param apk_filepath [String] a path to apk_filepath def initialize(apk_filepath:) @apk_filepath = apk_filepath - @dump_results = self.class.dump_badging(apk_filepath: apk_filepath)&.scrub&.split("\n") end # Parse output of aapt2 command to Hash format # # @return [::AndroidApk::Aapt2::DumpBadging::Result] def parse - vars = {} - results = @dump_results.dup - results.each do |line| - key, value = parse_line(line) - next if key.nil? + return @parse if defined?(@parse) - if vars.key?(key) - reject_illegal_duplicated_key!(key) + raw_result_lines = self.class.dump_badging(apk_filepath: @apk_filepath).scrub.split("\n") - if vars[key].kind_of?(Hash) and value.kind_of?(Hash) - vars[key].merge(value) - else - vars[key] = [vars[key]] unless vars[key].kind_of?(Array) - if value.kind_of?(Array) - vars[key].concat(value) - else + vars = { + "labels" => {}, + "icons" => {}, + "meta-data" => [] + } + + raw_result_lines.each do |line| + key, value = parse_line(line) + + if !(m = key.match(/\Aapplication-icon-(\d+)\z/)).nil? + vars["icons"][m[1].to_i] = value unless value.nil? + elsif !(m = key.match(/\Aapplication-label-(\S+)\z/)).nil? + vars["labels"][m[1]] = value unless value.nil? + else + # noinspection RubyCaseWithoutElseBlockInspection + case key + when *BOOLEAN_ELEMENT_TAG_NAMES + vars[key] = true + when *MULTIPLE_ELEMENTS_TAG_NAMES + if value.kind_of?(Hash) vars[key].push(value) + else + vars[key].push(*value) end + when *SINGLE_VALUE_ELEMENT_TAG_NAMES + reject_illegal_duplicated_key!(key) if vars.key?(key) + + vars[key] = value end - else - vars[key] = if value.nil? || value.kind_of?(Hash) - value - else - value.length > 1 ? value : value[0] - end end end - Result.new(vars) + @parse = Result.new(raw_result_lines: raw_result_lines, parsed_variables: vars) end # workaround for https://code.google.com/p/android/issues/detail?id=160847 @@ -146,25 +197,29 @@ def parse # Parse output of a line of aapt command like `key: values` # - # @param [String, nil] line a line of aapt command. - # @return [[String, Hash], nil] return nil if (see line) is nil. Otherwise the parsed hash will be returned. + # @param [String] line a line of aapt command. + # @return [[String, Hash]] return nil if (see line) is nil. Otherwise the parsed hash will be returned. private def parse_line(line) - return nil if line.nil? + key, values = line.split(":", 2) - info = line.split(":", 2) values = - if info[0].start_with?("application-label") - parse_values_workaround info[1] + if key.start_with?("application-label") + parse_values_workaround(values) else - parse_values info[1] + parse_values(values) end - return info[0], values + + if values.nil? || values.kind_of?(Hash) || values.length > 1 + [key, values] + else + [key, values[0]] + end end # @param [String] key a key of AndroidManifest.xml - # @raise [AndroidManifestValidateError] if a key is found in (see NOT_ALLOW_DUPLICATE_TAG_NAMES) + # @raise [InvalidApkError] if a key is found in (see NOT_ALLOW_DUPLICATE_TAG_NAMES) private def reject_illegal_duplicated_key!(key) - raise AndroidManifestValidateError, key if NOT_ALLOW_DUPLICATE_TAG_NAMES.include?(key) + raise InvalidApkError, "duplicates of #{key} tag in AndroidManifest.xml are invalid." if NOT_ALLOW_DUPLICATE_TAG_NAMES.include?(key) end end end diff --git a/lib/android_apk/error.rb b/lib/android_apk/error.rb index a4172bb..778ee2b 100644 --- a/lib/android_apk/error.rb +++ b/lib/android_apk/error.rb @@ -4,14 +4,24 @@ class AndroidApk class Error < StandardError; end class ApkFileNotFoundError < Error; end - class UnacceptableApkError < Error; end - class AndroidManifestValidateError < Error - def initialize(tag) - super("duplicates of #{tag} tag in AndroidManifest.xml are invalid.") + # @!attribute [r] stdout + # @return [String, nil] Standard output of a command + # @!attribute [r] stderr + # @return [String, nil] Standard error of a command + class Aapt2Error < Error + attr_reader :stdout, :stderr + + def initialize(message:, stdout:, stderr:) + super(message) + @stdout = stdout + @stderr = stderr end end + # This error wIll be thrown if an apk is invalid or contains invalid files. + class InvalidApkError < Error; end + class ApkSignerExecutionError < Error; end class ParsingSignatureError < Error; end end diff --git a/spec/android_apk/aapt2/dump_badging_spec.rb b/spec/android_apk/aapt2/dump_badging_spec.rb new file mode 100644 index 0000000..f5a35f4 --- /dev/null +++ b/spec/android_apk/aapt2/dump_badging_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +describe AndroidApk::Aapt2::DumpBadging do + let(:aapt2) { AndroidApk::Aapt2::DumpBadging.new(apk_filepath: apk_filepath) } + + describe "#parse" do + let(:result) { aapt2.parse } + + context "if invalid sample apk files are given" do + cases = [ + { + filepath: fixture_file("invalid", "no_android_manifest.apk"), + error: AndroidApk::InvalidApkError, + error_message: "This apk file is an invalid zip-format or contains no AndroidManifest.xml" + }, + { + filepath: fixture_file("invalid", "corrupt_manifest.apk"), + error: AndroidApk::InvalidApkError, + error_message: "AndroidManifest.xml seems to be invalid and not decode-able." + }, + { + filepath: fixture_file("invalid", "multi_application_tag.apk"), + error: AndroidApk::InvalidApkError, + error_message: "duplicates of application tag in AndroidManifest.xml are invalid." + }, + ] + + cases.each do |c| + context "for #{c[:filepath]}" do + let(:apk_filepath) { c[:filepath] } + + it { expect { result }.to raise_error(c[:error], c[:error_message]) } + end + end + end + + context "if an apk contains meta-tags" do + let(:apk_filepath) { fixture_file("meta-tag", "include-sdk.apk") } + + it "contains sdk meta data" do + expect(result.meta_data).to eq( + { + "com.deploygate.sdk.version" => "4", + "com.deploygate.sdk.artifact_version" => "4.6.1", + "com.deploygate.sdk.feature_flags" => "31" + } + ) + end + end + + context "if an apk does not contain meta-tags" do + let(:apk_filepath) { fixture_file("meta-tag", "no-meta-tag.apk") } + + it { expect(result.meta_data).to be_empty } + end + end +end diff --git a/spec/android_apk_spec.rb b/spec/android_apk_spec.rb index 2db7e65..e2cd609 100644 --- a/spec/android_apk_spec.rb +++ b/spec/android_apk_spec.rb @@ -26,34 +26,10 @@ end end - context "if invalid sample apk files are given" do - cases = [ - { - filepath: fixture_file("invalid", "no_such_file"), - error: AndroidApk::ApkFileNotFoundError, - }, - { - filepath: fixture_file("invalid", "no_android_manifest.apk"), - error: AndroidApk::UnacceptableApkError, - }, - { - filepath: fixture_file("invalid", "corrupt_manifest.apk"), - error: AndroidApk::UnacceptableApkError, - }, - { - filepath: fixture_file("invalid", "multi_application_tag.apk"), - error: AndroidApk::AndroidManifestValidateError, - error_message: /application/ - }, - ] - - cases.each do |c| - context "for #{c[:filepath]}" do - let(:apk_filepath) { c[:filepath] } - - it { expect { subject }.to raise_error(c[:error], c[:error_message]) } - end - end + context "if a file is not found" do + let(:apk_filepath) { fixture_file("invalid", "no_such_file") } + + it { expect { subject }.to raise_error(AndroidApk::ApkFileNotFoundError) } end context "if valid sample apk files are given" do diff --git a/spec/fixture/meta-tag/include-sdk.apk b/spec/fixture/meta-tag/include-sdk.apk new file mode 100644 index 0000000..6f75a09 Binary files /dev/null and b/spec/fixture/meta-tag/include-sdk.apk differ diff --git a/spec/fixture/meta-tag/no-meta-tag.apk b/spec/fixture/meta-tag/no-meta-tag.apk new file mode 100644 index 0000000..1ffb36b Binary files /dev/null and b/spec/fixture/meta-tag/no-meta-tag.apk differ