Skip to content

Commit 323e622

Browse files
committed
android_send_app_size_metrics action + specs
1 parent 62ecdf7 commit 323e622

File tree

3 files changed

+356
-0
lines changed

3 files changed

+356
-0
lines changed
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
require_relative '../../helper/app_size_metrics_helper'
2+
3+
module Fastlane
4+
module Actions
5+
class AndroidSendAppSizeMetricsAction < Action
6+
def self.run(params)
7+
# Check input parameters
8+
base_url = URI(params[:api_base_url])
9+
api_token = params[:api_token]
10+
if (api_token.nil? || api_token.empty?) && !base_url.is_a?(URI::File)
11+
UI.user_error!('An API token is required when using an `api_base_url` with a scheme other than `file://`')
12+
end
13+
14+
# Build the payload base
15+
metrics_helper = Fastlane::WPMRT::AppSizeMetricsHelper.new(
16+
Platform: 'Android',
17+
'App Name': params[:app_name],
18+
'App Version': params[:app_version_name],
19+
'Version Code': params[:app_version_code],
20+
'Product Flavor': params[:product_flavor],
21+
'Build Type': params[:build_type],
22+
Source: params[:source]
23+
)
24+
metrics_helper.add_metric(name: 'AAB File Size', value: File.size(params[:aab_path]))
25+
26+
# Add device-specific 'splits' metrics to the payload if a `:include_split_sizes` is enabled
27+
if params[:include_split_sizes]
28+
check_bundletool_installed!
29+
apkanalyzer_bin = find_apkanalyzer_binary!
30+
UI.message("[App Size Metrics] Generating the various APK splits from #{params[:aab_path]}...")
31+
Dir.mktmpdir('release-toolkit-android-app-size-metrics') do |tmp_dir|
32+
Action.sh('bundletool', 'build-apks', '--bundle', params[:aab_path], '--output-format', 'DIRECTORY', '--output', tmp_dir)
33+
apks = Dir.glob('splits/*.apk', base: tmp_dir).map { |f| File.join(tmp_dir, f) }
34+
UI.message("[App Size Metrics] Generated #{apks.length} APKs.")
35+
36+
apks.each do |apk|
37+
UI.message("[App Size Metrics] Computing file and download size of #{File.basename(apk)}...")
38+
split_name = File.basename(apk, '.apk').delete_prefix('base-')
39+
file_size = Action.sh(apkanalyzer_bin, 'apk', 'file-size', apk, print_command: false, print_command_output: false).chomp.to_i
40+
download_size = Action.sh(apkanalyzer_bin, 'apk', 'download-size', apk, print_command: false, print_command_output: false).chomp.to_i
41+
metrics_helper.add_metric(name: 'APK File Size', value: file_size, meta: { split: split_name })
42+
metrics_helper.add_metric(name: 'Download Size', value: download_size, meta: { split: split_name })
43+
end
44+
45+
UI.message('[App Size Metrics] Done computing splits sizes.')
46+
end
47+
end
48+
49+
# Send the payload
50+
metrics_helper.send_metrics(
51+
base_url: base_url,
52+
api_token: api_token,
53+
use_gzip: params[:use_gzip_content_encoding]
54+
)
55+
end
56+
57+
def self.check_bundletool_installed!
58+
Action.sh('command', '-v', 'bundletool', print_command: false, print_command_output: false)
59+
rescue StandardError
60+
UI.user_error!('bundletool is required to build the split APKs. Install it with `brew install bundletool`')
61+
raise
62+
end
63+
64+
def self.find_apkanalyzer_binary!
65+
sdk_root = ENV['ANDROID_SDK_ROOT'] || ENV['ANDROID_HOME']
66+
apkanalyzer_bin = sdk_root.nil? ? Action.sh('command', '-v', 'apkanalyzer') : File.join(sdk_root, 'cmdline-tools', 'latest', 'bin', 'apkanalyzer')
67+
UI.user_error!('Unable to find apkanalyzer executable. Make sure you installed the Android SDK Command-line Tools') unless File.executable?(apkanalyzer_bin)
68+
apkanalyzer_bin
69+
end
70+
71+
#####################################################
72+
# @!group Documentation
73+
#####################################################
74+
75+
def self.description
76+
'Send Android app size metrics to our metrics server'
77+
end
78+
79+
def self.details
80+
<<~DETAILS
81+
Send Android app size metrics to our metrics server.
82+
83+
See https://github.com/Automattic/apps-metrics for the API contract expected by the Metrics server you will send those metrics to.
84+
85+
Tip: If you provide a `file://` URL for the `api_base_url`, the action will write the payload on disk at the specified path instead of sending
86+
the data to a endpoint over network. This can be useful e.g. to inspect the payload and debug it, or to store the metrics data as CI artefacts.
87+
DETAILS
88+
end
89+
90+
def self.available_options
91+
[
92+
FastlaneCore::ConfigItem.new(
93+
key: :api_base_url,
94+
env_name: 'FL_ANDROID_SEND_APP_SIZE_METRICS_API_BASE_URL',
95+
description: 'The endpoint API URL to publish metrics to. (Note: you can also point to a `file://` URL to write the payload to a file instead)',
96+
type: String,
97+
optional: false
98+
),
99+
FastlaneCore::ConfigItem.new(
100+
key: :api_token,
101+
env_name: 'FL_ANDROID_SEND_APP_SIZE_METRICS_API_TOKEN',
102+
description: 'The bearer token to call the API. Required, unless `api_base_url` is a `file://` URL',
103+
type: String,
104+
optional: true
105+
),
106+
FastlaneCore::ConfigItem.new(
107+
key: :use_gzip_content_encoding,
108+
env_name: 'FL_ANDROID_SEND_APP_SIZE_METRICS_USE_GZIP_CONTENT_ENCODING',
109+
description: 'Specify that we should use `Content-Encoding: gzip` and gzip the body when sending the request',
110+
type: FastlaneCore::Boolean,
111+
default_value: true
112+
),
113+
FastlaneCore::ConfigItem.new(
114+
key: :app_name,
115+
env_name: 'FL_ANDROID_SEND_APP_SIZE_METRICS_APP_NAME',
116+
description: 'The name of the app for which we are publishing metrics, to help filter by app in the dashboard',
117+
type: String,
118+
optional: false
119+
),
120+
FastlaneCore::ConfigItem.new(
121+
key: :app_version_name,
122+
env_name: 'FL_ANDROID_SEND_APP_SIZE_METRICS_APP_VERSION_NAME',
123+
description: 'The version name of the app for which we are publishing metrics, to help filter by version in the dashboard',
124+
type: String,
125+
optional: false
126+
),
127+
FastlaneCore::ConfigItem.new(
128+
key: :app_version_code,
129+
env_name: 'FL_ANDROID_SEND_APP_SIZE_METRICS_APP_VERSION_CODE',
130+
description: 'The version code of the app for which we are publishing metrics, to help filter by version in the dashboard',
131+
type: Integer,
132+
optional: true
133+
),
134+
FastlaneCore::ConfigItem.new(
135+
key: :product_flavor,
136+
env_name: 'FL_ANDROID_SEND_APP_SIZE_METRICS_PRODUCT_FLAVOR',
137+
description: 'The product flavor for which we are publishing metrics, to help filter by flavor in the dashboard. E.g. `Vanilla`, `Jalapeno`, `Wasabi`',
138+
type: String,
139+
optional: true
140+
),
141+
FastlaneCore::ConfigItem.new(
142+
key: :build_type,
143+
env_name: 'FL_ANDROID_SEND_APP_SIZE_METRICS_BUILD_TYPE',
144+
description: 'The build type for which we are publishing metrics, to help filter by build type in the dashboard. E.g. `Debug`, `Release`',
145+
type: String,
146+
optional: true
147+
),
148+
FastlaneCore::ConfigItem.new(
149+
key: :source,
150+
env_name: 'FL_ANDROID_SEND_APP_SIZE_METRICS_SOURCE',
151+
description: 'The type of event at the origin of that build, to help filter data in the dashboard. E.g. `pr`, `beta`, `final-release`',
152+
type: String,
153+
optional: true
154+
),
155+
FastlaneCore::ConfigItem.new(
156+
key: :aab_path,
157+
env_name: 'FL_ANDROID_SEND_APP_SIZE_METRICS_AAB_PATH',
158+
description: 'The path to the .aab to extract size information from',
159+
type: String,
160+
optional: false,
161+
verify_block: proc do |value|
162+
UI.user_error!('You must provide an path to an existing `.aab` file') unless File.exist?(value)
163+
end
164+
),
165+
FastlaneCore::ConfigItem.new(
166+
key: :include_split_sizes,
167+
env_name: 'FL_ANDROID_SEND_APP_SIZE_METRICS_INCLUDE_SPLIT_SIZES',
168+
description: 'Indicate if we should use `bundletool` and `apkanalyzer` to also compute and send "split apk" sizes per architecture. ' \
169+
+ 'Setting this to `true` adds a bit of extra time to generate the `.apk` and extract the data, but provides more detailed metrics',
170+
type: FastlaneCore::Boolean,
171+
default_value: true
172+
),
173+
]
174+
end
175+
176+
def self.return_type
177+
:integer
178+
end
179+
180+
def self.return_value
181+
'The HTTP return code from the call. Expect a 201 when new metrics were received successfully and entries created in the database'
182+
end
183+
184+
def self.authors
185+
['automattic']
186+
end
187+
188+
def self.is_supported?(platform)
189+
platform == :android
190+
end
191+
end
192+
end
193+
end
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
require_relative './spec_helper'
2+
3+
describe Fastlane::Actions::AndroidSendAppSizeMetricsAction do
4+
let(:test_data_dir) { File.join(File.dirname(__FILE__), 'test-data', 'app_size_metrics') }
5+
6+
def test_app_size_action(fake_aab_size:, fake_apks:, expected_payload:, **other_action_args)
7+
in_tmp_dir do |tmp_dir|
8+
# Arrange
9+
output_file = File.join(tmp_dir, 'output-payload')
10+
aab_path = File.join(tmp_dir, 'fake.aab')
11+
File.write(aab_path, '-fake-aab-file-')
12+
allow(File).to receive(:size).with(aab_path).and_return(fake_aab_size)
13+
14+
if other_action_args[:include_split_sizes] != false
15+
# Arrange: fake that apkanalyzer exists
16+
ENV['ANDROID_SDK_ROOT'] = '__ANDROID_SDK_ROOT__FOR_TESTS__'
17+
apkanalyzer_bin = File.join('__ANDROID_SDK_ROOT__FOR_TESTS__', 'cmdline-tools', 'latest', 'bin', 'apkanalyzer')
18+
allow(File).to receive(:executable?).with(apkanalyzer_bin).and_return(true)
19+
20+
# Arrange: fake that bundletool exists and mock its call to create fake apks with corresponding apkanalyzer calls mocks
21+
allow(Fastlane::Action).to receive(:sh).with('command', '-v', 'bundletool', anything)
22+
allow(Fastlane::Action).to receive(:sh).with('bundletool', 'build-apks', '--bundle', aab_path, '--output-format', 'DIRECTORY', '--output', anything) do |*args|
23+
bundletool_tmpdir = args.last
24+
FileUtils.mkdir(File.join(bundletool_tmpdir, 'splits'))
25+
fake_apks.each do |apk_name, sizes|
26+
apk_path = File.join(bundletool_tmpdir, 'splits', apk_name.to_s)
27+
File.write(apk_path, "Fake APK file (#{sizes})")
28+
allow(Fastlane::Action).to receive(:sh).with(apkanalyzer_bin, 'apk', 'file-size', apk_path, anything).and_return(sizes[0].to_s)
29+
allow(Fastlane::Action).to receive(:sh).with(apkanalyzer_bin, 'apk', 'download-size', apk_path, anything).and_return(sizes[1].to_s)
30+
end
31+
end
32+
end
33+
34+
# Act
35+
code = run_described_fastlane_action(
36+
api_base_url: File.join('file://localhost/', output_file),
37+
aab_path: aab_path,
38+
**other_action_args
39+
)
40+
41+
# Asserts
42+
expect(code).to eq(201)
43+
expect(File).to exist(output_file)
44+
gzip_disabled = other_action_args[:use_gzip_content_encoding] == false
45+
generated_payload = gzip_disabled ? File.read(output_file) : Zlib::GzipReader.open(output_file, &:read)
46+
# Compare the payloads as pretty-formatted JSON, to make the diff in test failures more readable if one happen
47+
expect(JSON.pretty_generate(JSON.parse(generated_payload))).to eq(JSON.pretty_generate(expected_payload)), 'Decompressed JSON payload was not as expected'
48+
# Compare the payloads as raw uncompressed data as a final check
49+
expect(generated_payload).to eq(expected_payload.to_json)
50+
end
51+
end
52+
53+
context 'when `include_split_sizes` is turned off' do
54+
it 'generates the expected payload compressed by default' do
55+
expected = {
56+
meta: [
57+
{ name: 'Platform', value: 'Android' },
58+
{ name: 'App Name', value: 'my-app' },
59+
{ name: 'App Version', value: '10.2-rc-3' },
60+
{ name: 'Product Flavor', value: 'Vanilla' },
61+
{ name: 'Build Type', value: 'Release' },
62+
{ name: 'Source', value: 'unit-test' },
63+
],
64+
metrics: [
65+
{ name: 'AAB File Size', value: 123_456 },
66+
]
67+
}
68+
69+
test_app_size_action(
70+
fake_aab_size: 123_456,
71+
fake_apks: {},
72+
expected_payload: expected,
73+
app_name: 'my-app',
74+
app_version_name: '10.2-rc-3',
75+
product_flavor: 'Vanilla',
76+
build_type: 'Release',
77+
source: 'unit-test',
78+
include_split_sizes: false
79+
)
80+
end
81+
82+
it 'generates the expected payload uncompressed when disabling gzip' do
83+
expected = {
84+
meta: [
85+
{ name: 'Platform', value: 'Android' },
86+
{ name: 'App Name', value: 'my-app' },
87+
{ name: 'App Version', value: '10.2-rc-3' },
88+
{ name: 'Product Flavor', value: 'Vanilla' },
89+
{ name: 'Build Type', value: 'Release' },
90+
{ name: 'Source', value: 'unit-test' },
91+
],
92+
metrics: [
93+
{ name: 'AAB File Size', value: 123_456 },
94+
]
95+
}
96+
97+
test_app_size_action(
98+
fake_aab_size: 123_456,
99+
fake_apks: {},
100+
expected_payload: expected,
101+
app_name: 'my-app',
102+
app_version_name: '10.2-rc-3',
103+
product_flavor: 'Vanilla',
104+
build_type: 'Release',
105+
source: 'unit-test',
106+
include_split_sizes: false,
107+
use_gzip_content_encoding: false
108+
)
109+
end
110+
end
111+
112+
context 'when keeping the default value of `include_split_sizes` turned on' do
113+
it 'generates the expected payload containing the aab file size and optimized split sizes' do
114+
expected_fixture = File.join(test_data_dir, 'android-metrics-payload.json')
115+
expected = JSON.parse(File.read(expected_fixture))
116+
117+
test_app_size_action(
118+
fake_aab_size: 987_654_321,
119+
fake_apks: {
120+
'base-arm64_v8a.apk': [164_080, 64_080],
121+
'base-arm64_v8a_2.apk': [164_082, 64_082],
122+
'base-armeabi.apk': [150_000, 50_000],
123+
'base-armeabi_2.apk': [150_002, 50_002],
124+
'base-armeabi_v7a.apk': [150_070, 50_070],
125+
'base-armeabi_v7a_2.apk': [150_072, 50_072]
126+
},
127+
expected_payload: expected,
128+
app_name: 'wordpress',
129+
app_version_name: '19.8-rc-3',
130+
app_version_code: 1214,
131+
product_flavor: 'Vanilla',
132+
build_type: 'Release',
133+
source: 'unit-test'
134+
)
135+
end
136+
end
137+
end
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"meta": [
3+
{ "name": "Platform", "value": "Android" },
4+
{ "name": "App Name", "value": "wordpress" },
5+
{ "name": "App Version", "value": "19.8-rc-3" },
6+
{ "name": "Version Code", "value": 1214 },
7+
{ "name": "Product Flavor", "value": "Vanilla" },
8+
{ "name": "Build Type", "value": "Release" },
9+
{ "name": "Source", "value": "unit-test" }
10+
],
11+
"metrics": [
12+
{ "name": "AAB File Size", "value": 987654321 },
13+
{ "name": "APK File Size", "value": 164082, "meta": [{ "name": "split", "value": "arm64_v8a_2" }] },
14+
{ "name": "Download Size", "value": 64082, "meta": [{ "name": "split", "value": "arm64_v8a_2" }] },
15+
{ "name": "APK File Size", "value": 150072, "meta": [{ "name": "split", "value": "armeabi_v7a_2" }] },
16+
{ "name": "Download Size", "value": 50072, "meta": [{ "name": "split", "value": "armeabi_v7a_2" }] },
17+
{ "name": "APK File Size", "value": 150002, "meta": [{ "name": "split", "value": "armeabi_2" }] },
18+
{ "name": "Download Size", "value": 50002, "meta": [{ "name": "split", "value": "armeabi_2" }] },
19+
{ "name": "APK File Size", "value": 164080, "meta": [{ "name": "split", "value": "arm64_v8a" }] },
20+
{ "name": "Download Size", "value": 64080, "meta": [{ "name": "split", "value": "arm64_v8a" }] },
21+
{ "name": "APK File Size", "value": 150000, "meta": [{ "name": "split", "value": "armeabi" }] },
22+
{ "name": "Download Size", "value": 50000, "meta": [{ "name": "split", "value": "armeabi" }] },
23+
{ "name": "APK File Size", "value": 150070, "meta": [{ "name": "split", "value": "armeabi_v7a" }] },
24+
{ "name": "Download Size", "value": 50070, "meta": [{ "name": "split", "value": "armeabi_v7a" }] }
25+
]
26+
}

0 commit comments

Comments
 (0)