1#!/usr/bin/env nix-shell
2#!nix-shell -i ruby -p "ruby.withPackages (ps: with ps; [ slop curb nokogiri ])"
3
4require 'json'
5require 'rubygems'
6require 'shellwords'
7require 'erb'
8require 'uri'
9require 'stringio'
10require 'slop'
11require 'curb'
12require 'nokogiri'
13
14# Returns a repo URL for a given package name.
15def repo_url value
16 if value && value.start_with?('http')
17 value
18 elsif value
19 "https://dl.google.com/android/repository/#{value}"
20 else
21 nil
22 end
23end
24
25# Returns a system image URL for a given system image name.
26def image_url value, dir
27 if dir == "default"
28 dir = "android"
29 end
30 if value && value.start_with?('http')
31 value
32 elsif value
33 "https://dl.google.com/android/repository/sys-img/#{dir}/#{value}"
34 else
35 nil
36 end
37end
38
39# Runs a GET with curl.
40def _curl_get url
41 curl = Curl::Easy.new(url) do |http|
42 http.headers['User-Agent'] = 'nixpkgs androidenv update bot'
43 yield http if block_given?
44 end
45 STDERR.print "GET #{url}"
46 curl.perform
47 STDERR.puts "... #{curl.response_code}"
48
49 StringIO.new(curl.body_str)
50end
51
52# Retrieves a repo from the filesystem or a URL.
53def get location
54 uri = URI.parse(location)
55 case uri.scheme
56 when 'repo'
57 _curl_get repo_url("#{uri.host}#{uri.fragment}.xml")
58 when 'image'
59 _curl_get image_url("sys-img#{uri.fragment}.xml", uri.host)
60 else
61 if File.exist?(uri.path)
62 File.open(uri.path, 'rt')
63 else
64 raise "Repository #{uri} was neither a file nor a repo URL"
65 end
66 end
67end
68
69# Returns a JSON with the data and structure of the input XML
70def to_json_collector doc
71 json = {}
72 index = 0
73 doc.element_children.each { |node|
74 if node.children.length == 1 and node.children.first.text?
75 json["#{node.name}:#{index}"] ||= node.content
76 index += 1
77 next
78 end
79 json["#{node.name}:#{index}"] ||= to_json_collector node
80 index += 1
81 }
82 element_attributes = {}
83 doc.attribute_nodes.each do |attr|
84 if attr.name == "type"
85 type = attr.value.split(':', 2).last
86 case attr.value
87 when 'generic:genericDetailsType'
88 element_attributes["xsi:type"] ||= "ns5:#{type}"
89 when 'addon:extraDetailsType'
90 element_attributes["xsi:type"] ||= "ns8:#{type}"
91 when 'addon:mavenType'
92 element_attributes["xsi:type"] ||= "ns8:#{type}"
93 when 'sdk:platformDetailsType'
94 element_attributes["xsi:type"] ||= "ns11:#{type}"
95 when 'sdk:sourceDetailsType'
96 element_attributes["xsi:type"] ||= "ns11:#{type}"
97 when 'sys-img:sysImgDetailsType'
98 element_attributes["xsi:type"] ||= "ns12:#{type}"
99 when 'addon:addonDetailsType' then
100 element_attributes["xsi:type"] ||= "ns8:#{type}"
101 end
102 else
103 element_attributes[attr.name] ||= attr.value
104 end
105 end
106 if !element_attributes.empty?
107 json['element-attributes'] ||= element_attributes
108 end
109 json
110end
111
112# Returns a tuple of [type, revision, revision components] for a package node.
113def package_revision package
114 type_details = package.at_css('> type-details')
115 type = type_details.attributes['type']
116 type &&= type.value
117
118 revision = nil
119 components = nil
120
121 case type
122 when 'generic:genericDetailsType', 'addon:extraDetailsType', 'addon:mavenType'
123 major = text package.at_css('> revision > major')
124 minor = text package.at_css('> revision > minor')
125 micro = text package.at_css('> revision > micro')
126 preview = text package.at_css('> revision > preview')
127
128 revision = ''
129 components = []
130 unless empty?(major)
131 revision << major
132 components << major
133 end
134
135 unless empty?(minor)
136 revision << ".#{minor}"
137 components << minor
138 end
139
140 unless empty?(micro)
141 revision << ".#{micro}"
142 components << micro
143 end
144
145 unless empty?(preview)
146 revision << "-rc#{preview}"
147 components << preview
148 end
149 when 'sdk:platformDetailsType'
150 codename = text type_details.at_css('> codename')
151 api_level = text type_details.at_css('> api-level')
152 revision = empty?(codename) ? api_level : codename
153 components = [revision]
154 when 'sdk:sourceDetailsType'
155 api_level = text type_details.at_css('> api-level')
156 revision, components = api_level, [api_level]
157 when 'sys-img:sysImgDetailsType'
158 codename = text type_details.at_css('> codename')
159 api_level = text type_details.at_css('> api-level')
160 id = text type_details.at_css('> tag > id')
161 abi = text type_details.at_css('> abi')
162
163 revision = ''
164 components = []
165 if empty?(codename)
166 revision << api_level
167 components << api_level
168 else
169 revision << codename
170 components << codename
171 end
172
173 unless empty?(id)
174 revision << "-#{id}"
175 components << id
176 end
177
178 unless empty?(abi)
179 revision << "-#{abi}"
180 components << abi
181 end
182 when 'addon:addonDetailsType' then
183 api_level = text type_details.at_css('> api-level')
184 id = text type_details.at_css('> tag > id')
185 revision = api_level
186 components = [api_level, id]
187 end
188
189 [type, revision, components]
190end
191
192# Returns a hash of archives for the specified package node.
193def package_archives package
194 archives = {}
195 package.css('> archives > archive').each do |archive|
196 host_os = text archive.at_css('> host-os')
197 host_arch = text archive.at_css('> host-arch')
198 host_os = 'all' if empty?(host_os)
199 host_arch = 'all' if empty?(host_arch)
200 archives[host_os + host_arch] = {
201 'os' => host_os,
202 'arch' => host_arch,
203 'size' => Integer(text(archive.at_css('> complete > size'))),
204 'sha1' => text(archive.at_css('> complete > checksum')),
205 'url' => yield(text(archive.at_css('> complete > url')))
206 }
207 end
208 archives
209end
210
211# Returns the text from a node, or nil.
212def text node
213 node ? node.text : nil
214end
215
216# Nil or empty helper.
217def empty? value
218 !value || value.empty?
219end
220
221# Fixes up returned hashes by converting archives like
222# (e.g. {'linux' => {'sha1' => ...}, 'macosx' => ...} to
223# [{'os' => 'linux', 'sha1' => ...}, {'os' => 'macosx', ...}, ...].
224def fixup value
225 Hash[value.map do |k, v|
226 if k == 'archives' && v.is_a?(Hash)
227 [k, v.map do |os, archive|
228 fixup(archive)
229 end]
230 elsif v.is_a?(Hash)
231 [k, fixup(v)]
232 else
233 [k, v]
234 end
235 end]
236end
237
238# Today since Unix Epoch, January 1, 1970.
239def today
240 Time.now.utc.to_i / 24 / 60 / 60
241end
242
243# The expiration strategy. Expire if the last available day was before the `oldest_valid_day`.
244def expire_records record, oldest_valid_day
245 if record.is_a?(Hash)
246 if record.has_key?('last-available-day') &&
247 record['last-available-day'] < oldest_valid_day
248 return nil
249 end
250 update = {}
251 # This should only happen in the first run of this scrip after adding the `expire_record` function.
252 if record.has_key?('displayName') &&
253 !record.has_key?('last-available-day')
254 update['last-available-day'] = today
255 end
256 record.each {|key, value|
257 v = expire_records value, oldest_valid_day
258 update[key] = v if v
259 }
260 update
261 else
262 record
263 end
264end
265
266# Normalize the specified license text.
267# See: https://brash-snapper.glitch.me/ for how the munging works.
268def normalize_license license
269 license = license.dup
270 license.gsub!(/([^\n])\n([^\n])/m, '\1 \2')
271 license.gsub!(/ +/, ' ')
272 license.strip!
273 license
274end
275
276# Gets all license texts, deduplicating them.
277def get_licenses doc
278 licenses = {}
279 doc.css('license[type="text"]').each do |license_node|
280 license_id = license_node['id']
281 if license_id
282 licenses[license_id] ||= []
283 licenses[license_id] |= [normalize_license(text(license_node))]
284 end
285 end
286 licenses
287end
288
289def parse_package_xml doc
290 licenses = get_licenses doc
291 packages = {}
292 # check https://github.com/NixOS/nixpkgs/issues/373785
293 extras = {}
294
295 doc.css('remotePackage').each do |package|
296 name, _, version = package['path'].partition(';')
297 next if version == 'latest'
298
299 is_extras = name == 'extras'
300 if is_extras
301 name = package['path'].tr(';', '-')
302 end
303
304 type, revision, _ = package_revision(package)
305 next unless revision
306
307 path = package['path'].tr(';', '/')
308 display_name = text package.at_css('> display-name')
309 uses_license = package.at_css('> uses-license')
310 uses_license &&= uses_license['ref']
311 obsolete ||= package['obsolete']
312 type_details = to_json_collector package.at_css('> type-details')
313 revision_details = to_json_collector package.at_css('> revision')
314 archives = package_archives(package) {|url| repo_url url}
315 dependencies_xml = package.at_css('> dependencies')
316 dependencies = to_json_collector dependencies_xml if dependencies_xml
317
318 if is_extras
319 target = extras
320 component = package['path']
321 target = (target[component] ||= {})
322 else
323 target = (packages[name] ||= {})
324 target = (target[revision] ||= {})
325 end
326
327 target['name'] ||= name
328 target['path'] ||= path
329 target['revision'] ||= revision
330 target['displayName'] ||= display_name
331 target['license'] ||= uses_license if uses_license
332 target['obsolete'] ||= obsolete if obsolete == 'true'
333 target['type-details'] ||= type_details
334 target['revision-details'] ||= revision_details
335 target['dependencies'] ||= dependencies if dependencies
336 target['archives'] ||= {}
337 merge target['archives'], archives
338 target['last-available-day'] = today
339 end
340
341 [licenses, packages, extras]
342end
343
344def parse_image_xml doc
345 licenses = get_licenses doc
346 images = {}
347
348 doc.css('remotePackage[path^="system-images;"]').each do |package|
349 type, revision, components = package_revision(package)
350 next unless revision
351
352 path = package['path'].tr(';', '/')
353 display_name = text package.at_css('> display-name')
354 uses_license = package.at_css('> uses-license')
355 uses_license &&= uses_license['ref']
356 obsolete &&= package['obsolete']
357 type_details = to_json_collector package.at_css('> type-details')
358 revision_details = to_json_collector package.at_css('> revision')
359 archives = package_archives(package) {|url| image_url url, components[-2]}
360 dependencies_xml = package.at_css('> dependencies')
361 dependencies = to_json_collector dependencies_xml if dependencies_xml
362
363 target = images
364 components.each do |component|
365 target[component] ||= {}
366 target = target[component]
367 end
368
369 target['name'] ||= "system-image-#{revision}"
370 target['path'] ||= path
371 target['revision'] ||= revision
372 target['displayName'] ||= display_name
373 target['license'] ||= uses_license if uses_license
374 target['obsolete'] ||= obsolete if obsolete
375 target['type-details'] ||= type_details
376 target['revision-details'] ||= revision_details
377 target['dependencies'] ||= dependencies if dependencies
378 target['archives'] ||= {}
379 merge target['archives'], archives
380 target['last-available-day'] = today
381 end
382
383 [licenses, images]
384end
385
386def parse_addon_xml doc
387 licenses = get_licenses doc
388 addons, extras = {}, {}
389
390 doc.css('remotePackage').each do |package|
391 type, revision, components = package_revision(package)
392 next unless revision
393
394 path = package['path'].tr(';', '/')
395 display_name = text package.at_css('> display-name')
396 uses_license = package.at_css('> uses-license')
397 uses_license &&= uses_license['ref']
398 obsolete &&= package['obsolete']
399 type_details = to_json_collector package.at_css('> type-details')
400 revision_details = to_json_collector package.at_css('> revision')
401 archives = package_archives(package) {|url| repo_url url}
402 dependencies_xml = package.at_css('> dependencies')
403 dependencies = to_json_collector dependencies_xml if dependencies_xml
404
405 case type
406 when 'addon:addonDetailsType'
407 name = components.last
408 target = addons
409
410 # Hack for Google APIs 25 r1, which displays as 23 for some reason
411 archive_name = text package.at_css('> archives > archive > complete > url')
412 if archive_name == 'google_apis-25_r1.zip'
413 path = 'add-ons/addon-google_apis-google-25'
414 revision = '25'
415 components = [revision, components.last]
416 end
417 when 'addon:extraDetailsType', 'addon:mavenType'
418 name = package['path'].tr(';', '-')
419 components = [package['path']]
420 target = extras
421 end
422
423 components.each do |component|
424 target = (target[component] ||= {})
425 end
426
427 target['name'] ||= name
428 target['path'] ||= path
429 target['revision'] ||= revision
430 target['displayName'] ||= display_name
431 target['license'] ||= uses_license if uses_license
432 target['obsolete'] ||= obsolete if obsolete
433 target['type-details'] ||= type_details
434 target['revision-details'] ||= revision_details
435 target['dependencies'] ||= dependencies if dependencies
436 target['archives'] ||= {}
437 merge target['archives'], archives
438 target['last-available-day'] = today
439 end
440
441 [licenses, addons, extras]
442end
443
444# Make the clean diff by always sorting the result before puting it in the stdout.
445def sort_recursively value
446 if value.is_a?(Hash)
447 Hash[
448 value.map do |k, v|
449 [k, sort_recursively(v)]
450 end.sort_by {|(k, v)| k }
451 ]
452 elsif value.is_a?(Array)
453 value.map do |v| sort_recursively(v) end
454 else
455 value
456 end
457end
458
459def merge_recursively a, b
460 a.merge!(b) {|key, a_item, b_item|
461 if a_item.is_a?(Hash) && b_item.is_a?(Hash)
462 merge_recursively(a_item, b_item)
463 elsif b_item != nil
464 b_item
465 end
466 }
467 a
468end
469
470def merge dest, src
471 merge_recursively dest, src
472end
473
474opts = Slop.parse do |o|
475 o.array '-p', '--packages', 'packages repo XMLs to parse', default: %w[repo://repository#2-3]
476 o.array '-i', '--images', 'system image repo XMLs to parse', default: %w[
477 image://android#2-3
478 image://android-tv#2-3
479 image://android-wear#2-3
480 image://android-wear-cn#2-3
481 image://android-automotive#2-3
482 image://google_apis#2-3
483 image://google_apis_playstore#2-3
484 ]
485 o.array '-a', '--addons', 'addon repo XMLs to parse', default: %w[repo://addon#2-3]
486 o.string '-I', '--input', 'input JSON file for repo', default: File.join(__dir__, 'repo.json')
487 o.string '-O', '--output', 'output JSON file for repo', default: File.join(__dir__, 'repo.json')
488end
489
490result = {}
491result['licenses'] = {}
492result['packages'] = {}
493result['images'] = {}
494result['addons'] = {}
495result['extras'] = {}
496
497opts[:packages].each do |filename|
498 licenses, packages, extras = parse_package_xml(Nokogiri::XML(get(filename)) { |conf| conf.noblanks })
499 merge result['licenses'], licenses
500 merge result['packages'], packages
501 merge result['extras'], extras
502end
503
504opts[:images].each do |filename|
505 licenses, images = parse_image_xml(Nokogiri::XML(get(filename)) { |conf| conf.noblanks })
506 merge result['licenses'], licenses
507 merge result['images'], images
508end
509
510opts[:addons].each do |filename|
511 licenses, addons, extras = parse_addon_xml(Nokogiri::XML(get(filename)) { |conf| conf.noblanks })
512 merge result['licenses'], licenses
513 merge result['addons'], addons
514 merge result['extras'], extras
515end
516
517result['latest'] = {}
518result['packages'].each do |name, versions|
519 max_version = Gem::Version.new('0')
520 versions.each do |version, package|
521 if package['license'] == 'android-sdk-license' && Gem::Version.correct?(package['revision'])
522 package_version = Gem::Version.new(package['revision'])
523 max_version = package_version if package_version > max_version
524 end
525 end
526 result['latest'][name] = max_version.to_s
527end
528
529# As we keep the old packages in the repo JSON file, we should have
530# a strategy to remove them at some point!
531# So with this variable we claim it's okay to remove them from the
532# JSON after two years that they are not available.
533two_years_ago = today - 2 * 365
534
535input = {}
536prev_latest = {}
537begin
538 input_json = if File.exist?(opts[:input])
539 STDERR.puts "Reading #{opts[:input]}"
540 File.read(opts[:input])
541 else
542 STDERR.puts "Creating new repo"
543 "{}"
544 end
545
546 if input_json != nil && !input_json.empty?
547 input = expire_records(JSON.parse(input_json), two_years_ago)
548
549 # Just create a new set of latest packages.
550 prev_latest = input['latest'] || {}
551 input['latest'] = {}
552 end
553rescue JSON::ParserError => e
554 STDERR.write(e.message)
555 return
556end
557
558fixup_result = fixup(result)
559
560# Regular installation of Android SDK would keep the previously installed packages even if they are not
561# in the uptodate XML files, so here we try to support this logic by keeping un-available packages,
562# therefore the old packages will work as long as the links are working on the Google servers.
563output = merge input, fixup_result
564
565# Write the repository. Append a \n to keep nixpkgs Github Actions happy.
566STDERR.puts "Writing #{opts[:output]}"
567File.write opts[:output], (JSON.pretty_generate(sort_recursively(output)) + "\n")
568
569# Output metadata for the nixpkgs update script.
570if ENV['UPDATE_NIX_ATTR_PATH']
571 # See if there are any changes in the latest versions.
572 cur_latest = output['latest'] || {}
573
574 old_versions = []
575 new_versions = []
576 changes = []
577 changed = false
578
579 cur_latest.each do |k, v|
580 prev = prev_latest[k]
581 if prev && prev != v
582 old_versions << "#{k}:#{prev}"
583 new_versions << "#{k}:#{v}"
584 changes << "#{k}: #{prev} -> #{v}"
585 changed = true
586 end
587 end
588
589 changed_paths = []
590 if changed
591 # Instantiate it.
592 test_result = `NIXPKGS_ALLOW_UNFREE=1 NIXPKGS_ACCEPT_ANDROID_SDK_LICENSE=1 nix-build #{Shellwords.escape(File.realpath(File.join(__dir__, '..', '..', '..', '..', 'default.nix')))} -A #{Shellwords.join [ENV['UPDATE_NIX_ATTR_PATH']]} 2>&1`
593 test_status = $?.exitstatus
594
595 template = ERB.new(<<-EOF, trim_mode: '<>-')
596androidenv: <%= changes.join('; ') %>
597
598Performed the following automatic androidenv updates:
599
600<% changes.each do |change| %>
601- <%= change -%>
602<% end %>
603
604Tests exited with status: <%= test_status -%>
605
606<% if !test_result.empty? %>
607Last 100 lines of output:
608```
609<%= test_result.lines.last(100).join -%>
610```
611<% end %>
612EOF
613
614 changed_paths << {
615 attrPath: 'androidenv.androidPkgs.androidsdk',
616 oldVersion: old_versions.join('; '),
617 newVersion: new_versions.join('; '),
618 files: [
619 opts[:output]
620 ],
621 commitMessage: template.result(binding)
622 }
623 end
624
625 # nix-update info is on stdout
626 STDOUT.puts JSON.pretty_generate(changed_paths)
627end