mysigner 0.1.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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/.DS_Store +0 -0
  3. data/.gitignore +12 -0
  4. data/.rspec +3 -0
  5. data/.ruby-version +1 -0
  6. data/.travis.yml +7 -0
  7. data/CODE_OF_CONDUCT.md +74 -0
  8. data/Gemfile +7 -0
  9. data/Gemfile.lock +137 -0
  10. data/LICENSE +201 -0
  11. data/MANUAL_TEST.md +341 -0
  12. data/README.md +493 -0
  13. data/Rakefile +6 -0
  14. data/bin/console +14 -0
  15. data/bin/setup +8 -0
  16. data/exe/mysigner +5 -0
  17. data/lib/mysigner/build/android_executor.rb +367 -0
  18. data/lib/mysigner/build/android_parser.rb +293 -0
  19. data/lib/mysigner/build/configurator.rb +126 -0
  20. data/lib/mysigner/build/detector.rb +388 -0
  21. data/lib/mysigner/build/error_analyzer.rb +193 -0
  22. data/lib/mysigner/build/executor.rb +176 -0
  23. data/lib/mysigner/build/parser.rb +206 -0
  24. data/lib/mysigner/cli/auth_commands.rb +1381 -0
  25. data/lib/mysigner/cli/build_commands.rb +2095 -0
  26. data/lib/mysigner/cli/concerns/actionable_suggestions.rb +500 -0
  27. data/lib/mysigner/cli/concerns/api_helpers.rb +131 -0
  28. data/lib/mysigner/cli/concerns/error_handlers.rb +446 -0
  29. data/lib/mysigner/cli/concerns/helpers.rb +63 -0
  30. data/lib/mysigner/cli/diagnostic_commands.rb +1034 -0
  31. data/lib/mysigner/cli/resource_commands.rb +2670 -0
  32. data/lib/mysigner/cli.rb +43 -0
  33. data/lib/mysigner/client.rb +189 -0
  34. data/lib/mysigner/config.rb +311 -0
  35. data/lib/mysigner/export/exporter.rb +150 -0
  36. data/lib/mysigner/signing/certificate_checker.rb +148 -0
  37. data/lib/mysigner/signing/keystore_manager.rb +239 -0
  38. data/lib/mysigner/signing/validator.rb +150 -0
  39. data/lib/mysigner/signing/wizard.rb +784 -0
  40. data/lib/mysigner/upload/app_store_automation.rb +402 -0
  41. data/lib/mysigner/upload/app_store_submission.rb +312 -0
  42. data/lib/mysigner/upload/play_store_uploader.rb +378 -0
  43. data/lib/mysigner/upload/uploader.rb +373 -0
  44. data/lib/mysigner/version.rb +3 -0
  45. data/lib/mysigner.rb +15 -0
  46. data/mysigner.gemspec +78 -0
  47. data/test_manual.rb +102 -0
  48. metadata +286 -0
