# frozen_string_literal: false
module Danger
require 'json'
require 'open-uri'
require 'fileutils'
require_relative '../converter/parser/report_parser'
require_relative '../converter/helper/memory_size'
require_relative '../converter/helper/android_utils'
# A Danger plugin for reporting iOS and Android app size violations.
#
#
# A valid App Thinning Size Report must be passed to the plugin
# for accurate functionality in case of iOS.
#
# @example Report iOS app size violations if one or more App variants
# exceed 4GB.
#
# report_path = "/Path/to/AppSize/Report.txt"
# app_size_report.flag_ios_violations(
# report_path,
# build_type: 'App',
# size_limit: 4,
# limit_unit: 'GB',
# fail_on_warning: false
# )
#
# @example Report iOS app size violations if one or more App Clip variants
# exceed 8MB.
#
# report_path = "/Path/to/AppSize/Report.txt"
# app_size_report.flag_ios_violations(
# report_path,
# build_type: 'Clip',
# size_limit: 8,
# limit_unit: 'MB',
# fail_on_warning: false
# )
#
# @example Fail PR if one or more iOS App Clip variants exceed 8MB.
#
# report_path = "/Path/to/AppSize/Report.txt"
# app_size_report.flag_ios_violations(
# report_path,
# build_type: 'Clip',
# size_limit: 8,
# limit_unit: 'MB',
# fail_on_warning: true
# )
#
# @example Get JSON string representation of iOS app thinning size report
#
# report_path = "/Path/to/AppSize/Report.txt"
# app_size_json = app_size_report.report_json(report_path)
#
# @example Report Android app size violations if one or more App variants
#
# aab_path = "/Path/to/app.aab"
# ks_path = "/Path/to/keyStore"
# ks_alias = "KeyAlias"
# ks_password = "Key Password"
# ks_alias_password = "Key Alias Password"
# app_size_report.flag_android_violations(
# aab_path,
# ks_path,
# ks_alias,
# ks_password,
# ks_alias_password,
# screen_densities: ["MDPI", "HDPI", "XHDPI", "XXHDPI", "XXXHDPI"],
# languages: ["en", "de", "da", "es", "fr", "it", "nb", "nl", "sv"],
# build_type: 'App',
# size_limit: 14,
# limit_unit: 'MB',
# fail_on_warning: false
# )
#
# @example Report Android Instant app size violations if one or more App variants
#
# aab_path = "/Path/to/app.aab"
# ks_path = "/Path/to/keyStore"
# ks_alias = "KeyAlias"
# ks_password = "Key Password"
# ks_alias_password = "Key Alias Password"
# app_size_report.flag_android_violations(
# aab_path,
# ks_path,
# ks_alias,
# ks_password,
# ks_alias_password,
# screen_densities: ["MDPI", "HDPI", "XHDPI", "XXHDPI", "XXXHDPI"],
# languages: ["en", "de", "da", "es", "fr", "it", "nb", "nl", "sv"],
# build_type: 'Instant',
# size_limit: 4,
# limit_unit: 'MB',
# fail_on_warning: false
# )
#
# @example Fail PR if one or more Android Instant App variants exceed 4MB.
#
# aab_path = "/Path/to/app.aab"
# ks_path = "/Path/to/keyStore"
# ks_alias = "KeyAlias"
# ks_password = "Key Password"
# ks_alias_password = "Key Alias Password"
# app_size_report.flag_android_violations(
# aab_path,
# ks_path,
# ks_alias,
# ks_password,
# ks_alias_password,
# screen_densities: ["MDPI", "HDPI", "XHDPI", "XXHDPI", "XXXHDPI"],
# languages: ["en", "de", "da", "es", "fr", "it", "nb", "nl", "sv"],
# build_type: 'Instant',
# size_limit: 4,
# limit_unit: 'MB',
# fail_on_warning: true
# )
#
# @see ChargePoint/danger-app_size_report
# @tags ios, xcode, appclip, thinning, size
#
class DangerAppSizeReport < Plugin
# Reports IOS app size violations given a valid App Thinning Size Report.
# @param [String, required] report_path
# Path to valid App Thinning Size Report text file.
# @param [String, optional] build_type
# Specify whether the report corresponds to an App or an App Clip.
# Default: 'App'
# Supported values: 'App', 'Clip'
# @param [Numeric, optional] size_limit
# Specify the app size limit.
# Default: 4
# @param [String, optional] limit_unit
# Specific the unit for the given size limit.
# Default: 'GB'
# Supported values: 'KB', 'MB', 'GB'
# @param [Boolean, optional] fail_on_warning
# Specify whether the PR should fail if one or more app variants
# exceed the given size limit. By default, the plugin issues
# a warning in this case.
# Default: 'false'
# @return [void]
#
def flag_ios_violations(report_path, build_type: 'App', size_limit: 4, limit_unit: 'GB', fail_on_warning: false)
report_text = File.read(report_path)
variants = ReportParser.parse(report_text)
raise ArgumentError, "The 'build_type' argument only accepts the values \"App\" and \"Clip\"" unless %w[App Clip].include? build_type
raise ArgumentError, "The 'size_limit' argument only accepts numeric values" unless size_limit.is_a? Numeric
limit_unit.upcase!
raise ArgumentError, "The 'build_type' argument only accepts the values \"KB\", \"MB\" and \"GB\"" unless %w[KB MB GB].include? limit_unit
raise ArgumentError, "The 'fail_on_warning' argument only accepts the values 'true' and 'false'" unless [true, false].include? fail_on_warning
generate_size_report_markdown(variants, build_type, size_limit, limit_unit, fail_on_warning)
generate_variant_descriptors_markdown(variants)
generate_ads_label_markdown
end
# Reports Android app size violations given a valid AAB.
# @param [String, required] aab_path
# Path to valid AAB file.
# @param [String, required] ks_path
# Path to valid signing key file.
# @param [String, required] ks_alias
# Alias of signing key
# @param [String, required] ks_password
# Password of signing key
# @param [String, required] ks_alias_password
# Alias Password of signing key.
# @param [Array, optional] screen_densities
# Array of screen densities to check APK size
# Default: ["MDPI", "HDPI", "XHDPI", "XXHDPI", "XXXHDPI"]
# @param [Array, optional] languages
# Array of languages to check APK size
# Default: ["en"]
# @param [String, optional] build_type
# Specify whether the report corresponds to an App, Instant.
# Default: 'App'
# Supported values: 'App', 'Instant'
# @param [Numeric, optional] size_limit
# Specify the app size limit.
# Default: 150
# @param [String, optional] limit_unit
# Specific the unit for the given size limit.
# Default: 'MB'
# Supported values: 'KB', 'MB', 'GB'
# @param [Boolean, optional] fail_on_warning
# Specify whether the PR should fail if one or more app variants
# exceed the given size limit. By default, the plugin issues
# a warning in this case.
# Default: 'false'
# @return [void]
#
def flag_android_violations(aab_path, ks_path, ks_alias, ks_password, ks_alias_password, screen_densities: %w[MDPI HDPI XHDPI XXHDPI XXXHDPI], languages: ['en'], build_type: 'App', size_limit: 150, limit_unit: 'MB', fail_on_warning: false)
project_root = Dir.pwd
temp_path = "#{project_root}/temp"
apks_path = "#{temp_path}/output.apks"
size_csv_path = "#{temp_path}/output.csv"
bundletool_path = "#{temp_path}/bundletool.jar"
bundletool_version = '1.8.2'
variants_limit = 25
raise ArgumentError, "The 'build_type' argument only accepts the values \"App\" and \"Instant\"" unless %w[App Instant].include? build_type
raise ArgumentError, "The 'size_limit' argument only accepts numeric values" unless size_limit.is_a? Numeric
limit_unit.upcase!
raise ArgumentError, "The 'limit_unit' argument only accepts the values \"KB\", \"MB\" and \"GB\"" unless %w[KB MB GB].include? limit_unit
raise ArgumentError, "The 'fail_on_warning' argument only accepts the values 'true' and 'false'" unless [true, false].include? fail_on_warning
create_temp_dir(temp_path)
unless AndroidUtils.download_bundletool(bundletool_version, bundletool_path)
clean_temp!
return
end
AndroidUtils.generate_apks(aab_path, ks_path, ks_alias, ks_password, ks_alias_password, apks_path,
bundletool_path)
AndroidUtils.generate_estimated_sizes(apks_path, size_csv_path, bundletool_path, build_type)
filtered_sizes = AndroidUtils.filter_estimated_sizes(size_csv_path, screen_densities, languages)
sorted_sizes = AndroidUtils.sort_estimated_sizes(filtered_sizes)
clean_temp!(temp_path)
generate_android_size_report_markdown(sorted_sizes, build_type, size_limit, limit_unit, fail_on_warning,
variants_limit)
generate_ads_label_markdown
end
# Returns a JSON string representation of the given App Thinning Size Report.
# @param [String, required] report_path
# Path to valid App Thinning Size Report text file.
# @return [String]
#
def report_json(report_path)
report_text = File.read(report_path)
variants = ReportParser.parse(report_text)
JSON.pretty_generate(variants)
end
private
def create_temp_dir(temp_path)
Dir.mkdir temp_path
end
def clean_temp!(temp_path)
FileUtils.rm_rf(temp_path)
end
def generate_android_size_report_markdown(sorted_sizes, build_type, size_limit, limit_unit, fail_on_warning, variants_limit)
limit_size = MemorySize.new("#{size_limit}#{limit_unit}")
if build_type == 'Instant' && limit_size.megabytes > 4
message "The size limit was set to 4 MB as the given limit of #{size_limit} #{limit_unit} exceeds Android Instant App size restrictions"
size_limit = 4
limit_unit = 'MB'
limit_size.kilobytes = 4 * 1024
elsif build_type == 'App' && limit_size.megabytes > 150
message "The size limit was set to 150 MB as the given limit of #{size_limit} #{limit_unit} exceeds Android App size restrictions"
size_limit = 150
limit_unit = 'MB'
limit_size.kilobytes = 150 * 1024
end
violation_count = AndroidUtils.violations_count(sorted_sizes, limit_size.bytes)
if violation_count.positive?
if fail_on_warning
failure "The size limit of #{size_limit} #{limit_unit.upcase} has been exceeded by #{violation_count} variants"
else
warn "The size limit of #{size_limit} #{limit_unit.upcase} has been exceeded by #{violation_count} variants"
end
end
exceed_size_report = "| Under Limit | SDK | ABI | Screen Density | Language | Size (Bytes) |\n"
exceed_size_report << "| :-: | :-: | :-: | :-: | :-: | :-: |\n"
more_exceed_size_report = "| Under Limit | SDK | ABI | Screen Density | Language | Size (Bytes) |\n"
more_exceed_size_report << "| :-: | :-: | :-: | :-: | :-: | :-: |\n"
under_size_report = "| Under Limit | SDK | ABI | Screen Density | Language | Size (Bytes) |\n"
under_size_report << "| :-: | :-: | :-: | :-: | :-: | :-: |\n"
counter = sorted_sizes.length - 1
exceed_counter = 1
under_counter = 1
while counter >= 0
variant = sorted_sizes[counter]
is_violating = variant.max > limit_size.bytes ? '❌' : '✅'
variant_report = "#{is_violating} | #{variant.sdk} | #{variant.abi} | #{variant.screen_density} | #{variant.language} | #{variant.max} |\n"
if variant.max > limit_size.bytes
if exceed_counter <= variants_limit
exceed_counter += 1
exceed_size_report << variant_report
else
more_exceed_size_report << variant_report
end
elsif under_counter <= variants_limit
under_counter += 1
under_size_report << variant_report
end
counter -= 1
end
size_report = "# Android #{build_type} Size Report\n"
size_report << "### Size limit = #{size_limit} #{limit_unit.upcase}\n\n"
if violation_count.positive?
size_report << "## Variants exceeding the size limit\n\n"
size_report << exceed_size_report
size_report << "\n"
if violation_count > variants_limit
size_report << "\nClick to view more violating variants!
\n\n"
size_report << more_exceed_size_report
size_report << " \n\n"
end
end
size_report << "## Variants under or equal to the size limit\n\n"
size_report << "\nClick to expand!
\n\n"
size_report << under_size_report
size_report << " \n"
markdown size_report
end
def generate_size_report_markdown(variants, build_type, size_limit, limit_unit, fail_on_warning)
limit_size = MemorySize.new("#{size_limit}#{limit_unit}")
if build_type == 'Clip' && limit_size.megabytes > 10
message "The size limit was set to 10 MB as the given limit of #{size_limit} #{limit_unit} exceeds Apple's App Clip size restrictions"
size_limit = 10
limit_unit = 'MB'
limit_size.kilobytes = 10 * 1024
elsif build_type == 'App' && limit_size.gigabytes > 4
message "The size limit was set to 4 GB as the given limit of #{size_limit} #{limit_unit} exceeds Apple's App size restrictions"
size_limit = 4
limit_unit = 'GB'
limit_size.kilobytes = 4 * 1024 * 1024
end
flagged_variant_names = []
variants.each do |variant|
flagged_variant_names.append(variant.variant) if variant.app_size.uncompressed.value > limit_size.megabytes || variant.on_demand_resources_size.uncompressed.value > limit_size.megabytes
end
if flagged_variant_names.length.positive?
if fail_on_warning
failure "The size limit of #{size_limit} #{limit_unit.upcase} has been exceeded by one or more variants"
else
warn "The size limit of #{size_limit} #{limit_unit.upcase} has been exceeded by one or more variants"
end
end
size_report = "# App Thinning Size Report\n"
size_report << "### Size limit = #{size_limit} #{limit_unit.upcase}\n\n"
size_report << "| Under Limit | Variant | App Size - Compressed | App Size - Uncompressed | ODR Size - Compressed | ODR Size - Uncompressed |\n"
size_report << "| :-: | :-: | :-: | :-: | :-: | :-: |\n"
flagged_variants_set = flagged_variant_names.to_set
variants.each do |variant|
is_violating = flagged_variants_set.include?(variant.variant) ? '❌' : '✅'
app_size_compressed = "#{variant.app_size.compressed.value} #{variant.app_size.compressed.unit}"
app_size_uncompressed = "#{variant.app_size.uncompressed.value} #{variant.app_size.uncompressed.unit}"
odr_size_compressed = "#{variant.on_demand_resources_size.compressed.value} #{variant.on_demand_resources_size.compressed.unit}"
odr_size_uncompressed = "#{variant.on_demand_resources_size.uncompressed.value} #{variant.on_demand_resources_size.uncompressed.unit}"
size_report << "#{is_violating} | #{variant.variant} | #{app_size_compressed} | #{app_size_uncompressed} | #{odr_size_compressed} | #{odr_size_uncompressed} |\n"
end
markdown size_report
end
def generate_variant_descriptors_markdown(variants)
variant_descriptors_report = "### Supported Variant Descriptors \n\n"
variants.each do |variant|
variant_descriptors_report << " \n"
variant_descriptors_report << " #{variant.variant}
\n\n"
variant_descriptors_report << "| Model | Operating System | \n"
variant_descriptors_report << "| - | :-: |\n"
variant.supported_variant_descriptors.each do |variant_descriptor|
variant_descriptors_report << "#{variant_descriptor.device} | #{variant_descriptor.os_version} | \n"
end
variant_descriptors_report << " \n\n"
end
markdown variant_descriptors_report
end
def generate_ads_label_markdown
ads_label = 'Powered by [danger-app_size_report](https://github.com/ChargePoint/danger-app_size_report)'
markdown ads_label
end
end
end