fastlane-plugin-wpmreleasetoolkit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +339 -0
  3. data/README.md +38 -0
  4. data/bin/drawText +19 -0
  5. data/ext/drawText/extconf.rb +36 -0
  6. data/lib/fastlane/plugin/wpmreleasetoolkit.rb +16 -0
  7. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/README.md +20 -0
  8. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/an_localize_libs_action.rb +53 -0
  9. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/an_update_metadata_source_action.rb +171 -0
  10. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/an_validate_lib_strings_action.rb +63 -0
  11. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_betabuild_prechecks.rb +103 -0
  12. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_build_prechecks.rb +83 -0
  13. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_build_preflight.rb +54 -0
  14. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_bump_version_beta.rb +69 -0
  15. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_bump_version_final_release.rb +58 -0
  16. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_bump_version_hotfix.rb +77 -0
  17. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_bump_version_release.rb +89 -0
  18. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_codefreeze_prechecks.rb +79 -0
  19. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_completecodefreeze_prechecks.rb +68 -0
  20. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_create_xml_release_notes.rb +63 -0
  21. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_current_branch_is_hotfix.rb +44 -0
  22. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_download_file_by_version.rb +79 -0
  23. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_download_translations_action.rb +115 -0
  24. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_finalize_prechecks.rb +71 -0
  25. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_get_alpha_version.rb +44 -0
  26. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_get_app_version.rb +44 -0
  27. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_get_release_version.rb +44 -0
  28. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_hotifx_prechecks.rb +78 -0
  29. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_merge_translators_strings.rb +106 -0
  30. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_tag_build.rb +51 -0
  31. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_update_metadata.rb +52 -0
  32. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_update_release_notes.rb +56 -0
  33. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/circleci_trigger_job_action.rb +63 -0
  34. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/close_milestone_action.rb +56 -0
  35. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/create_new_milestone_action.rb +59 -0
  36. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/create_release_action.rb +91 -0
  37. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/extract_release_notes_for_version_action.rb +89 -0
  38. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/get_prs_list_action.rb +64 -0
  39. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_downloadmetadata_action.rb +90 -0
  40. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb +170 -0
  41. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/promo_screenshots_action.rb +247 -0
  42. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/removebranchprotection_action.rb +57 -0
  43. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/setbranchprotection_action.rb +56 -0
  44. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/setfrozentag_action.rb +81 -0
  45. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/configure/configure_add_files_to_copy_action.rb +96 -0
  46. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/configure/configure_apply_action.rb +139 -0
  47. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/configure/configure_download_action.rb +57 -0
  48. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/configure/configure_setup_action.rb +86 -0
  49. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/configure/configure_update_action.rb +139 -0
  50. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/configure/configure_validate_action.rb +134 -0
  51. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/add_development_certificates_to_provisioning_profiles.rb +77 -0
  52. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/add_devices_to_provisioning_profiles.rb +79 -0
  53. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_betabuild_prechecks.rb +92 -0
  54. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_build_prechecks.rb +74 -0
  55. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_build_preflight.rb +78 -0
  56. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_bump_version_beta.rb +68 -0
  57. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_bump_version_hotfix.rb +87 -0
  58. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_bump_version_release.rb +114 -0
  59. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_check_beta_deps.rb +62 -0
  60. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_clear_intermediate_tags.rb +60 -0
  61. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_codefreeze_prechecks.rb +70 -0
  62. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_completecodefreeze_prechecks.rb +63 -0
  63. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_current_branch_is_hotfix.rb +40 -0
  64. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_final_tag.rb +52 -0
  65. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_finalize_prechecks.rb +64 -0
  66. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_get_app_version.rb +47 -0
  67. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_get_build_version.rb +60 -0
  68. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_get_store_app_sizes.rb +121 -0
  69. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_hotifx_prechecks.rb +78 -0
  70. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_lint_localizations.rb +167 -0
  71. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_localize_project.rb +44 -0
  72. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_merge_translators_strings.rb +93 -0
  73. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_tag_build.rb +44 -0
  74. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_update_metadata.rb +40 -0
  75. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_update_metadata_source.rb +81 -0
  76. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_update_release_notes.rb +56 -0
  77. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_validate_ci_build.rb +46 -0
  78. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/an_metadata_update_helper.rb +152 -0
  79. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/android/android_git_helper.rb +44 -0
  80. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/android/android_localize_helper.rb +359 -0
  81. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/android/android_version_helper.rb +475 -0
  82. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ci_helper.rb +91 -0
  83. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/configure_helper.rb +282 -0
  84. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/encryption_helper.rb +51 -0
  85. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/filesystem_helper.rb +93 -0
  86. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/git_helper.rb +224 -0
  87. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb +135 -0
  88. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_adc_app_sizes_helper.rb +74 -0
  89. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_git_helper.rb +80 -0
  90. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_l10n_helper.rb +208 -0
  91. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_version_helper.rb +348 -0
  92. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata_download_helper.rb +107 -0
  93. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata_update_helper.rb +182 -0
  94. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/promo_screenshots_helper.rb +399 -0
  95. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/release_notes_helper.rb +21 -0
  96. data/lib/fastlane/plugin/wpmreleasetoolkit/models/configuration.rb +40 -0
  97. data/lib/fastlane/plugin/wpmreleasetoolkit/models/file_reference.rb +86 -0
  98. data/lib/fastlane/plugin/wpmreleasetoolkit/version.rb +5 -0
  99. metadata +449 -0