@@ -0,0 +1,312 @@
1
+ require 'set'
2
+
3
+ module Mysigner
4
+ module Upload
5
+ class AppStoreSubmission
6
+ class SubmissionError < Mysigner::Error; end
7
+
8
+ def initialize(client, organization_id, build_info, metadata_overrides: {}, override_sources: [])
9
+ @client = client
10
+ @organization_id = organization_id
11
+ @build_info = build_info # { bundle_id:, version:, build_number:, app_id:, build_id: }
12
+ @metadata_overrides = metadata_overrides || {}
13
+ @override_sources = Array(override_sources)
14
+ @override_lookup = build_override_lookup(@override_sources)
15
+ end
16
+
17
+ # Submit build for App Store review
18
+ def submit_for_review!(automation: nil)
19
+ puts ""
20
+ puts "šŸ“¤ Preparing for App Store submission..."
21
+ puts ""
22
+
23
+ begin
24
+ # Step 1: Fetch release metadata from My Signer API
25
+ merged = merge_metadata(fetch_release_metadata)
26
+ metadata = merged[:merged]
27
+
28
+ if metadata && metadata.any?
29
+ puts "āœ“ Loaded release configuration from My Signer"
30
+ puts ""
31
+ display_metadata(metadata)
32
+ else
33
+ puts "āš ļø No release configuration found in My Signer"
34
+ puts " Create one at: #{@client.api_url}/organizations/#{@organization_id}/app_store_releases"
35
+ puts ""
36
+ end
37
+
38
+ # Enrich build_info with config values for smart build selection
39
+ enriched_build_info = symbolize_keys(@build_info)
40
+ if metadata
41
+ # min_build_number: skip builds below this number
42
+ if metadata['build_number'] && !enriched_build_info[:build_number]
43
+ enriched_build_info[:min_build_number] = metadata['build_number'].to_i
44
+ end
45
+ # Use version_string from config if not specified
46
+ if metadata['version_string'] && !enriched_build_info[:version]
47
+ enriched_build_info[:version] = metadata['version_string']
48
+ end
49
+ end
50
+
51
+ automation_result = if automation
52
+ automation.perform!(
53
+ metadata: metadata,
54
+ build_info: enriched_build_info,
55
+ metadata_overrides: @metadata_overrides
56
+ )
57
+ else
58
+ guide_to_manual_submission(metadata)
59
+ nil
60
+ end
61
+
62
+ { success: true, metadata: metadata, automation: automation_result }
63
+
64
+ rescue => e
65
+ raise SubmissionError, "Failed to prepare submission: #{e.message}"
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def fetch_release_metadata
72
+ # Fetch release metadata from My Signer API
73
+ begin
74
+ response = @client.get(
75
+ "/api/v1/organizations/#{@organization_id}/app_store_releases",
76
+ params: { bundle_id: @build_info[:bundle_id] }
77
+ )
78
+
79
+ if response[:success]
80
+ data = response[:data]
81
+ # API returns { app_store_releases: [...] } - extract first release
82
+ if data.is_a?(Hash) && data['app_store_releases'].is_a?(Array)
83
+ data['app_store_releases'].first
84
+ elsif data.is_a?(Hash) && data['app_store_release']
85
+ # Single release format
86
+ data['app_store_release']
87
+ else
88
+ data
89
+ end
90
+ else
91
+ nil
92
+ end
93
+ rescue Mysigner::NotFoundError
94
+ # No configuration found - that's okay
95
+ nil
96
+ rescue StandardError => e
97
+ puts "āš ļø Could not fetch release metadata: #{e.message}"
98
+ nil
99
+ end
100
+ end
101
+
102
+ # Fetch release config and extract min_build_number for smart build selection
103
+ def fetch_release_config
104
+ metadata = fetch_release_metadata
105
+ return {} unless metadata
106
+
107
+ config = {}
108
+ config[:min_build_number] = metadata['build_number'].to_i if metadata['build_number']
109
+ config[:release_type] = metadata['release_type'] if metadata['release_type']
110
+ config[:earliest_release_date] = metadata['earliest_release_date'] if metadata['earliest_release_date']
111
+ config
112
+ end
113
+
114
+ def merge_metadata(api_metadata)
115
+ api_data = stringify_keys(api_metadata || {})
116
+ overrides = stringify_keys(@metadata_overrides)
117
+ {
118
+ merged: deep_merge(api_data, overrides),
119
+ api: api_data,
120
+ overrides: overrides
121
+ }
122
+ end
123
+
124
+ def guide_to_manual_submission(metadata)
125
+ puts "šŸ“‹ Next Steps for App Store Submission"
126
+ puts "=" * 60
127
+ puts ""
128
+ puts "Your build is uploaded to App Store Connect!"
129
+ puts ""
130
+ puts "To submit for review:"
131
+ puts " 1. Wait for Apple to process the build (5-15 minutes)"
132
+ puts " 2. Open App Store Connect:"
133
+ puts " https://appstoreconnect.apple.com"
134
+ puts " 3. Select your app and go to 'App Store' tab"
135
+ puts " 4. Create a new version or select existing one"
136
+ puts " 5. Select this build (#{@build_info[:version]} / #{@build_info[:build_number]})"
137
+ puts " 6. Add screenshots and metadata if not already present"
138
+ puts " 7. Click 'Submit for Review'"
139
+ puts ""
140
+
141
+ if metadata && metadata['auto_submit']
142
+ puts "šŸ’” Auto-submit enabled"
143
+ end
144
+
145
+ puts ""
146
+ puts "Tip: rerun with --submit-for-review when ready"
147
+ puts " Use --wait/--asc-timeout-seconds to control polling"
148
+ puts ""
149
+ end
150
+
151
+ def display_metadata(metadata)
152
+ puts "šŸ“ Release Configuration:"
153
+ print_metadata_line('Bundle ID', metadata['bundle_identifier'], 'bundle_identifier')
154
+ print_metadata_line('App Name', metadata['app_name'], 'app_name') if metadata['app_name']
155
+
156
+ # Version info from config
157
+ print_metadata_line('Version String', metadata['version_string'], 'version_string') if metadata['version_string']
158
+ print_metadata_line('Min Build #', metadata['build_number'], 'build_number') if metadata['build_number']
159
+
160
+ if metadata['whats_new'] && !metadata['whats_new'].to_s.strip.empty?
161
+ puts " What's New: #{truncate(metadata['whats_new'])}#{override_suffix('whats_new')}"
162
+ else
163
+ puts " What's New: —#{override_suffix('whats_new')}"
164
+ end
165
+
166
+ if metadata['promotional_text'] && !metadata['promotional_text'].to_s.strip.empty?
167
+ puts " Promo Text: #{truncate(metadata['promotional_text'])}#{override_suffix('promotional_text')}"
168
+ end
169
+
170
+ print_metadata_line('Support URL', metadata['support_url'], 'support_url')
171
+ print_metadata_line('Marketing URL', metadata['marketing_url'], 'marketing_url')
172
+ print_metadata_line('Privacy URL', metadata['privacy_policy_url'], 'privacy_policy_url')
173
+
174
+ # Release settings
175
+ release_type_label = format_release_type(metadata['release_type'])
176
+ print_metadata_line('Release Type', release_type_label, 'release_type')
177
+ if metadata['release_type'] == 'SCHEDULED' && metadata['earliest_release_date']
178
+ print_metadata_line('Scheduled Date', metadata['earliest_release_date'], 'earliest_release_date')
179
+ end
180
+
181
+ print_metadata_toggle('Auto-submit', metadata['auto_submit'], 'auto_submit')
182
+ print_metadata_toggle('Phased Release', metadata['phased_release'], 'phased_release')
183
+
184
+ if metadata['localizations'].is_a?(Array) && metadata['localizations'].any?
185
+ first_locale = metadata['localizations'].first
186
+ locale_code = first_locale['locale'] || first_locale['localeCode'] || 'default'
187
+ puts " Localizations: #{metadata['localizations'].count} (showing #{locale_code})#{override_suffix('localizations')}"
188
+ end
189
+
190
+ warn_missing_submission_fields(metadata)
191
+ puts ""
192
+ end
193
+
194
+ def format_release_type(release_type)
195
+ case release_type
196
+ when 'AFTER_APPROVAL' then 'After Approval (auto-release)'
197
+ when 'MANUAL' then 'Manual (hold for manual release)'
198
+ when 'SCHEDULED' then 'Scheduled'
199
+ else release_type || 'After Approval (default)'
200
+ end
201
+ end
202
+
203
+ def deep_merge(base, overrides)
204
+ merged = base.dup
205
+
206
+ overrides.each do |key, value|
207
+ merged[key] = if merged[key].is_a?(Hash) && value.is_a?(Hash)
208
+ deep_merge(merged[key], value)
209
+ else
210
+ value
211
+ end
212
+ end
213
+
214
+ merged
215
+ end
216
+
217
+ def stringify_keys(object)
218
+ case object
219
+ when Hash
220
+ object.each_with_object({}) do |(k, v), memo|
221
+ memo[k.to_s] = stringify_keys(v)
222
+ end
223
+ when Array
224
+ object.map { |item| stringify_keys(item) }
225
+ else
226
+ object
227
+ end
228
+ end
229
+
230
+ def symbolize_keys(object)
231
+ case object
232
+ when Hash
233
+ object.each_with_object({}) do |(k, v), memo|
234
+ memo[k.to_sym] = symbolize_keys(v)
235
+ end
236
+ when Array
237
+ object.map { |item| symbolize_keys(item) }
238
+ else
239
+ object
240
+ end
241
+ end
242
+
243
+ def truncate(text, max = 100)
244
+ text = text.to_s
245
+ return '—' if text.strip.empty?
246
+
247
+ text.length > max ? "#{text[0, max]}..." : text
248
+ end
249
+
250
+ def print_metadata_line(label, value, key)
251
+ display = value && !value.to_s.strip.empty? ? value : '—'
252
+ puts " #{label}: #{display}#{override_suffix(key)}"
253
+ end
254
+
255
+ def print_metadata_toggle(label, value, key)
256
+ human = value.nil? ? '—' : (value ? 'Yes' : 'No')
257
+ puts " #{label}: #{human}#{override_suffix(key)}"
258
+ end
259
+
260
+ def override_suffix(key)
261
+ sources = Array(@override_lookup[key])
262
+ return '' if sources.empty?
263
+
264
+ formatted = sources.map do |source|
265
+ case source[:type]
266
+ when :inline then 'CLI flag'
267
+ when :file
268
+ File.basename(source[:path])
269
+ else
270
+ source[:type].to_s
271
+ end
272
+ end.uniq
273
+
274
+ " (override: #{formatted.join(', ')})"
275
+ end
276
+
277
+ def build_override_lookup(sources)
278
+ lookup = Hash.new { |h, k| h[k] = [] }
279
+ sources.each do |source|
280
+ Array(source[:keys]).each do |key|
281
+ lookup[key] << source
282
+ end
283
+ end
284
+ lookup
285
+ end
286
+
287
+ def warn_missing_submission_fields(metadata)
288
+ return unless metadata['auto_submit']
289
+
290
+ # Get version to check if first version
291
+ version_string = metadata['version_string'] || metadata['version'] || '1.0'
292
+ is_first_version = version_string.split('.').first.to_i <= 1
293
+
294
+ warnings = []
295
+ # What's New only required for updates (version > 1.0)
296
+ warnings << "Missing What's New copy (required for version updates)" if !is_first_version && metadata['whats_new'].to_s.strip.empty?
297
+
298
+ # Support URL may already be in App Store Connect, so just note it
299
+ if metadata['support_url'].to_s.strip.empty?
300
+ warnings << "Support URL not configured in My Signer (will use App Store Connect value if available)"
301
+ end
302
+
303
+ return if warnings.empty?
304
+
305
+ puts " āš ļø Notes:" unless warnings.empty?
306
+ warnings.each { |msg| puts " - #{msg}" }
307
+ end
308
+ end
309
+ end
310
+ end
311
+
312
+
@@ -0,0 +1,378 @@
1
+ require 'json'
2
+ require 'stringio'
3
+
4
+ module Mysigner
5
+ module Upload
6
+ class PlayStoreUploader
7
+ class UploadError < Mysigner::Error; end
8
+ class CredentialsError < UploadError; end
9
+ class TrackError < UploadError; end
10
+
11
+ # Special error for when AAB uploaded but track assignment failed
12
+ # This carries the version_code so it can be saved to prevent conflicts
13
+ class PartialUploadError < UploadError
14
+ attr_reader :version_code
15
+
16
+ def initialize(message, version_code:)
17
+ super(message)
18
+ @version_code = version_code
19
+ end
20
+ end
21
+
22
+ VALID_TRACKS = %w[internal alpha beta production].freeze
23
+ SCOPE = 'https://www.googleapis.com/auth/androidpublisher'.freeze
24
+
25
+ def initialize(aab_path:, service_account_json:, package_name:)
26
+ @aab_path = File.expand_path(aab_path)
27
+ @service_account_json = service_account_json
28
+ @package_name = package_name
29
+
30
+ validate_aab!
31
+ setup_google_client!
32
+ end
33
+
34
+ # Upload AAB and optionally assign to a track
35
+ # @param track [String] Track to assign: internal, alpha, beta, production
36
+ # @param release_notes [Hash] Localized release notes { 'en-US' => 'What\'s new...' }
37
+ # @param user_fraction [Float] Rollout percentage (0.0-1.0) for staged rollouts
38
+ # @return [Hash] Upload result with version_code and track info
39
+ def upload!(track: 'internal', release_notes: nil, user_fraction: nil)
40
+ @current_track = track # Store for error messages
41
+ say_uploading(track)
42
+
43
+ version_code = nil
44
+
45
+ begin
46
+ # 1. Create an edit
47
+ edit = create_edit
48
+
49
+ # 2. Upload the AAB
50
+ bundle = upload_bundle(edit.id)
51
+ version_code = bundle.version_code
52
+
53
+ say_upload_success(version_code)
54
+
55
+ # 3. Assign to track with release
56
+ if track
57
+ assign_to_track(edit.id, track, version_code, release_notes: release_notes, user_fraction: user_fraction)
58
+ end
59
+
60
+ # 4. Commit the edit
61
+ commit_edit(edit.id)
62
+
63
+ say_success(track, version_code)
64
+
65
+ {
66
+ success: true,
67
+ version_code: version_code,
68
+ track: track,
69
+ package_name: @package_name
70
+ }
71
+ rescue Google::Apis::ClientError => e
72
+ error_message = parse_google_error(e)
73
+ # If AAB was uploaded, raise PartialUploadError so CLI can save the version
74
+ if version_code
75
+ raise PartialUploadError.new("Google Play API error: #{error_message}", version_code: version_code)
76
+ else
77
+ raise UploadError, "Google Play API error: #{error_message}"
78
+ end
79
+ rescue PartialUploadError
80
+ # Re-raise as-is
81
+ raise
82
+ rescue => e
83
+ if version_code
84
+ raise PartialUploadError.new("Upload failed: #{e.message}", version_code: version_code)
85
+ else
86
+ raise UploadError, "Upload failed: #{e.message}"
87
+ end
88
+ end
89
+ end
90
+
91
+ # Upload AAB only (without assigning to track)
92
+ def upload_bundle_only!
93
+ say_uploading(nil)
94
+
95
+ begin
96
+ edit = create_edit
97
+ bundle = upload_bundle(edit.id)
98
+ version_code = bundle.version_code
99
+
100
+ say_upload_success(version_code)
101
+
102
+ # Don't assign to track, just commit
103
+ commit_edit(edit.id)
104
+
105
+ {
106
+ success: true,
107
+ version_code: version_code,
108
+ package_name: @package_name
109
+ }
110
+ rescue Google::Apis::ClientError => e
111
+ error_message = parse_google_error(e)
112
+ raise UploadError, "Google Play API error: #{error_message}"
113
+ rescue => e
114
+ raise UploadError, "Upload failed: #{e.message}"
115
+ end
116
+ end
117
+
118
+ # Assign an existing version code to a track
119
+ def assign_existing_to_track!(version_code, track:, release_notes: nil, user_fraction: nil)
120
+ @current_track = track # Store for error messages
121
+ begin
122
+ edit = create_edit
123
+ assign_to_track(edit.id, track, version_code, release_notes: release_notes, user_fraction: user_fraction)
124
+ commit_edit(edit.id)
125
+
126
+ {
127
+ success: true,
128
+ version_code: version_code,
129
+ track: track,
130
+ package_name: @package_name
131
+ }
132
+ rescue Google::Apis::ClientError => e
133
+ error_message = parse_google_error(e)
134
+ raise TrackError, "Failed to assign to track: #{error_message}"
135
+ end
136
+ end
137
+
138
+ private
139
+
140
+ def validate_aab!
141
+ unless File.exist?(@aab_path)
142
+ raise UploadError, "AAB file not found: #{@aab_path}"
143
+ end
144
+
145
+ unless @aab_path.end_with?('.aab')
146
+ raise UploadError, "Invalid file type: #{@aab_path} (must be .aab)"
147
+ end
148
+
149
+ file_size = File.size(@aab_path)
150
+ if file_size < 10_000
151
+ raise UploadError, "AAB file seems too small: #{file_size} bytes (possible corruption)"
152
+ end
153
+ end
154
+
155
+ def setup_google_client!
156
+ require 'googleauth'
157
+ require 'google/apis/androidpublisher_v3'
158
+
159
+ # Parse and validate service account JSON
160
+ begin
161
+ @credentials_data = JSON.parse(@service_account_json)
162
+ rescue JSON::ParserError => e
163
+ raise CredentialsError, "Invalid service account JSON: #{e.message}"
164
+ end
165
+
166
+ # Build authorization
167
+ @auth = Google::Auth::ServiceAccountCredentials.make_creds(
168
+ json_key_io: StringIO.new(@service_account_json),
169
+ scope: SCOPE
170
+ )
171
+
172
+ # Create service
173
+ @service = Google::Apis::AndroidpublisherV3::AndroidPublisherService.new
174
+ @service.authorization = @auth
175
+ @service.client_options.open_timeout_sec = 30
176
+ @service.client_options.read_timeout_sec = 300 # Large file uploads need time
177
+ @service.request_options.retries = 3
178
+
179
+ rescue LoadError => e
180
+ raise CredentialsError, "Google API client not installed. Run: gem install google-api-client"
181
+ end
182
+
183
+ def create_edit
184
+ edit = Google::Apis::AndroidpublisherV3::AppEdit.new
185
+ @service.insert_edit(@package_name, edit)
186
+ rescue Google::Apis::ClientError => e
187
+ if e.message.include?("Package not found") || e.status_code == 404
188
+ raise UploadError, first_upload_error_message
189
+ end
190
+ raise
191
+ end
192
+
193
+ def first_upload_error_message
194
+ <<~MSG
195
+ Google Play API can't find package '#{@package_name}'.
196
+
197
+ This happens when no build has been uploaded to this app yet.
198
+ Google Play API requires the FIRST build to be uploaded manually.
199
+
200
+ To fix:
201
+ 1. Build AAB: mysigner android build
202
+ 2. Go to Play Console → Your App → Internal testing → Create release
203
+ 3. Upload the AAB file shown in the build output
204
+ 4. Save the release (don't need to roll out)
205
+
206
+ After that, mysigner ship will work for all future uploads.
207
+ MSG
208
+ end
209
+
210
+ def upload_bundle(edit_id)
211
+ puts "šŸ“¦ Uploading AAB (#{format_bytes(File.size(@aab_path))})..."
212
+ puts ""
213
+
214
+ begin
215
+ result = @service.upload_edit_bundle(
216
+ @package_name,
217
+ edit_id,
218
+ upload_source: @aab_path,
219
+ content_type: 'application/octet-stream'
220
+ )
221
+ result
222
+ rescue Google::Apis::ClientError => e
223
+ error_msg = parse_google_error(e)
224
+ raise UploadError, "Bundle upload failed: #{error_msg}"
225
+ rescue => e
226
+ raise UploadError, "Bundle upload failed: #{e.message}"
227
+ end
228
+ end
229
+
230
+ def assign_to_track(edit_id, track, version_code, release_notes: nil, user_fraction: nil)
231
+ unless VALID_TRACKS.include?(track)
232
+ raise TrackError, "Invalid track '#{track}'. Valid tracks: #{VALID_TRACKS.join(', ')}"
233
+ end
234
+
235
+ puts "šŸš‚ Assigning to #{track} track..."
236
+
237
+ # Build release
238
+ release = Google::Apis::AndroidpublisherV3::TrackRelease.new(
239
+ version_codes: [version_code.to_s],
240
+ status: user_fraction ? 'inProgress' : 'completed'
241
+ )
242
+
243
+ # Add release notes if provided
244
+ if release_notes && release_notes.any?
245
+ release.release_notes = release_notes.map do |lang, text|
246
+ Google::Apis::AndroidpublisherV3::LocalizedText.new(
247
+ language: lang,
248
+ text: text
249
+ )
250
+ end
251
+ end
252
+
253
+ # Add user fraction for staged rollouts
254
+ if user_fraction
255
+ release.user_fraction = user_fraction
256
+ end
257
+
258
+ # Build track update
259
+ track_obj = Google::Apis::AndroidpublisherV3::Track.new(
260
+ track: track,
261
+ releases: [release]
262
+ )
263
+
264
+ @service.update_edit_track(@package_name, edit_id, track, track_obj)
265
+ end
266
+
267
+ def commit_edit(edit_id)
268
+ puts "šŸ’¾ Committing changes..."
269
+ begin
270
+ # Try with changesNotSentForReview first (for apps without managed review)
271
+ @service.commit_edit(@package_name, edit_id, changes_not_sent_for_review: true)
272
+ rescue Google::Apis::ClientError => e
273
+ error_text = e.message.to_s
274
+ # Also check body if present
275
+ if e.respond_to?(:body) && e.body
276
+ error_text += " #{e.body}"
277
+ end
278
+
279
+ if error_text.include?('changesNotSentForReview')
280
+ # App has managed review enabled, commit without the flag
281
+ @service.commit_edit(@package_name, edit_id)
282
+ else
283
+ raise
284
+ end
285
+ end
286
+ end
287
+
288
+ def parse_google_error(error)
289
+ begin
290
+ body = nil
291
+ if error.respond_to?(:body) && error.body
292
+ body = JSON.parse(error.body) rescue nil
293
+ end
294
+
295
+ message = body&.dig('error', 'message') || error.message
296
+ details = body&.dig('error', 'errors')&.map { |e| e['message'] }&.join('; ')
297
+ full_message = details ? "#{message} (#{details})" : message
298
+
299
+ # Provide helpful context for common errors
300
+ case full_message.to_s.downcase
301
+ when /package.*not found/i, /app not found/i
302
+ "#{full_message}\n\nšŸ’” Make sure the package name '#{@package_name}' matches your app in Google Play Console."
303
+ when /not authorized/i, /permission denied/i, /forbidden/i
304
+ "#{full_message}\n\nšŸ’” Check that your service account has Editor or Admin access to the app in Google Play Console."
305
+ when /version.*code.*already/i, /already.*used/i
306
+ "#{full_message}\n\nšŸ’” Version code already exists. Increment versionCode in android/app/build.gradle and rebuild."
307
+ when /precondition.*check.*failed/i, /precondition.*failed/i
308
+ track_name = @current_track || "this track"
309
+ "#{full_message}\n\n" \
310
+ "šŸ’” Google Play Console requires setup before publishing to #{track_name}:\n\n" \
311
+ " For PRODUCTION track:\n" \
312
+ " • Complete store listing (description, screenshots, etc.)\n" \
313
+ " • Set content rating\n" \
314
+ " • Configure pricing & distribution\n\n" \
315
+ " For BETA/ALPHA tracks:\n" \
316
+ " • Create a closed/open testing track in Play Console\n" \
317
+ " • Add at least one tester email\n\n" \
318
+ " For INTERNAL track:\n" \
319
+ " • Add internal testers in Play Console\n\n" \
320
+ " āœ… Your AAB was uploaded successfully!\n" \
321
+ " → Go to Play Console to complete track setup, then use:\n" \
322
+ " mysigner submit #{track_name} --platform android --version-code VERSION"
323
+ when /invalid request/i
324
+ "#{full_message}\n\nšŸ’” Common causes:\n" \
325
+ " • Version code not found on Google Play (must upload first)\n" \
326
+ " • App not created in Google Play Console\n" \
327
+ " • Service account missing permissions"
328
+ when /signing/i, /signature/i
329
+ "#{full_message}\n\nšŸ’” The AAB may not be signed with the correct key. Check your keystore matches what's registered in Play Console."
330
+ else
331
+ full_message
332
+ end
333
+ rescue => parse_error
334
+ "#{error.message} (parsing error: #{parse_error.message})"
335
+ end
336
+ end
337
+
338
+ def say_uploading(track)
339
+ puts "ā˜ļø Uploading to Google Play#{track ? " (#{track} track)" : ''}..."
340
+ puts ""
341
+ puts "AAB: #{File.basename(@aab_path)}"
342
+ puts "Size: #{format_bytes(File.size(@aab_path))}"
343
+ puts "Package: #{@package_name}"
344
+ puts "Track: #{track || 'none (upload only)'}"
345
+ puts ""
346
+ end
347
+
348
+ def say_upload_success(version_code)
349
+ puts "āœ“ Bundle uploaded successfully (version code: #{version_code})"
350
+ puts ""
351
+ end
352
+
353
+ def say_success(track, version_code)
354
+ puts ""
355
+ puts "=" * 80
356
+ puts "āœ“ Upload complete!"
357
+ puts "=" * 80
358
+ puts ""
359
+ puts "šŸŽ‰ Your app is now on Google Play (#{track} track)"
360
+ puts ""
361
+ puts "Version Code: #{version_code}"
362
+ puts "Package: #{@package_name}"
363
+ puts "Track: #{track}"
364
+ puts ""
365
+ end
366
+
367
+ def format_bytes(bytes)
368
+ if bytes < 1024
369
+ "#{bytes} B"
370
+ elsif bytes < 1024 * 1024
371
+ "#{(bytes / 1024.0).round(1)} KB"
372
+ else
373
+ "#{(bytes / (1024.0 * 1024)).round(1)} MB"
374
+ end
375
+ end
376
+ end
377
+ end
378
+ end