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,367 @@
1
+ require 'fileutils'
2
+ require 'tempfile'
3
+
4
+ module Mysigner
5
+ module Build
6
+ class AndroidExecutor
7
+ class BuildError < StandardError; end
8
+
9
+ def initialize(project_info, parser)
10
+ @project_info = project_info
11
+ @parser = parser
12
+ end
13
+
14
+ # Build AAB (Android App Bundle) for Play Store
15
+ # Returns: path to .aab file
16
+ # Options:
17
+ # - variant: Build variant (default: 'release')
18
+ # - keystore_path: Path to keystore file
19
+ # - keystore_password: Keystore password
20
+ # - key_alias: Key alias in keystore
21
+ # - key_password: Key password (defaults to keystore_password)
22
+ # - version_code: Override version code (passed via gradle property)
23
+ def build_aab!(variant: 'release', keystore_path: nil, keystore_password: nil, key_alias: nil, key_password: nil, version_code: nil)
24
+ @variant = variant
25
+ @keystore_path = keystore_path
26
+ @keystore_password = keystore_password
27
+ @key_alias = key_alias
28
+ @key_password = key_password || keystore_password
29
+ @version_code = version_code
30
+
31
+ # Determine task name
32
+ task = "bundle#{variant.capitalize}"
33
+
34
+ # Build
35
+ success = run_gradle_build(task)
36
+
37
+ unless success
38
+ raise BuildError, "Android build failed. Check output above for errors."
39
+ end
40
+
41
+ # Find output AAB
42
+ aab_path = find_aab_output(variant)
43
+
44
+ unless aab_path && File.exist?(aab_path)
45
+ raise BuildError, "Build reported success but AAB not found. Expected at: #{@parser.aab_output_path(variant)}"
46
+ end
47
+
48
+ aab_path
49
+ end
50
+
51
+ # Build APK
52
+ # Returns: path to .apk file
53
+ def build_apk!(variant: 'release', keystore_path: nil, keystore_password: nil, key_alias: nil, key_password: nil)
54
+ @variant = variant
55
+ @keystore_path = keystore_path
56
+ @keystore_password = keystore_password
57
+ @key_alias = key_alias
58
+ @key_password = key_password || keystore_password
59
+
60
+ # Determine task name
61
+ task = "assemble#{variant.capitalize}"
62
+
63
+ # Build
64
+ success = run_gradle_build(task)
65
+
66
+ unless success
67
+ raise BuildError, "Android build failed. Check output above for errors."
68
+ end
69
+
70
+ # Find output APK
71
+ apk_path = find_apk_output(variant)
72
+
73
+ unless apk_path && File.exist?(apk_path)
74
+ raise BuildError, "Build reported success but APK not found. Expected at: #{@parser.apk_output_path(variant)}"
75
+ end
76
+
77
+ apk_path
78
+ end
79
+
80
+ # Clean build outputs
81
+ def clean!
82
+ run_gradle_command('clean')
83
+ end
84
+
85
+ private
86
+
87
+ def run_gradle_build(task)
88
+ # Ensure JAVA_HOME is valid
89
+ ensure_java_home!
90
+
91
+ # Handle framework-specific pre-build steps
92
+ run_pre_build_steps
93
+
94
+ # Build command with signing properties
95
+ cmd = build_gradle_command(task)
96
+
97
+ # Execute build
98
+ execute_with_output(cmd)
99
+ end
100
+
101
+ def ensure_java_home!
102
+ java_home = ENV['JAVA_HOME']
103
+
104
+ # Check if JAVA_HOME is set and valid
105
+ if java_home && !java_home.empty? && Dir.exist?(java_home)
106
+ # All good
107
+ else
108
+ # Try to detect valid JAVA_HOME
109
+ detected = detect_java_home
110
+ if detected
111
+ puts "🔧 Auto-detected JAVA_HOME: #{detected}"
112
+ ENV['JAVA_HOME'] = detected
113
+ elsif java_home && !java_home.empty?
114
+ raise BuildError, "JAVA_HOME is set to invalid directory: #{java_home}\n" \
115
+ "Run 'mysigner doctor' to fix, or set JAVA_HOME manually:\n" \
116
+ " export JAVA_HOME=$(/usr/libexec/java_home -v 17)"
117
+ end
118
+ end
119
+
120
+ # Also ensure ANDROID_HOME is set
121
+ ensure_android_home!
122
+ end
123
+
124
+ def ensure_android_home!
125
+ android_home = ENV['ANDROID_HOME'] || ENV['ANDROID_SDK_ROOT']
126
+
127
+ # Check if already valid
128
+ if android_home && !android_home.empty? && Dir.exist?(android_home)
129
+ return
130
+ end
131
+
132
+ # Try to detect Android SDK
133
+ detected = detect_android_home
134
+ if detected
135
+ puts "🔧 Auto-detected ANDROID_HOME: #{detected}"
136
+ ENV['ANDROID_HOME'] = detected
137
+ ENV['ANDROID_SDK_ROOT'] = detected
138
+ else
139
+ raise BuildError, "Android SDK not found.\n" \
140
+ "Run 'mysigner doctor' to diagnose, or set ANDROID_HOME:\n" \
141
+ " export ANDROID_HOME=~/Library/Android/sdk"
142
+ end
143
+ end
144
+
145
+ def detect_android_home
146
+ # Common SDK locations
147
+ candidates = [
148
+ File.expand_path('~/Library/Android/sdk'),
149
+ File.expand_path('~/Android/Sdk'),
150
+ '/opt/homebrew/share/android-commandlinetools',
151
+ '/usr/local/share/android-commandlinetools'
152
+ ]
153
+
154
+ candidates.each do |path|
155
+ return path if Dir.exist?(path) && Dir.exist?(File.join(path, 'platform-tools'))
156
+ end
157
+
158
+ nil
159
+ end
160
+
161
+ def detect_java_home
162
+ # Try macOS java_home utility first
163
+ if system('which /usr/libexec/java_home > /dev/null 2>&1')
164
+ java_home = `/usr/libexec/java_home 2>/dev/null`.strip
165
+ return java_home if !java_home.empty? && Dir.exist?(java_home)
166
+ end
167
+
168
+ # Try Homebrew paths (Apple Silicon)
169
+ %w[
170
+ /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
171
+ /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
172
+ /opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home
173
+ ].each { |p| return p if Dir.exist?(p) }
174
+
175
+ # Try Homebrew paths (Intel)
176
+ %w[
177
+ /usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
178
+ /usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
179
+ /usr/local/opt/openjdk/libexec/openjdk.jdk/Contents/Home
180
+ ].each { |p| return p if Dir.exist?(p) }
181
+
182
+ # Try system Java
183
+ system_paths = Dir.glob('/Library/Java/JavaVirtualMachines/*/Contents/Home')
184
+ return system_paths.first if system_paths.any?
185
+
186
+ nil
187
+ end
188
+
189
+ def run_pre_build_steps
190
+ case @project_info[:framework]
191
+ when :capacitor
192
+ # Capacitor: sync before build
193
+ puts "🔄 Syncing Capacitor..."
194
+ Dir.chdir(@project_info[:directory]) do
195
+ system('npx cap sync android > /dev/null 2>&1')
196
+ end
197
+ when :react_native
198
+ # React Native: might need to install dependencies
199
+ if File.exist?(File.join(@project_info[:directory], 'node_modules'))
200
+ # Dependencies already installed
201
+ else
202
+ puts "📦 Installing npm dependencies..."
203
+ Dir.chdir(@project_info[:directory]) do
204
+ system('npm install > /dev/null 2>&1') || system('yarn install > /dev/null 2>&1')
205
+ end
206
+ end
207
+ when :flutter
208
+ # Flutter: ensure dependencies are fetched
209
+ puts "📦 Getting Flutter dependencies..."
210
+ Dir.chdir(@project_info[:directory]) do
211
+ system('flutter pub get > /dev/null 2>&1')
212
+ end
213
+ end
214
+ end
215
+
216
+ def build_gradle_command(task)
217
+ android_dir = @parser.android_directory
218
+ gradle_cmd = @parser.gradle_command
219
+
220
+ cmd_parts = []
221
+
222
+ # Export JAVA_HOME if we detected/fixed it
223
+ java_home = ENV['JAVA_HOME']
224
+ if java_home && Dir.exist?(java_home)
225
+ cmd_parts << "export JAVA_HOME=#{shell_escape(java_home)}"
226
+ cmd_parts << "&&"
227
+ end
228
+
229
+ # Export ANDROID_HOME if we detected/fixed it
230
+ android_home = ENV['ANDROID_HOME']
231
+ if android_home && Dir.exist?(android_home)
232
+ cmd_parts << "export ANDROID_HOME=#{shell_escape(android_home)}"
233
+ cmd_parts << "&&"
234
+ cmd_parts << "export ANDROID_SDK_ROOT=#{shell_escape(android_home)}"
235
+ cmd_parts << "&&"
236
+ end
237
+
238
+ # Change to android directory and run gradle
239
+ cmd_parts << "cd #{shell_escape(android_dir)}"
240
+ cmd_parts << "&&"
241
+ cmd_parts << gradle_cmd
242
+ cmd_parts << task
243
+
244
+ # Add signing properties if provided
245
+ if @keystore_path && File.exist?(@keystore_path)
246
+ cmd_parts << "-Pandroid.injected.signing.store.file=#{shell_escape(File.absolute_path(@keystore_path))}"
247
+ cmd_parts << "-Pandroid.injected.signing.store.password=#{shell_escape(@keystore_password)}" if @keystore_password
248
+ cmd_parts << "-Pandroid.injected.signing.key.alias=#{shell_escape(@key_alias)}" if @key_alias
249
+ cmd_parts << "-Pandroid.injected.signing.key.password=#{shell_escape(@key_password)}" if @key_password
250
+ end
251
+
252
+ # Add version code override if provided (no file modification needed)
253
+ if @version_code
254
+ cmd_parts << "-PversionCode=#{@version_code}"
255
+ end
256
+
257
+ # Standard build options
258
+ cmd_parts << "--no-daemon" # Avoid daemon issues in CI
259
+ cmd_parts << "-q" # Quiet mode (less noise)
260
+
261
+ cmd_parts.join(' ')
262
+ end
263
+
264
+ def run_gradle_command(task)
265
+ android_dir = @parser.android_directory
266
+ gradle_cmd = @parser.gradle_command
267
+
268
+ exports = []
269
+ java_home = ENV['JAVA_HOME']
270
+ exports << "export JAVA_HOME=#{shell_escape(java_home)}" if java_home && Dir.exist?(java_home)
271
+
272
+ android_home = ENV['ANDROID_HOME']
273
+ if android_home && Dir.exist?(android_home)
274
+ exports << "export ANDROID_HOME=#{shell_escape(android_home)}"
275
+ exports << "export ANDROID_SDK_ROOT=#{shell_escape(android_home)}"
276
+ end
277
+
278
+ export_str = exports.any? ? exports.join(' && ') + ' && ' : ''
279
+ cmd = "#{export_str}cd #{shell_escape(android_dir)} && #{gradle_cmd} #{task} --no-daemon -q"
280
+ system(cmd)
281
+ end
282
+
283
+ def execute_with_output(cmd)
284
+ puts "🏗️ Running: gradle #{@variant}..."
285
+ puts ""
286
+
287
+ # Run command and capture output in real-time
288
+ IO.popen("#{cmd} 2>&1", 'r') do |io|
289
+ io.each_line do |line|
290
+ next if line.strip.empty?
291
+
292
+ # Show errors and warnings
293
+ if line.include?('FAILURE') || line.include?('ERROR') || line.include?('error:')
294
+ puts line
295
+ elsif line.include?('warning:') || line.include?('WARNING')
296
+ puts line
297
+ # Show task progress
298
+ elsif line.include?('> Task') || line.include?('BUILD')
299
+ print '.'
300
+ # Show download progress
301
+ elsif line.include?('Download')
302
+ # Skip verbose download logs
303
+ end
304
+ end
305
+ end
306
+
307
+ puts "" # New line after dots
308
+
309
+ $?.success?
310
+ end
311
+
312
+ def find_aab_output(variant)
313
+ android_dir = @parser.android_directory
314
+
315
+ # Search patterns for AAB files
316
+ patterns = [
317
+ File.join(android_dir, "app/build/outputs/bundle/#{variant}/*.aab"),
318
+ File.join(android_dir, "app/build/outputs/bundle/#{variant.downcase}/*.aab"),
319
+ File.join(android_dir, "build/outputs/bundle/#{variant}/*.aab"),
320
+ # Flutter uses different naming
321
+ File.join(android_dir, "app/build/outputs/bundle/#{variant}/app-#{variant}.aab"),
322
+ File.join(android_dir, "build/app/outputs/bundle/#{variant}/*.aab")
323
+ ]
324
+
325
+ patterns.each do |pattern|
326
+ matches = Dir.glob(pattern)
327
+ return matches.first if matches.any?
328
+ end
329
+
330
+ nil
331
+ end
332
+
333
+ def find_apk_output(variant)
334
+ android_dir = @parser.android_directory
335
+
336
+ # Search patterns for APK files
337
+ patterns = [
338
+ File.join(android_dir, "app/build/outputs/apk/#{variant}/*.apk"),
339
+ File.join(android_dir, "app/build/outputs/apk/#{variant.downcase}/*.apk"),
340
+ File.join(android_dir, "build/outputs/apk/#{variant}/*.apk"),
341
+ File.join(android_dir, "app/build/outputs/apk/#{variant}/app-#{variant}.apk"),
342
+ # Unsigned APKs
343
+ File.join(android_dir, "app/build/outputs/apk/#{variant}/app-#{variant}-unsigned.apk")
344
+ ]
345
+
346
+ patterns.each do |pattern|
347
+ matches = Dir.glob(pattern)
348
+ return matches.first if matches.any?
349
+ end
350
+
351
+ nil
352
+ end
353
+
354
+ def shell_escape(str)
355
+ return "''" if str.nil? || str.empty?
356
+
357
+ # If string contains no special characters, return as-is
358
+ if str =~ /\A[a-zA-Z0-9_.\-\/]+\z/
359
+ return str
360
+ end
361
+
362
+ # Otherwise, quote it
363
+ "'" + str.gsub("'", "'\\''") + "'"
364
+ end
365
+ end
366
+ end
367
+ end
@@ -0,0 +1,293 @@
1
+ module Mysigner
2
+ module Build
3
+ class AndroidParser
4
+ attr_reader :project_info
5
+
6
+ def initialize(project_info)
7
+ @project_info = project_info
8
+ @gradle_content = read_gradle_file
9
+ @manifest_content = read_manifest_file
10
+ end
11
+
12
+ # Get the application ID (package name)
13
+ def application_id
14
+ # Try to extract from build.gradle
15
+ app_id = extract_from_gradle('applicationId')
16
+ return app_id if app_id
17
+
18
+ # Try namespace (newer Gradle)
19
+ namespace = extract_from_gradle('namespace')
20
+ return namespace if namespace
21
+
22
+ # Try Expo app.json (for Expo projects)
23
+ expo_package = extract_from_expo_config
24
+ return expo_package if expo_package
25
+
26
+ # Fallback to AndroidManifest.xml
27
+ extract_package_from_manifest
28
+ end
29
+
30
+ alias package_name application_id
31
+
32
+ # Get version code
33
+ def version_code
34
+ extract_from_gradle('versionCode')&.to_i
35
+ end
36
+
37
+ # Get version name
38
+ def version_name
39
+ extract_from_gradle('versionName')
40
+ end
41
+
42
+ # Get minimum SDK version
43
+ def min_sdk_version
44
+ extract_from_gradle('minSdk') || extract_from_gradle('minSdkVersion')
45
+ end
46
+
47
+ # Get target SDK version
48
+ def target_sdk_version
49
+ extract_from_gradle('targetSdk') || extract_from_gradle('targetSdkVersion')
50
+ end
51
+
52
+ # Get compile SDK version
53
+ def compile_sdk_version
54
+ extract_from_gradle('compileSdk') || extract_from_gradle('compileSdkVersion')
55
+ end
56
+
57
+ # Get all build types (debug, release, etc.)
58
+ def build_types
59
+ types = []
60
+
61
+ # Match buildTypes block
62
+ if @gradle_content =~ /buildTypes\s*\{(.*?)\n\s*\}/m
63
+ block = $1
64
+ # Find all type names (e.g., "release {" or "debug {")
65
+ block.scan(/(\w+)\s*\{/) do |match|
66
+ types << match[0] unless %w[debug release].include?(match[0]) && types.include?(match[0])
67
+ types << match[0]
68
+ end
69
+ end
70
+
71
+ # Default build types if none found
72
+ types = ['debug', 'release'] if types.empty?
73
+ types.uniq
74
+ end
75
+
76
+ # Get all product flavors
77
+ def product_flavors
78
+ flavors = []
79
+
80
+ if @gradle_content =~ /productFlavors\s*\{(.*?)\n\s{4}\}/m
81
+ block = $1
82
+ block.scan(/(\w+)\s*\{/) do |match|
83
+ flavors << match[0]
84
+ end
85
+ end
86
+
87
+ flavors
88
+ end
89
+
90
+ # Get signing config for a build type
91
+ def signing_config(build_type = 'release')
92
+ # Look for signingConfig in the build type block
93
+ if @gradle_content =~ /#{build_type}\s*\{[^}]*signingConfig\s*(?:=\s*)?signingConfigs\.(\w+)/m
94
+ return $1
95
+ end
96
+ nil
97
+ end
98
+
99
+ # Check if signing is configured for release builds
100
+ def signing_configured?(build_type = 'release')
101
+ signing_config(build_type) != nil
102
+ end
103
+
104
+ # Get signing configs defined in the project
105
+ def signing_configs
106
+ configs = []
107
+
108
+ if @gradle_content =~ /signingConfigs\s*\{(.*?)\n\s{4}\}/m
109
+ block = $1
110
+ block.scan(/(\w+)\s*\{/) do |match|
111
+ configs << match[0]
112
+ end
113
+ end
114
+
115
+ configs
116
+ end
117
+
118
+ # Get keystore path from signing config
119
+ def keystore_path(config_name = 'release')
120
+ if @gradle_content =~ /#{config_name}\s*\{[^}]*storeFile\s*(?:=\s*)?(?:file\()?"?([^")\n]+)"?\)?/m
121
+ return $1
122
+ end
123
+ nil
124
+ end
125
+
126
+ # Get keystore alias from signing config
127
+ def keystore_alias(config_name = 'release')
128
+ if @gradle_content =~ /#{config_name}\s*\{[^}]*keyAlias\s*(?:=\s*)?["']?([^"'\n]+)["']?/m
129
+ return $1.strip
130
+ end
131
+ nil
132
+ end
133
+
134
+ # Get the app name from strings.xml or manifest
135
+ def app_name
136
+ # Try strings.xml first
137
+ strings_path = File.join(android_directory, 'app/src/main/res/values/strings.xml')
138
+ if File.exist?(strings_path)
139
+ content = File.read(strings_path)
140
+ if content =~ /<string\s+name="app_name"[^>]*>([^<]+)<\/string>/
141
+ return $1
142
+ end
143
+ end
144
+
145
+ # Fallback to manifest label
146
+ if @manifest_content && @manifest_content =~ /android:label="([^"]+)"/
147
+ return $1
148
+ end
149
+
150
+ # Fallback to directory name
151
+ File.basename(@project_info[:directory])
152
+ end
153
+
154
+ # Check if project uses Kotlin DSL
155
+ def kotlin_dsl?
156
+ @project_info[:app_build_gradle]&.end_with?('.kts')
157
+ end
158
+
159
+ # Get the gradle wrapper command
160
+ def gradle_command
161
+ wrapper_path = File.join(android_directory, 'gradlew')
162
+ File.exist?(wrapper_path) ? './gradlew' : 'gradle'
163
+ end
164
+
165
+ # Check if gradle wrapper exists
166
+ def gradle_wrapper_exists?
167
+ File.exist?(File.join(android_directory, 'gradlew'))
168
+ end
169
+
170
+ # Get available build variants (build type + flavor combinations)
171
+ def build_variants
172
+ flavors = product_flavors
173
+ types = build_types
174
+
175
+ if flavors.empty?
176
+ types.map { |t| t }
177
+ else
178
+ flavors.flat_map do |flavor|
179
+ types.map { |type| "#{flavor}#{type.capitalize}" }
180
+ end
181
+ end
182
+ end
183
+
184
+ # Get AAB output path for a variant
185
+ def aab_output_path(variant = 'release')
186
+ # Standard output location
187
+ File.join(android_directory, "app/build/outputs/bundle/#{variant}/app-#{variant}.aab")
188
+ end
189
+
190
+ # Get APK output path for a variant
191
+ def apk_output_path(variant = 'release')
192
+ File.join(android_directory, "app/build/outputs/apk/#{variant}/app-#{variant}.apk")
193
+ end
194
+
195
+ # Get Android directory
196
+ def android_directory
197
+ @project_info[:android_directory] || @project_info[:path]
198
+ end
199
+
200
+ # Get project summary
201
+ def summary
202
+ {
203
+ application_id: application_id,
204
+ version_code: version_code,
205
+ version_name: version_name,
206
+ min_sdk: min_sdk_version,
207
+ target_sdk: target_sdk_version,
208
+ build_types: build_types,
209
+ product_flavors: product_flavors,
210
+ signing_configured: signing_configured?,
211
+ kotlin_dsl: kotlin_dsl?,
212
+ gradle_wrapper: gradle_wrapper_exists?
213
+ }
214
+ end
215
+
216
+ private
217
+
218
+ def read_gradle_file
219
+ gradle_path = @project_info[:app_build_gradle]
220
+
221
+ unless gradle_path && File.exist?(gradle_path)
222
+ # Try to find it
223
+ android_dir = android_directory
224
+ gradle_path = File.join(android_dir, 'app/build.gradle')
225
+ gradle_path = File.join(android_dir, 'app/build.gradle.kts') unless File.exist?(gradle_path)
226
+ gradle_path = File.join(android_dir, 'build.gradle') unless File.exist?(gradle_path)
227
+ gradle_path = File.join(android_dir, 'build.gradle.kts') unless File.exist?(gradle_path)
228
+ end
229
+
230
+ return '' unless File.exist?(gradle_path)
231
+ File.read(gradle_path)
232
+ end
233
+
234
+ def read_manifest_file
235
+ manifest_path = File.join(android_directory, 'app/src/main/AndroidManifest.xml')
236
+ return nil unless File.exist?(manifest_path)
237
+ File.read(manifest_path)
238
+ end
239
+
240
+ def extract_from_gradle(property)
241
+ # Handle both Groovy and Kotlin DSL syntax
242
+ # Groovy: applicationId "com.example.app" or applicationId = "com.example.app"
243
+ # Kotlin: applicationId = "com.example.app"
244
+
245
+ patterns = [
246
+ /#{property}\s*=?\s*["']([^"']+)["']/,
247
+ /#{property}\s+["']([^"']+)["']/,
248
+ /#{property}\s*=\s*(\d+)/,
249
+ /#{property}\s+(\d+)/
250
+ ]
251
+
252
+ patterns.each do |pattern|
253
+ if @gradle_content =~ pattern
254
+ return $1
255
+ end
256
+ end
257
+
258
+ nil
259
+ end
260
+
261
+ def extract_package_from_manifest
262
+ return nil unless @manifest_content
263
+
264
+ if @manifest_content =~ /package="([^"]+)"/
265
+ return $1
266
+ end
267
+
268
+ nil
269
+ end
270
+
271
+ def extract_from_expo_config
272
+ # Check for app.json in project root
273
+ project_dir = @project_info[:directory]
274
+ app_json_path = File.join(project_dir, 'app.json')
275
+
276
+ return nil unless File.exist?(app_json_path)
277
+
278
+ begin
279
+ require 'json'
280
+ config = JSON.parse(File.read(app_json_path))
281
+
282
+ # Expo config can be nested under 'expo' key or at root
283
+ expo_config = config['expo'] || config
284
+
285
+ # Get Android package name
286
+ expo_config.dig('android', 'package')
287
+ rescue
288
+ nil
289
+ end
290
+ end
291
+ end
292
+ end
293
+ end