fastlane-plugin-wpmreleasetoolkit 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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