@@ -0,0 +1,359 @@
1
+ require 'fastlane_core/ui/ui'
2
+ require 'fileutils'
3
+ require 'nokogiri'
4
+ require 'open-uri'
5
+
6
+ module Fastlane
7
+ UI = FastlaneCore::UI unless Fastlane.const_defined?('UI')
8
+
9
+ module Helper
10
+ module Android
11
+ module LocalizeHelper
12
+ # Checks if string_line has the content_override flag set
13
+ def self.skip_string_by_tag(string_line)
14
+ skip = string_line.attr('content_override') == 'true' unless string_line.attr('content_override').nil?
15
+ if skip
16
+ puts " - Skipping #{string_line.attr('name')} string"
17
+ return true
18
+ end
19
+
20
+ return false
21
+ end
22
+
23
+ # Checks if string_name is in the excluesion list
24
+ def self.skip_string_by_exclusion_list(library, string_name)
25
+ return false unless library.key?(:exclusions)
26
+
27
+ skip = library[:exclusions].include?(string_name)
28
+ if skip
29
+ puts " - Skipping #{string_name} string"
30
+ return true
31
+ end
32
+ end
33
+
34
+ # Merge string_line into main_string
35
+ def self.merge_string(main_strings, library, string_line)
36
+ string_name = string_line.attr('name')
37
+ string_content = string_line.content
38
+
39
+ # Skip strings in the exclusions list
40
+ return :skipped if skip_string_by_exclusion_list(library, string_name)
41
+
42
+ # Search for the string in the main file
43
+ result = :added
44
+ main_strings.xpath('//string').each do |this_string|
45
+ if this_string.attr('name') == string_name
46
+ # Skip if the string has the content_override tag
47
+ return :skipped if skip_string_by_tag(this_string)
48
+
49
+ # If nodes are equivalent, skip
50
+ return :found if string_line =~ this_string
51
+
52
+ # The string needs an update
53
+ result = :updated
54
+ if this_string.attr('tools:ignore').nil?
55
+ # It can be updated, so remove the current one and move ahead
56
+ this_string.remove
57
+ break
58
+ else
59
+ # It has the tools:ignore flag, so update the content without touching the other attributes
60
+ this_string.content = string_content
61
+ return result
62
+ end
63
+ end
64
+ end
65
+
66
+ # String not found, or removed because needing update and not in the exclusion list: add to the main file
67
+ main_strings.xpath('//string').last().add_next_sibling("\n#{' ' * 4}#{string_line.to_xml().strip}")
68
+ return result
69
+ end
70
+
71
+ # Verify a string
72
+ def self.verify_string(main_strings, library, string_line)
73
+ string_name = string_line.attr('name')
74
+ string_content = string_line.content
75
+
76
+ # Skip strings in the exclusions list
77
+ return if skip_string_by_exclusion_list(library, string_name)
78
+
79
+ # Search for the string in the main file
80
+ main_strings.xpath('//string').each do |this_string|
81
+ if this_string.attr('name') == string_name
82
+ # Skip if the string has the content_override tag
83
+ return if skip_string_by_tag(this_string)
84
+
85
+ # Update if needed
86
+ UI.user_error!("String #{string_name} [#{string_content}] has been updated in the main file but not in the library #{library[:library]}.") if this_string.content != string_content
87
+ return
88
+ end
89
+ end
90
+
91
+ # String not found and not in the exclusion list:
92
+ UI.user_error!("String #{string_name} [#{string_content}] was found in library #{library[:library]} but not in the main file.")
93
+ end
94
+
95
+ def self.merge_lib(main, library)
96
+ UI.message("Merging #{library[:library]} strings into #{main}")
97
+ main_strings = File.open(main) { |f| Nokogiri::XML(f, nil, Encoding::UTF_8.to_s) }
98
+ lib_strings = File.open(library[:strings_path]) { |f| Nokogiri::XML(f, nil, Encoding::UTF_8.to_s) }
99
+
100
+ updated_count = 0
101
+ untouched_count = 0
102
+ added_count = 0
103
+ skipped_count = 0
104
+ lib_strings.xpath('//string').each do |string_line|
105
+ res = merge_string(main_strings, library, string_line)
106
+ case res
107
+ when :updated
108
+ puts "#{string_line.attr('name')} updated."
109
+ updated_count = updated_count + 1
110
+ when :found
111
+ untouched_count = untouched_count + 1
112
+ when :added
113
+ puts "#{string_line.attr('name')} added."
114
+ added_count = added_count + 1
115
+ when :skipped
116
+ skipped_count = skipped_count + 1
117
+ else
118
+ UI.user_error!("Internal Error! #{res}")
119
+ end
120
+ end
121
+
122
+ File.open(main, 'w:UTF-8') do |f|
123
+ f.write(main_strings.to_xml(indent: 4))
124
+ end
125
+
126
+ UI.message("Done (#{added_count} added, #{updated_count} updated, #{untouched_count} untouched, #{skipped_count} skipped).")
127
+ return (added_count + updated_count) != 0
128
+ end
129
+
130
+ def self.verify_diff(diff_string, main_strings, lib_strings, library)
131
+ if diff_string.start_with?('name=')
132
+ diff_string.slice!('name="')
133
+
134
+ end_index = diff_string.index('"')
135
+ end_index ||= diff_string.length # Use the whole string if there's no '"'
136
+
137
+ diff_string = diff_string.slice(0..(end_index - 1))
138
+
139
+ lib_strings.xpath('//string').each do |string_line|
140
+ res = verify_string(main_strings, library, string_line) if string_line.attr('name') == diff_string
141
+ end
142
+ end
143
+ end
144
+
145
+ def self.verify_lib(main, library, source_diff)
146
+ UI.message("Checking #{library[:library]} strings vs #{main}")
147
+ main_strings = File.open(main) { |f| Nokogiri::XML(f, nil, Encoding::UTF_8.to_s) }
148
+ lib_strings = File.open(library[:strings_path]) { |f| Nokogiri::XML(f, nil, Encoding::UTF_8.to_s) }
149
+
150
+ verify_local_diff(main, library, main_strings, lib_strings)
151
+ verify_pr_diff(main, library, main_strings, lib_strings, source_diff) unless source_diff.nil?
152
+ end
153
+
154
+ def self.verify_local_diff(main, library, main_strings, lib_strings)
155
+ `git diff #{main}`.each_line do |line|
156
+ if line.start_with?('+ ') || line.start_with?('- ')
157
+ diffs = line.gsub(/\s+/m, ' ').strip.split(' ')
158
+ diffs.each do |diff|
159
+ verify_diff(diff, main_strings, lib_strings, library)
160
+ end
161
+ end
162
+ end
163
+ end
164
+
165
+ def self.verify_pr_diff(main, library, main_strings, lib_strings, source_diff)
166
+ source_diff.each_line do |line|
167
+ if line.start_with?('+ ') || line.start_with?('- ')
168
+ diffs = line.gsub(/\s+/m, ' ').strip.split(' ')
169
+ diffs.each do |diff|
170
+ verify_diff(diff, main_strings, lib_strings, library)
171
+ end
172
+ end
173
+ end
174
+ end
175
+
176
+ ########
177
+ # @!group Downloading translations from GlotPress
178
+ ########
179
+
180
+ # Create the `available_languages.xml` file.
181
+ #
182
+ # @param [String] res_dir The relative path to the `…/src/main/res` directory
183
+ # @param [Array<String>] locale_codes The list of locale codes to include in the available_languages.xml file
184
+ #
185
+ def self.create_available_languages_file(res_dir:, locale_codes:)
186
+ doc = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
187
+ xml.comment('Warning: Auto-generated file, do not edit.')
188
+ xml.resources do
189
+ xml.send(:'string-array', name: 'available_languages', translatable: 'false') do
190
+ locale_codes.each { |code| xml.item(code.gsub('-r', '_')) }
191
+ end
192
+ end
193
+ end
194
+ File.write(File.join(res_dir, 'values', 'available_languages.xml'), doc.to_xml)
195
+ end
196
+
197
+ # Download translations from GlotPress
198
+ #
199
+ # @param [String] res_dir The relative path to the `…/src/main/res` directory.
200
+ # @param [String] glotpress_project_url The base URL to the glotpress project to download the strings from.
201
+ # @param [Hash{String=>String}, Array] glotpress_filters
202
+ # The filters to apply when exporting strings from GlotPress.
203
+ # Typical examples include `{ status: 'current' }` or `{ status: 'review' }`.
204
+ # If an array of Hashes is provided instead of a single Hash, this method will perform as many
205
+ # export requests as items in this array, then merge all the results – useful for OR-ing multiple filters.
206
+ # @param [Array<Hash{Symbol=>String}>] locales_map
207
+ # An array of locales to download. Each item in the array must be a Hash
208
+ # with keys `:glotpress` and `:android` containing the respective locale codes.
209
+ #
210
+ def self.download_from_glotpress(res_dir:, glotpress_project_url:, glotpress_filters: { status: 'current' }, locales_map:)
211
+ glotpress_filters = [glotpress_filters] unless glotpress_filters.is_a?(Array)
212
+
213
+ attributes_to_copy = %w[formatted] # Attributes that we want to replicate into translated `string.xml` files
214
+ orig_file = File.join(res_dir, 'values', 'strings.xml')
215
+ orig_xml = File.open(orig_file) { |f| Nokogiri::XML(f, nil, Encoding::UTF_8.to_s) }
216
+ orig_attributes = orig_xml.xpath('//string').map { |tag| [tag['name'], tag.attributes.select { |k, _| attributes_to_copy.include?(k) }] }.to_h
217
+
218
+ locales_map.each do |lang_codes|
219
+ all_xml_documents = glotpress_filters.map do |filters|
220
+ UI.message "Downloading translations for '#{lang_codes[:android]}' from GlotPress (#{lang_codes[:glotpress]}) [#{filters}]..."
221
+ download_glotpress_export_file(project_url: glotpress_project_url, locale: lang_codes[:glotpress], filters: filters)
222
+ end.compact
223
+ next if all_xml_documents.empty?
224
+
225
+ # Merge all XMLs together
226
+ merged_xml = merge_xml_documents(all_xml_documents)
227
+
228
+ # Process XML (text substitutions, replicate attributes, quick-lint string)
229
+ merged_xml.xpath('//string').each do |string_tag|
230
+ apply_substitutions(string_tag)
231
+ orig_attributes[string_tag['name']]&.each { |k, v| string_tag[k] = v }
232
+ quick_lint(string_tag, lang_codes[:android])
233
+ end
234
+ merged_xml.xpath('//string-array/item').each { |item_tag| apply_substitutions(item_tag) }
235
+
236
+ # Save
237
+ lang_dir = File.join(res_dir, "values-#{lang_codes[:android]}")
238
+ FileUtils.mkdir(lang_dir) unless Dir.exist?(lang_dir)
239
+ lang_file = File.join(lang_dir, 'strings.xml')
240
+ File.open(lang_file, 'w') { |f| merged_xml.write_to(f, encoding: Encoding::UTF_8.to_s, indent: 4) }
241
+ end
242
+ end
243
+
244
+ #####################
245
+ # Private Helpers
246
+ #####################
247
+
248
+ # Downloads the export from GlotPress for a given locale and given filters
249
+ #
250
+ # @param [String] project_url The URL to the GlotPress project to export from.
251
+ # @param [String] locale The GlotPress locale code to download strings for.
252
+ # @param [Hash{Symbol=>String}] filters The hash of filters to apply when exporting from GlotPress.
253
+ # Typical examples include `{ status: 'current' }` or `{ status: 'review' }`.
254
+ # @return [Nokogiri::XML] the download XML document, parsed as a Nokogiri::XML object
255
+ #
256
+ def self.download_glotpress_export_file(project_url:, locale:, filters:)
257
+ query_params = filters.transform_keys { |k| "filters[#{k}]" }.merge(format: 'android')
258
+ uri = URI.parse("#{project_url.chomp('/')}/#{locale}/default/export-translations?#{URI.encode_www_form(query_params)}")
259
+ begin
260
+ uri.open { |f| Nokogiri::XML(f.read.gsub("\t", ' '), nil, Encoding::UTF_8.to_s) }
261
+ rescue StandardError => e
262
+ UI.error "Error downloading #{locale} - #{e.message}"
263
+ return nil
264
+ end
265
+ end
266
+ private_class_method :download_glotpress_export_file
267
+
268
+ # Merge multiple Nokogiri::XML `strings.xml` documents together
269
+ #
270
+ # @param [Array<Nokogiri::XML::Document>] all_xmls Array of the Nokogiri XML documents to merge together
271
+ # @return [Nokogiri::XML::Document] The merged document.
272
+ #
273
+ # @note The first document in the array is used as starting point. Then string/resources from other docs are merged into it.
274
+ # If a string/resource with a given `name` is present in multiple documents, the node of the last one wins.
275
+ #
276
+ def self.merge_xml_documents(all_xmls)
277
+ return all_xmls.first if all_xmls.count <= 1
278
+
279
+ merged_xml = all_xmls.first.dup # Use the first XML as starting point
280
+ resources_node = merged_xml.xpath('/resources').first
281
+ # For each other XML, find all the nodes with a name attribute, and merge them in
282
+ all_xmls.drop(1).each do |other_xml|
283
+ other_xml.xpath('/resources/*[@name]').each do |other_node|
284
+ existing_node = merged_xml.xpath("//#{other_node.name}[@name='#{other_node['name']}']").first
285
+ if existing_node.nil?
286
+ resources_node << ' ' << other_node << "\n"
287
+ else
288
+ existing_node.replace(other_node)
289
+ end
290
+ end
291
+ end
292
+ merged_xml
293
+ end
294
+ private_class_method :merge_xml_documents
295
+
296
+ # Apply some common text substitutions to tag contents
297
+ #
298
+ # @param [Nokogiri::XML::Node] tag The XML tag/node to apply substitutions to
299
+ #
300
+ def self.apply_substitutions(tag)
301
+ tag.content = tag.content.gsub('...', '…')
302
+ end
303
+ private_class_method :apply_substitutions
304
+
305
+ # Perform some quick basic checks about an individual `<string>` tag and print warnings accordingly
306
+ #
307
+ # @param [Nokogiri::XML::Node] string_tag The XML tag/node to check
308
+ # @param [String] lang The language we are currently processing. Used for providing context during logging / warning message
309
+ #
310
+ def self.quick_lint(string_tag, lang)
311
+ if string_tag['formatted'] == 'false' && string_tag.content.include?('%%')
312
+ UI.important "Warning: [#{lang}] translation for '#{string_tag['name']}' has attribute formatted=false, but still contains escaped '%%' in translation."
313
+ end
314
+ end
315
+ private_class_method :quick_lint
316
+
317
+ # @!endgroup
318
+ end
319
+ end
320
+ end
321
+ end
322
+
323
+ # Source: https://stackoverflow.com/questions/7825258/determine-if-two-nokogiri-nodes-are-equivalent?rq=1
324
+ # There may be better solutions now that Ruby supports canonicalization.
325
+ module Nokogiri
326
+ module XML
327
+ class Node
328
+ # Return true if this node is content-equivalent to other, false otherwise
329
+ def =~(other)
330
+ return true if self == other
331
+ return false unless name == other.name
332
+
333
+ stype = node_type
334
+ otype = other.node_type
335
+ return false unless stype == otype
336
+
337
+ sa = attributes
338
+ oa = other.attributes
339
+ return false unless sa.length == oa.length
340
+
341
+ sa = sa.sort.map { |n, a| [n, a.value, a.namespace && a.namespace.href] }
342
+ oa = oa.sort.map { |n, a| [n, a.value, a.namespace && a.namespace.href] }
343
+ return false unless sa == oa
344
+
345
+ skids = children
346
+ okids = other.children
347
+ return false unless skids.length == okids.length
348
+ return false if stype == TEXT_NODE && (content != other.content)
349
+
350
+ sns = namespace
351
+ ons = other.namespace
352
+ return false if !sns ^ !ons
353
+ return false if sns && (sns.href != ons.href)
354
+
355
+ skids.to_enum.with_index.all? { |ski, i| ski =~ okids[i] }
356
+ end
357
+ end
358
+ end
359
+ end
@@ -0,0 +1,475 @@
1
+ module Fastlane
2
+ module Helper
3
+ module Android
4
+ # A module containing helper methods to manipulate/extract/bump Android version strings in gradle files
5
+ #
6
+ module VersionHelper
7
+ # The key used in internal version Hash objects to hold the versionName value
8
+ VERSION_NAME = 'name'
9
+ # The key used in internal version Hash objects to hold the versionCode value
10
+ VERSION_CODE = 'code'
11
+ # The index for the major version number part
12
+ MAJOR_NUMBER = 0
13
+ # The index for the minor version number part
14
+ MINOR_NUMBER = 1
15
+ # The index for the hotfix version number part
16
+ HOTFIX_NUMBER = 2
17
+ # The prefix used in front of the versionName for alpha versions
18
+ ALPHA_PREFIX = 'alpha-'
19
+ # The suffix used in the versionName for RC (beta) versions
20
+ RC_SUFFIX = '-rc'
21
+
22
+ # Returns the public-facing version string.
23
+ #
24
+ # @example
25
+ # "1.2" # Assuming build.gradle contains versionName "1.2"
26
+ # "1.2" # Assuming build.gradle contains versionName "1.2.0"
27
+ # "1.2.3" # Assuming build.gradle contains versionName "1.2.3"
28
+ #
29
+ # @return [String] The public-facing version number, extracted from the `versionName` of the `build.gradle` file.
30
+ # - If this version is a hotfix (more than 2 parts and 3rd part is non-zero), returns the "X.Y.Z" formatted string
31
+ # - Otherwise (not a hotfix / 3rd part of version is 0), returns "X.Y" formatted version number
32
+ #
33
+ def self.get_public_version
34
+ version = get_release_version
35
+ vp = get_version_parts(version[VERSION_NAME])
36
+ return "#{vp[MAJOR_NUMBER]}.#{vp[MINOR_NUMBER]}" unless is_hotfix?(version)
37
+
38
+ "#{vp[MAJOR_NUMBER]}.#{vp[MINOR_NUMBER]}.#{vp[HOTFIX_NUMBER]}"
39
+ end
40
+
41
+ # Extract the version name and code from the `vanilla` flavor of the `$PROJECT_NAME/build.gradle file`
42
+ # or for the defaultConfig if `HAS_ALPHA_VERSION` is not defined.
43
+ #
44
+ # @env HAS_ALPHA_VERSION If set (with any value), indicates that the project uses `vanilla` flavor.
45
+ #
46
+ # @return [Hash] A hash with 2 keys "name" and "code" containing the extracted version name and code, respectively
47
+ #
48
+ def self.get_release_version
49
+ section = ENV['HAS_ALPHA_VERSION'].nil? ? 'defaultConfig' : 'vanilla {'
50
+ gradle_path = self.gradle_path
51
+ name = get_version_name_from_gradle_file(gradle_path, section)
52
+ code = get_version_build_from_gradle_file(gradle_path, section)
53
+ return { VERSION_NAME => name, VERSION_CODE => code }
54
+ end
55
+
56
+ # Extract the version name and code from the `defaultConfig` of the `$PROJECT_NAME/build.gradle` file
57
+ #
58
+ # @return [Hash] A hash with 2 keys `"name"` and `"code"` containing the extracted version name and code, respectively,
59
+ # or `nil` if `$HAS_ALPHA_VERSION` is not defined.
60
+ #
61
+ def self.get_alpha_version
62
+ return nil if ENV['HAS_ALPHA_VERSION'].nil?
63
+
64
+ section = 'defaultConfig'
65
+ gradle_path = self.gradle_path
66
+ name = get_version_name_from_gradle_file(gradle_path, section)
67
+ code = get_version_build_from_gradle_file(gradle_path, section)
68
+ return { VERSION_NAME => name, VERSION_CODE => code }
69
+ end
70
+
71
+ # Determines if a version name corresponds to an alpha version (starts with `"alpha-"`` prefix)
72
+ #
73
+ # @param [String] version The version name to check
74
+ #
75
+ # @return [Bool] true if the version name starts with the `ALPHA_PREFIX`, false otherwise.
76
+ #
77
+ # @private
78
+ #
79
+ def self.is_alpha_version?(version)
80
+ version[VERSION_NAME].start_with?(ALPHA_PREFIX)
81
+ end
82
+
83
+ # Check if this versionName corresponds to a beta, i.e. contains some `-rc` suffix
84
+ #
85
+ # @param [String] version The versionName string to check for
86
+ #
87
+ # @return [Bool] True if the version string contains `-rc`, indicating it is a beta version.
88
+ #
89
+ def self.is_beta_version?(version)
90
+ version[VERSION_NAME].include?(RC_SUFFIX)
91
+ end
92
+
93
+ # Returns the version name and code to use for the final release.
94
+ #
95
+ # - The final version name corresponds to the beta's versionName, without the `-rc` suffix
96
+ # - The final version code corresponds to the versionCode for the alpha (or for the beta if alpha_version is nil) incremented by one.
97
+ #
98
+ # @param [Hash] beta_version The version hash for the beta (vanilla flavor), containing values for keys "name" and "code"
99
+ # @param [Hash] alpha_version The version hash for the alpha (defaultConfig), containing values for keys "name" and "code",
100
+ # or `nil` if no alpha version to consider.
101
+ #
102
+ # @return [Hash] A version hash with keys "name" and "code", containing the version name and code to use for final release.
103
+ #
104
+ def self.calc_final_release_version(beta_version, alpha_version)
105
+ version_name = beta_version[VERSION_NAME].split('-')[0]
106
+ version_code = alpha_version.nil? ? beta_version[VERSION_CODE] + 1 : alpha_version[VERSION_CODE] + 1
107
+
108
+ { VERSION_NAME => version_name, VERSION_CODE => version_code }
109
+ end
110
+
111
+ # Returns the version name and code to use for the next alpha.
112
+ #
113
+ # - The next version name corresponds to the `alpha_version`'s name incremented by one (alpha-42 => alpha-43)
114
+ # - The next version code corresponds to the `version`'s code incremented by one.
115
+ #
116
+ # @param [Hash] version The version hash for the current beta or release, containing values for keys "name" and "code"
117
+ # @param [Hash] alpha_version The version hash for the current alpha (defaultConfig), containing values for keys "name" and "code"
118
+ #
119
+ # @return [Hash] A version hash with keys "name" and "code", containing the version name and code to use for final release.
120
+ #
121
+ def self.calc_next_alpha_version(version, alpha_version)
122
+ # Bump alpha name
123
+ alpha_number = alpha_version[VERSION_NAME].sub(ALPHA_PREFIX, '')
124
+ alpha_name = "#{ALPHA_PREFIX}#{alpha_number.to_i() + 1}"
125
+
126
+ # Bump alpha code
127
+ alpha_code = version[VERSION_CODE] + 1
128
+
129
+ { VERSION_NAME => alpha_name, VERSION_CODE => alpha_code }
130
+ end
131
+
132
+ # Compute the version name and code to use for the next beta (`X.Y.Z-rc-N`).
133
+ #
134
+ # - The next version name corresponds to the `version`'s name with the value after the `-rc-` suffix incremented by one,
135
+ # or with `-rc-1` added if there was no previous rc suffix (if `version` was not a beta but a release)
136
+ # - The next version code corresponds to the `alpha_version`'s (or `version`'s if `alpha_version` is nil) code, incremented by one.
137
+ #
138
+ # @example
139
+ # calc_next_beta_version({"name": "1.2.3", "code": 456}) #=> {"name": "1.2.3-rc-1", "code": 457}
140
+ # calc_next_beta_version({"name": "1.2.3-rc-2", "code": 456}) #=> {"name": "1.2.3-rc-3", "code": 457}
141
+ # calc_next_beta_version({"name": "1.2.3", "code": 456}, {"name": "alpha-1.2.3", "code": 457}) #=> {"name": "1.2.3-rc-1", "code": 458}
142
+ #
143
+ # @param [Hash] version The version hash for the current beta or release, containing values for keys "name" and "code"
144
+ # @param [Hash] alpha_version The version hash for the alpha, containing values for keys "name" and "code",
145
+ # or `nil` if no alpha version to consider.
146
+ #
147
+ # @return [Hash] A hash with keys `"name"` and `"code"` containing the next beta version name and code.
148
+ #
149
+ def self.calc_next_beta_version(version, alpha_version = nil)
150
+ # Bump version name
151
+ beta_number = is_beta_version?(version) ? version[VERSION_NAME].split('-')[2].to_i + 1 : 1
152
+ version_name = "#{version[VERSION_NAME].split('-')[0]}#{RC_SUFFIX}-#{beta_number}"
153
+
154
+ # Bump version code
155
+ version_code = alpha_version.nil? ? version[VERSION_CODE] + 1 : alpha_version[VERSION_CODE] + 1
156
+ { VERSION_NAME => version_name, VERSION_CODE => version_code }
157
+ end
158
+
159
+ # Compute the version name to use for the next release (`"X.Y"`).
160
+ #
161
+ # @param [String] version The version name (string) to increment
162
+ #
163
+ # @return [String] The version name for the next release
164
+ #
165
+ def self.calc_next_release_short_version(version)
166
+ v = self.calc_next_release_base_version(VERSION_NAME => version, VERSION_CODE => nil)
167
+ return v[VERSION_NAME]
168
+ end
169
+
170
+ # Compute the next release version name for the given version, without incrementing the version code
171
+ #
172
+ # - The version name sees its minor version part incremented by one (and carried to next major if it reaches 10)
173
+ # - The version code is unchanged. This method is intended to be called internally by other methods taking care of the version code bump.
174
+ #
175
+ # @param [Hash] version A version hash, with keys `"name"` and `"code"`, containing the version to increment
176
+ #
177
+ # @return [Hash] Hash containing the next release version name ("X.Y") and code.
178
+ #
179
+ def self.calc_next_release_base_version(version)
180
+ version_name = remove_beta_suffix(version[VERSION_NAME])
181
+ vp = get_version_parts(version_name)
182
+ vp[MINOR_NUMBER] += 1
183
+ if vp[MINOR_NUMBER] == 10
184
+ vp[MAJOR_NUMBER] += 1
185
+ vp[MINOR_NUMBER] = 0
186
+ end
187
+
188
+ { VERSION_NAME => "#{vp[MAJOR_NUMBER]}.#{vp[MINOR_NUMBER]}", VERSION_CODE => version[VERSION_CODE] }
189
+ end
190
+
191
+ # Compute the name of the next version to use after code freeze, by incrementing the current version name and making it a `-rc-1`
192
+ #
193
+ # @example
194
+ # calc_next_release_version({"name": "1.2", "code": 456}) #=> {"name":"1.3-rc-1", "code": 457}
195
+ # calc_next_release_version({"name": "1.2.3", "code": 456}) #=> {"name":"1.3-rc-1", "code": 457}
196
+ # calc_next_release_version({"name": "1.2", "code": 456}, {"name":"alpha-1.2", "code": 457}) #=> {"name":"1.3-rc-1", "code": 458}
197
+ #
198
+ # @param [Hash] version The current version hash, with keys `"name"` and `"code"`
199
+ # @param [Hash] alpha_version The current alpha version hash, with keys `"name"` and `"code"`, or nil if no alpha version
200
+ #
201
+ # @return [Hash] The hash containing the version name and code to use after release cut
202
+ #
203
+ def self.calc_next_release_version(version, alpha_version = nil)
204
+ nv = calc_next_release_base_version(VERSION_NAME => version[VERSION_NAME], VERSION_CODE => alpha_version.nil? ? version[VERSION_CODE] : [version[VERSION_CODE], alpha_version[VERSION_CODE]].max)
205
+ calc_next_beta_version(nv)
206
+ end
207
+
208
+ # Compute the name and code of the next hotfix version.
209
+ #
210
+ # @param [String] hotfix_version_name The next version name we want for the hotfix
211
+ # @param [String] hotfix_version_code The next version code we want for the hotfix
212
+ #
213
+ # @return [Hash] The predicted next hotfix version, as a Hash containing the keys `"name"` and `"code"`
214
+ #
215
+ def self.calc_next_hotfix_version(hotfix_version_name, hotfix_version_code)
216
+ { VERSION_NAME => hotfix_version_name, VERSION_CODE => hotfix_version_code }
217
+ end
218
+
219
+ # Compute the name of the previous release version, by decrementing the minor version number
220
+ #
221
+ # @example
222
+ # calc_prev_release_version("1.2") => "1.1"
223
+ # calc_prev_release_version("1.2.3") => "1.1"
224
+ # calc_prev_release_version("3.0") => "2.9"
225
+ #
226
+ # @param [String] version The version string to decrement
227
+ #
228
+ # @return [String] A 2-parts version string "X.Y" corresponding to the guessed previous release version.
229
+ #
230
+ def self.calc_prev_release_version(version)
231
+ vp = get_version_parts(version)
232
+ if vp[MINOR_NUMBER] == 0
233
+ vp[MAJOR_NUMBER] -= 1
234
+ vp[MINOR_NUMBER] = 9
235
+ else
236
+ vp[MINOR_NUMBER] -= 1
237
+ end
238
+
239
+ "#{vp[MAJOR_NUMBER]}.#{vp[MINOR_NUMBER]}"
240
+ end
241
+
242
+ # Determines if a version name corresponds to a hotfix
243
+ #
244
+ # @param [String] version The version number to test
245
+ #
246
+ # @return [Bool] True if the version number has a non-zero 3rd component, meaning that it is a hotfix version.
247
+ #
248
+ def self.is_hotfix?(version)
249
+ return false if is_alpha_version?(version)
250
+
251
+ vp = get_version_parts(version[VERSION_NAME])
252
+ return (vp.length > 2) && (vp[HOTFIX_NUMBER] != 0)
253
+ end
254
+
255
+ # Prints the current and next release version names to stdout, then returns the next release version
256
+ #
257
+ # @return [String] The next release version name to use after bumping the currently used release version.
258
+ #
259
+ def self.bump_version_release
260
+ # Bump release
261
+ current_version = get_release_version()
262
+ UI.message("Current version: #{current_version[VERSION_NAME]}")
263
+ new_version = calc_next_release_base_version(current_version)
264
+ UI.message("New version: #{new_version[VERSION_NAME]}")
265
+ verified_version = verify_version(new_version[VERSION_NAME])
266
+
267
+ return verified_version
268
+ end
269
+
270
+ # Update the `build.gradle` file with new `versionName` and `versionCode` values, both or the `defaultConfig` and `vanilla` flavors
271
+ #
272
+ # @param [Hash] new_version_beta The version hash for the beta (vanilla flavor), containing values for keys "name" and "code"
273
+ # @param [Hash] new_version_alpha The version hash for the alpha (defaultConfig), containing values for keys "name" and "code"
274
+ # @env HAS_ALPHA_VERSION If set (with any value), indicates that the project uses `vanilla` flavor.
275
+ #
276
+ def self.update_versions(new_version_beta, new_version_alpha)
277
+ self.update_version(new_version_beta, ENV['HAS_ALPHA_VERSION'].nil? ? 'defaultConfig' : 'vanilla {')
278
+ self.update_version(new_version_alpha, 'defaultConfig') unless new_version_alpha.nil?
279
+ end
280
+
281
+ # Compute the name of the previous hotfix version.
282
+ #
283
+ # @param [String] version_name The current version name we want to decrement
284
+ #
285
+ # @return [String] The predicted previous hotfix version, in the form of "X.Y.Z", or "X.Y" if Z is 0.
286
+ # Corresponds to decrementing the 3rd component Z of the version, stripping it if it ends up being zero.
287
+ #
288
+ def self.calc_prev_hotfix_version_name(version_name)
289
+ vp = get_version_parts(version_name)
290
+ vp[HOTFIX_NUMBER] -= 1 unless vp[HOTFIX_NUMBER] == 0
291
+ return "#{vp[MAJOR_NUMBER]}.#{vp[MINOR_NUMBER]}.#{vp[HOTFIX_NUMBER]}" unless vp[HOTFIX_NUMBER] == 0
292
+
293
+ "#{vp[MAJOR_NUMBER]}.#{vp[MINOR_NUMBER]}"
294
+ end
295
+
296
+ # Extract the value of a import key from build.gradle
297
+ #
298
+ # @param [String] import_key The key to look for
299
+ # @return [String] The value of the key, or nil if not found
300
+ #
301
+ def self.get_library_version_from_gradle_config(import_key:)
302
+ gradle_file_path = File.join(ENV['PROJECT_ROOT_FOLDER'] || '.', 'build.gradle')
303
+
304
+ return nil unless File.exist?(gradle_file_path)
305
+
306
+ File.open(gradle_file_path, 'r') do |f|
307
+ text = f.read
308
+ text.match(/^\s*(?:\w*\.)?#{Regexp.escape(import_key)}\s*=\s*['"](.*?)["']/m)&.captures&.first
309
+ end
310
+ end
311
+
312
+ #----------------------------------------
313
+ private
314
+
315
+ # Remove the beta suffix (part after the `-`) from a version string
316
+ #
317
+ # @param [String] version The version string to remove the suffix from
318
+ #
319
+ # @return [String] The part of the version string without the beta suffix, i.e. the part before the first dash.
320
+ #
321
+ # @example remove_beta_suffix("1.2.3-rc.4") => "1.2.3"
322
+ #
323
+ def self.remove_beta_suffix(version)
324
+ version.split('-')[0]
325
+ end
326
+
327
+ # Split a version string into its individual integer parts
328
+ #
329
+ # @param [String] version The version string to split, e.g. "1.2.3.4"
330
+ #
331
+ # @return [Array<Int>] An array of integers containing the individual integer parts of the version.
332
+ # Always contains 3 items at minimum (0 are added to the end if the original string contains less than 3 parts)
333
+ #
334
+ def self.get_version_parts(version)
335
+ parts = version.split('.').map(&:to_i)
336
+ parts.fill(0, parts.length...3) # add 0 if needed to ensure array has at least 3 components
337
+ return parts
338
+ end
339
+
340
+ # Extract the versionName from a build.gradle file
341
+ #
342
+ # @param [String] file_path The path to the `.gradle` file
343
+ # @param [String] section The name of the section we expect the keyword to be in, e.g. "defaultConfig" or "vanilla"
344
+ #
345
+ # @return [String] The value of the versionName attribute as found in the build.gradle file and for this section.
346
+ #
347
+ def self.get_version_name_from_gradle_file(file_path, section)
348
+ res = get_keyword_from_gradle_file(file_path, section, 'versionName')
349
+ res = res.tr('\"', '') unless res.nil?
350
+ return res
351
+ end
352
+
353
+ # Extract the versionCode rom a build.gradle file
354
+ #
355
+ # @param [String] file_path The path to the `.gradle` file
356
+ # @param [String] section The name of the section we expect the keyword to be in, e.g. "defaultConfig" or "vanilla"
357
+ #
358
+ # @return [String] The value of the versionCode attribute as found in the build.gradle file and for this section.
359
+ #
360
+ def self.get_version_build_from_gradle_file(file_path, section)
361
+ res = get_keyword_from_gradle_file(file_path, section, 'versionCode')
362
+ return res.to_i
363
+ end
364
+
365
+ # Extract the value for a specific keyword in a specific section of a `.gradle` file
366
+ #
367
+ # @todo: This implementation is very fragile. This should be done parsing the file in a proper way.
368
+ # Leveraging gradle itself is probably the easiest way.
369
+ #
370
+ # @param [String] file_path The path of the `.gradle` file to extract the value from
371
+ # @param [String] section The name of the section from which we want to extract this keyword from. For example `defaultConfig` or `myFlavor`
372
+ # @param [String] keyword The keyword (key name) we want the value for
373
+ #
374
+ # @return [String] Returns the value for that keyword in the section of the `.gradle` file, or nil if not found.
375
+ #
376
+ def self.get_keyword_from_gradle_file(file_path, section, keyword)
377
+ found_section = false
378
+ File.open(file_path, 'r') do |file|
379
+ file.each_line do |line|
380
+ if !found_section
381
+ found_section = true if line.include?(section)
382
+ else
383
+ return line.split(' ')[1] if line.include?(keyword) && !line.include?("\"#{keyword}\"") && !line.include?("P#{keyword}")
384
+ end
385
+ end
386
+ end
387
+ return nil
388
+ end
389
+
390
+ # Ensure that a version string is correctly formatted (that is, each of its parts is a number) and returns the 2-parts version number
391
+ #
392
+ # @param [String] version The version string to verify
393
+ #
394
+ # @return [String] The "major.minor" version string, only with the first 2 components
395
+ # @raise [UserError] If any of the parts of the version string is not a number
396
+ #
397
+ def self.verify_version(version)
398
+ v_parts = get_version_parts(version)
399
+
400
+ v_parts.each do |part|
401
+ UI.user_error!('Version value can only contains numbers.') unless is_int?(part)
402
+ end
403
+
404
+ "#{v_parts[MAJOR_NUMBER]}.#{v_parts[MINOR_NUMBER]}"
405
+ end
406
+
407
+ # Check if a string is an integer.
408
+ #
409
+ # @param [String] string The string to test
410
+ #
411
+ # @return [Bool] true if the string is representing an integer value, false if not
412
+ #
413
+ def self.is_int? string
414
+ true if Integer(string) rescue false
415
+ end
416
+
417
+ # The path to the build.gradle file for the project.
418
+ #
419
+ # @env PROJECT_ROOT_FOLDER The path to the root of the project (the folder containing the `.git` directory).
420
+ # @env PROJECT_NAME The name of the project, i.e. the name of the subdirectory containing the project's `build.gradle` file.
421
+ #
422
+ # @return [String] The path of the `build.gradle` file inside the project subfolder in the project's repo
423
+ #
424
+ def self.gradle_path
425
+ UI.user_error!("You need to set the \`PROJECT_ROOT_FOLDER\` environment variable to the path to the project's root") if ENV['PROJECT_ROOT_FOLDER'].nil?
426
+ UI.user_error!("You need to set the \`PROJECT_NAME\` environment variable to the relative path to the project subfolder name") if ENV['PROJECT_NAME'].nil?
427
+ File.join(ENV['PROJECT_ROOT_FOLDER'], ENV['PROJECT_NAME'], 'build.gradle')
428
+ end
429
+
430
+ # Update both the versionName and versionCode of the build.gradle file to the specified version.
431
+ #
432
+ # @param [Hash] version The version hash, containing values for keys "name" and "code"
433
+ # @param [String] section The name of the section to update in the build.gradle file, e.g. "defaultConfig" or "vanilla"
434
+ #
435
+ # @todo This implementation is very fragile. This should be done parsing the file in a proper way.
436
+ # Leveraging gradle itself is probably the easiest way.
437
+ #
438
+ def self.update_version(version, section)
439
+ gradle_path = self.gradle_path
440
+ temp_file = Tempfile.new('fastlaneIncrementVersion')
441
+ found_section = false
442
+ version_updated = 0
443
+ File.open(gradle_path, 'r') do |file|
444
+ file.each_line do |line|
445
+ if !found_section
446
+ temp_file.puts line
447
+ found_section = true if line.include? section
448
+ else
449
+ if version_updated < 2
450
+ if line.include?('versionName') && !line.include?('"versionName"') && !line.include?('PversionName')
451
+ version_name = line.split(' ')[1].tr('\"', '')
452
+ line.sub!(version_name, version[VERSION_NAME].to_s)
453
+ version_updated = version_updated + 1
454
+ end
455
+
456
+ if line.include? 'versionCode'
457
+ version_code = line.split(' ')[1]
458
+ line.sub!(version_code, version[VERSION_CODE].to_s)
459
+ version_updated = version_updated + 1
460
+ end
461
+ end
462
+ temp_file.puts line
463
+ end
464
+ end
465
+ file.close
466
+ end
467
+ temp_file.rewind
468
+ temp_file.close
469
+ FileUtils.mv(temp_file.path, gradle_path)
470
+ temp_file.unlink
471
+ end
472
+ end
473
+ end
474
+ end
475
+ end