ann-flavor-flutter 0.4.5 → 0.4.7

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: defb9dd7c5b8628bf31becb0117001e9468f0534acd8adf739e3505dd0c28628
4
- data.tar.gz: b2fc3b531e1870e811bff2eca5371bc823a2079ab9dd8e0298dc698c16811efc
3
+ metadata.gz: fd7cce396ef86ea1e15205e7440d3755a79707eeaa55126a84bce553d0785add
4
+ data.tar.gz: 10d3037745026703bcd2ae17d93ea6467fb8fe3b6d0962f228b23f2d25e4e7cc
5
5
  SHA512:
6
- metadata.gz: 79151855669eea4e3c328da868b4024a47b5669e43cb38e72f47333f480da0bb48c52f1a81e8ea85fb9b5095d75face5a88c72289873f8137b134b565927a464
7
- data.tar.gz: e366bb91225e5adcca0e31bc5d4dce65909d165e4b1735a710f19b4f484b61094fa2b660ddf78fd270b6b5d30bf4c3679596332e4efca3f81b5a63bf462d0633
6
+ metadata.gz: b3312b0e1231f50e7c99bb0ea43580245bd5bae4814dfd63d790e29fa41f9bc197f5492dcb3e0f9b120ac95e8cbbd4c27666874ed0abc18caf8b0183d0013a26
7
+ data.tar.gz: 174e5a6e6a016a6772ad6d764056b4576e09279f03130a0e91e36e323d4f0bb3f9b6dc4db825d3405a3feeefc034bfe1f42c8dbf7b1cdd5217f0a13bc1eb8308
data/.gitignore CHANGED
@@ -1,2 +1,7 @@
1
1
  /pkg/
2
2
  Gemfile.lock
3
+
4
+ # Vendored ann_flavor_core source, generated transiently during publish (DEF-058).
5
+ # Never committed for real — CI/`make publish-fastlane` `git add -f` it briefly
6
+ # so `gem build`'s `git ls-files`-based file list includes it, then discard it.
7
+ /lib/vendor/
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.7
4
+
5
+ ### Fixed
6
+ - `dart_defines` (`annspec.yaml`'s `android.default.dart_defines` / per-flavor overrides) was silently never applied to `flutter build` when compilation happened as part of `upload_to_play_store`, `upload_to_app_store`, or `upload_to_cloudflare` — only a manual `bundle exec fastlane android compile_build` invocation picked it up, since the flag was injected only into the generated Fastfile's standalone `compile_build` lane, which those upload/publish helpers never call. `compile_build` (the shared helper in `lanes_annai.rb`) now resolves `dart_defines` itself at runtime, so every caller gets it automatically — this also means a flavor-level `dart_defines` override is now honored, which the old generation-time injection could never represent (it could only see default-level values). ([#59](https://github.com/anntech-dev/ann-flavor-tooling/issues/59))
7
+
8
+ ## 0.4.6
9
+
10
+ ### Fixed
11
+ - Loading this gem (`require 'ann_flavor_flutter'` / `require 'fastlane/plugin/ann_flavor_flutter'`) failed with `cannot load such file -- ann_flavor_core` — the 0.4.5 core-delegation change added a hard `require 'ann_flavor_core'` in `utils_spec_loader.rb`, but `ann_flavor_core` is intentionally never published to RubyGems (ADR-008). The published gem now vendors `ann_flavor_core`'s source and resolves it automatically; local monorepo development is unaffected. ([#58](https://github.com/anntech-dev/ann-flavor-tooling/issues/58))
12
+
3
13
  ## 0.4.5
4
14
 
5
15
  ### Changed
@@ -1,3 +1,3 @@
1
1
  module AnnFlavorFlutter
2
- VERSION = "0.4.5"
2
+ VERSION = "0.4.7"
3
3
  end
@@ -1,5 +1,13 @@
1
1
  # This file is loaded by require 'annai-flutter-flavor'
2
2
 
3
+ # The published gem vendors ann_flavor_core's source under lib/vendor/ (ADR-008:
4
+ # ann_flavor_core is never published to RubyGems, only path/git dependencies).
5
+ # In the monorepo this directory doesn't exist, and ann_flavor_core resolves via
6
+ # the real gem installed by the local Gemfile's `path:` dependency instead.
7
+ vendored_core_lib = File.expand_path('vendor/ann_flavor_core/lib', __dir__)
8
+ $LOAD_PATH.unshift(vendored_core_lib) if File.directory?(vendored_core_lib) &&
9
+ !$LOAD_PATH.include?(vendored_core_lib)
10
+
3
11
  # Load the version (Good Practice)
4
12
  require 'ann_flavor_flutter/version'
5
13
 
@@ -113,6 +113,22 @@ module FastlaneFlutterFlavor
113
113
  end
114
114
  end
115
115
 
116
+ # dart_defines (REQ-DDEF-00040): resolved here — not only in the
117
+ # standalone Fastfile `compile_build` lane — so every caller that
118
+ # compiles via this helper gets it, including upload_to_play_store,
119
+ # upload_to_app_store, and upload_to_cloudflare, which call this
120
+ # method directly and previously never applied dart_defines at all
121
+ # (DEF-059, #59). A caller-supplied additional_parameter is preserved
122
+ # and the resolved flags are appended alongside it as separate argv
123
+ # elements (Fastlane::Actions.sh below is called with a splatted
124
+ # array, so each flag must be its own element, not one combined
125
+ # string). additional_parameter may already be an Array here (the web
126
+ # branch above turns it into one) or a String.
127
+ dart_defines_flags = @specLoader.get_dart_defines_flags(@platform, flavor, build_config)
128
+ unless dart_defines_flags.empty?
129
+ additional_parameter = Array(additional_parameter).flatten.reject { |p| p.to_s.empty? } + dart_defines_flags
130
+ end
131
+
116
132
  # Base command parts
117
133
  command_parts = [
118
134
  "flutter", "build",
@@ -329,5 +329,27 @@ module FastlaneFlutterFlavor
329
329
  resolved = resolve(platform, flavor_name, build_config)
330
330
  resolved&.effective_auth&.reversed_client_id
331
331
  end
332
+
333
+ # Merges common + compile-scoped dart_defines (compile wins on collision,
334
+ # per REQ-DDEF-00015) into individual --dart-define=KEY=VALUE flags for
335
+ # `flutter build`. Returns [] when there's nothing to emit. Returned as
336
+ # separate array elements (not a single joined string) so callers can pass
337
+ # them straight through to Fastlane::Actions.sh's argv-array form without
338
+ # each flag being treated as one combined shell argument.
339
+ #
340
+ # REQ-DDEF-00040's default-only scoping decision applies here too:
341
+ # `resolve` folds flavor-level overrides in via the core's 4-level cascade,
342
+ # so a flavor with its own dart_defines still resolves correctly per call —
343
+ # unlike the (now-removed) generated-Fastfile-lane version, which could
344
+ # only represent default-level dart_defines since it was a single static
345
+ # lane shared across every flavor.
346
+ def get_dart_defines_flags(platform, flavor_name, build_type = 'release')
347
+ resolved = resolve(platform, flavor_name, build_type)
348
+ defines = resolved&.effective_dart_defines
349
+ return [] unless defines
350
+
351
+ merged = (defines.common || {}).merge(defines.compile || {})
352
+ merged.map { |k, v| "--dart-define=#{k}=#{v}" }
353
+ end
332
354
  end
333
355
  end
@@ -0,0 +1,37 @@
1
+ require_relative 'resolver'
2
+
3
+ module AnnFlavorCore
4
+ # Shared iOS bundle-ID / Firebase-path resolution — used by both
5
+ # ann-flavor-cocoapods and ann-flavor-fastlane so the two plugins compute
6
+ # identical effective values instead of independently re-deriving them
7
+ # (plan-030 Phase 6 / REQ-CDEL-00070).
8
+ module IosResolution
9
+ # Effective bundle ID for a flavor at a specific build type. Always applies
10
+ # the full cascade (default → flavor → build_type suffix) — there is no
11
+ # build-type-less variant; callers must pass the actual build type they are
12
+ # assembling for.
13
+ #
14
+ # An unknown flavor key falls back to the unsuffixed default id (matches
15
+ # pre-consolidation behavior — never raises for a missing flavor).
16
+ def self.bundle_id(ios_platform, flavor_key, build_type)
17
+ flavor = ios_platform.flavors[flavor_key.to_s] || AnnFlavorCore::IosFlavor.defaults
18
+ Resolver.resolve_ios_flavor(flavor, ios_platform.default, build_type.to_s).effective_id
19
+ end
20
+
21
+ # Effective Firebase config file path for a flavor at a specific build
22
+ # type. Full 4-level cascade via the core resolver (config_file,
23
+ # project_id, service_account, target). When only project_id is set (no
24
+ # config_file), synthesizes the stable generated-plist path convention:
25
+ # #{generated_dir}/GoogleService-Info-#{flavor_key}-#{build_type}.plist
26
+ #
27
+ # Returns nil when neither config_file nor project_id is set.
28
+ def self.firebase_config_path(ios_platform, flavor_key, build_type, generated_dir: 'lib/generated/firebase')
29
+ flavor = ios_platform.flavors[flavor_key.to_s] || AnnFlavorCore::IosFlavor.defaults
30
+ resolved = Resolver.resolve_ios_flavor(flavor, ios_platform.default, build_type.to_s)
31
+ fb = resolved.effective_firebase
32
+ return nil if fb.nil?
33
+
34
+ fb.config_file || (fb.project_id && "#{generated_dir}/GoogleService-Info-#{flavor_key}-#{build_type}.plist")
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,196 @@
1
+ module AnnFlavorCore
2
+ # ── dart_defines ────────────────────────────────────────────────────────────
3
+ #
4
+ # compile/run/deploy are reserved action-scope sub-keys (REQ-DDEF-00015).
5
+ # +common+ holds keys declared directly under dart_defines — they apply to every
6
+ # action. Each action Hash is resolved as an addition on top of +common+, never a
7
+ # replacement.
8
+ DartDefines = Struct.new(:common, :compile, :run, :deploy, keyword_init: true) do
9
+ def self.defaults = new(common: {}, compile: {}, run: {}, deploy: {})
10
+ end
11
+
12
+ # ── Top-level ───────────────────────────────────────────────────────────────
13
+
14
+ AnnSpec = Struct.new(:app, :enabled, :debug_config, :tooling, keyword_init: true) do
15
+ def self.defaults = new(app: AnnApp.new, enabled: true, debug_config: DebugConfig.new)
16
+ end
17
+
18
+ AnnApp = Struct.new(:android, :ios, :web, :windows, :general, :integrations, keyword_init: true)
19
+
20
+ # ── Tooling (REQ-TOOL-00010 — top-level, non-cascading) ──────────────────────
21
+
22
+ ToolingConfig = Struct.new(:gradle_plugin, :cocoapods_plugin, :fastlane_plugin, keyword_init: true)
23
+
24
+ # ── Integrations ─────────────────────────────────────────────────────────────
25
+
26
+ AnnIntegrations = Struct.new(:fastlane, :melos, :firebase, keyword_init: true) do
27
+ def self.defaults = new(fastlane: false, melos: false, firebase: false)
28
+ end
29
+
30
+ # ── Android ─────────────────────────────────────────────────────────────────
31
+
32
+ AndroidPlatform = Struct.new(:default, :flavors, keyword_init: true) do
33
+ def self.defaults = new(default: AndroidDefault.new, flavors: {})
34
+ end
35
+
36
+ AndroidDefault = Struct.new(
37
+ :id, :name, :version_name, :version_code, :main_file,
38
+ :admob, :icon, :sdk, :credentials, :firebase, :build_types, :custom, :dart_defines,
39
+ keyword_init: true
40
+ ) do
41
+ def self.defaults = new(build_types: {}, custom: {}, dart_defines: DartDefines.defaults)
42
+ end
43
+
44
+ AndroidFlavor = Struct.new(
45
+ :id, :id_suffix, :name, :name_suffix, :version_name, :version_code,
46
+ :main_file, :admob, :icon, :firebase, :stores, :credentials, :build_types, :custom, :dart_defines,
47
+ keyword_init: true
48
+ ) do
49
+ def self.defaults = new(id_suffix: '', name_suffix: '', build_types: {}, custom: {}, dart_defines: DartDefines.defaults)
50
+ end
51
+
52
+ AndroidSdk = Struct.new(:min_sdk, :compile_sdk, :target_sdk, keyword_init: true)
53
+
54
+ AndroidCredentials = Struct.new(:signing, :google_play, :samsung_galaxy, :amazon, keyword_init: true)
55
+ AndroidSigning = Struct.new(:key_file, keyword_init: true)
56
+ GooglePlayCredentials = Struct.new(:api_key, keyword_init: true)
57
+ SamsungGalaxyCredentials = Struct.new(:seller_id, :api_key, keyword_init: true)
58
+ AmazonCredentials = Struct.new(:client_id, :client_secret, keyword_init: true)
59
+
60
+ # ── iOS ─────────────────────────────────────────────────────────────────────
61
+
62
+ IosPlatform = Struct.new(:default, :flavors, keyword_init: true) do
63
+ def self.defaults = new(default: IosDefault.new, flavors: {})
64
+ end
65
+
66
+ IosDefault = Struct.new(
67
+ :id, :name, :version_name, :version_code, :main_file,
68
+ :admob, :icon, :credentials, :firebase, :build_types, :custom, :dart_defines,
69
+ keyword_init: true
70
+ ) do
71
+ def self.defaults = new(build_types: {}, custom: {}, dart_defines: DartDefines.defaults)
72
+ end
73
+
74
+ IosFlavor = Struct.new(
75
+ :id, :id_suffix, :name, :name_suffix, :version_name, :version_code,
76
+ :main_file, :admob, :icon, :firebase, :stores, :credentials, :build_types, :custom, :dart_defines,
77
+ keyword_init: true
78
+ ) do
79
+ def self.defaults = new(id_suffix: '', name_suffix: '', build_types: {}, custom: {}, dart_defines: DartDefines.defaults)
80
+ end
81
+
82
+ IosCredentials = Struct.new(:signing, :app_store, keyword_init: true)
83
+ IosSigning = Struct.new(:team_id, keyword_init: true)
84
+ AppStoreCredentials = Struct.new(
85
+ :api_key, :export_options_plist, :export_options_team_id,
86
+ :export_options_signing_certificate,
87
+ keyword_init: true
88
+ )
89
+
90
+ # ── Web ─────────────────────────────────────────────────────────────────────
91
+
92
+ WebPlatform = Struct.new(:default, :flavors, keyword_init: true) do
93
+ def self.defaults = new(default: WebDefault.new, flavors: {})
94
+ end
95
+
96
+ CloudflareConfig = Struct.new(:project_name, :account_id, :branch, :mode, keyword_init: true)
97
+
98
+ WebDefault = Struct.new(
99
+ :id, :name, :version_name, :version_code, :main_file, :icon, :cloudflare, :firebase, :build_types, :custom, :dart_defines,
100
+ keyword_init: true
101
+ ) do
102
+ def self.defaults = new(build_types: {}, custom: {}, dart_defines: DartDefines.defaults)
103
+ end
104
+
105
+ WebFlavor = Struct.new(
106
+ :id, :id_suffix, :name, :name_suffix, :version_name, :version_code,
107
+ :main_file, :icon, :cloudflare, :firebase, :build_types, :custom, :dart_defines,
108
+ keyword_init: true
109
+ ) do
110
+ def self.defaults = new(id_suffix: '', name_suffix: '', build_types: {}, custom: {}, dart_defines: DartDefines.defaults)
111
+ end
112
+
113
+ # ── Windows ─────────────────────────────────────────────────────────────────
114
+
115
+ WindowsPlatform = Struct.new(:default, :flavors, keyword_init: true) do
116
+ def self.defaults = new(default: WindowsDefault.new, flavors: {})
117
+ end
118
+
119
+ WindowsDefault = Struct.new(
120
+ :id, :name, :version_name, :version_code, :main_file, :firebase, :build_types, :custom, :dart_defines,
121
+ keyword_init: true
122
+ ) do
123
+ def self.defaults = new(build_types: {}, custom: {}, dart_defines: DartDefines.defaults)
124
+ end
125
+
126
+ WindowsFlavor = Struct.new(
127
+ :id, :id_suffix, :name, :name_suffix, :version_name, :version_code,
128
+ :main_file, :firebase, :build_types, :custom, :dart_defines,
129
+ keyword_init: true
130
+ ) do
131
+ def self.defaults = new(id_suffix: '', name_suffix: '', build_types: {}, custom: {}, dart_defines: DartDefines.defaults)
132
+ end
133
+
134
+ # ── Shared types ─────────────────────────────────────────────────────────────
135
+
136
+ AdmobConfig = Struct.new(:gms_ads_id, keyword_init: true)
137
+
138
+ BuildTypeConfig = Struct.new(
139
+ :id_suffix, :name_suffix,
140
+ :firebase, :auth, :admob,
141
+ :minify_enabled, :shrink_resources, :lint_check_release_builds,
142
+ :ndk_version, :ndk_debug_symbol_level, :ndk_abi_filters, :custom, :dart_defines,
143
+ keyword_init: true
144
+ ) do
145
+ def self.defaults = new(id_suffix: '', name_suffix: '', ndk_abi_filters: [], custom: {}, dart_defines: DartDefines.defaults)
146
+ end
147
+
148
+ FirebaseConfig = Struct.new(:project_id, :config_file, :service_account, :target, keyword_init: true)
149
+
150
+ AuthConfig = Struct.new(:client_id, :reversed_client_id, :others, keyword_init: true) do
151
+ def self.defaults = new(others: {})
152
+ end
153
+
154
+ FlavorStores = Struct.new(:google_play, :samsung_galaxy, :amazon, :app_store, keyword_init: true)
155
+ GooglePlayFlavorStore = Struct.new(:priority, keyword_init: true)
156
+ SamsungGalaxyFlavorStore = Struct.new(:app_id, keyword_init: true)
157
+ AmazonFlavorStore = Struct.new(:app_id, keyword_init: true)
158
+ AppStoreFlavorStore = Struct.new(:apple_id, :team_id, keyword_init: true)
159
+
160
+ class GeneralConfig
161
+ def self.defaults = new
162
+ end
163
+
164
+ DebugConfig = Struct.new(
165
+ :print_debug, :print_build_and_flavor_info, :print_sdk_versions, :print_release_build_type_info,
166
+ keyword_init: true
167
+ ) do
168
+ def self.defaults
169
+ new(
170
+ print_debug: false,
171
+ print_build_and_flavor_info: true,
172
+ print_sdk_versions: true,
173
+ print_release_build_type_info: false
174
+ )
175
+ end
176
+ end
177
+
178
+ # ── Resolved output types ────────────────────────────────────────────────────
179
+
180
+ ResolvedSpec = Struct.new(:android, :ios, :web, :windows, keyword_init: true)
181
+ ResolvedFlavor = Struct.new(:flavor_name, :by_build_type, keyword_init: true)
182
+
183
+ ResolvedBuildOutput = Struct.new(
184
+ :flavor_name, :build_type,
185
+ :effective_id, :effective_name,
186
+ :effective_version_name, :effective_version_code,
187
+ :effective_main_file,
188
+ :effective_firebase, :effective_auth,
189
+ :effective_gms_ads_id,
190
+ :effective_icon,
191
+ :effective_cloudflare,
192
+ :effective_custom,
193
+ :effective_dart_defines,
194
+ keyword_init: true
195
+ )
196
+ end
@@ -0,0 +1,378 @@
1
+ require 'yaml'
2
+ require_relative 'model'
3
+
4
+ module AnnFlavorCore
5
+ #
6
+ # Single source of truth for parsing annspec.yaml.
7
+ #
8
+ # Two entry points:
9
+ # AnnFlavorCore::Parser.parse(yaml_string) — from raw YAML text
10
+ # AnnFlavorCore::Parser.parse_file(path) — from a file path
11
+ # AnnFlavorCore::Parser.from_hash(hash) — from a pre-parsed Hash
12
+ # (e.g. in-memory UI data)
13
+ #
14
+ # After parsing, pass the AnnSpec to AnnFlavorCore::Resolver.resolve_all
15
+ # to obtain all resolved (effective) values per flavor × build-type.
16
+ #
17
+ module Parser
18
+ def self.parse(yaml_string)
19
+ raw = YAML.safe_load(yaml_string, aliases: true) || {}
20
+ from_hash(raw)
21
+ end
22
+
23
+ def self.parse_file(path)
24
+ parse(File.read(path))
25
+ end
26
+
27
+ def self.from_hash(raw)
28
+ app_hash = h(raw, 'app')
29
+ tooling_hash = h(raw, 'tooling')
30
+ AnnSpec.new(
31
+ app: parse_ann_app(app_hash),
32
+ enabled: raw.fetch('enabled', true),
33
+ debug_config: parse_debug_config(h(raw, 'debug')),
34
+ tooling: (parse_tooling(tooling_hash) unless tooling_hash.empty?),
35
+ )
36
+ end
37
+
38
+ def self.parse_custom_config(m)
39
+ return {} unless m.is_a?(Hash)
40
+ m.transform_values do |group_val|
41
+ next {} unless group_val.is_a?(Hash)
42
+ group_val.transform_values { |v| normalize_custom_value(v) }
43
+ end
44
+ end
45
+
46
+ def self.normalize_custom_value(v)
47
+ return v.map { |e| e.nil? ? '' : e.to_s } if v.is_a?(Array)
48
+ v
49
+ end
50
+
51
+ def self.parse_tooling(m)
52
+ ToolingConfig.new(
53
+ gradle_plugin: m['gradle_plugin'],
54
+ cocoapods_plugin: m['cocoapods_plugin'],
55
+ fastlane_plugin: m['fastlane_plugin'],
56
+ )
57
+ end
58
+
59
+ # ── AnnApp ────────────────────────────────────────────────────────────────
60
+
61
+ def self.parse_ann_app(m)
62
+ AnnApp.new(
63
+ android: (parse_android(h(m, 'android')) unless h(m, 'android').empty?),
64
+ ios: (parse_ios(h(m, 'ios')) unless h(m, 'ios').empty?),
65
+ web: (parse_web(h(m, 'web')) unless h(m, 'web').empty?),
66
+ windows: (parse_windows(h(m, 'windows')) unless h(m, 'windows').empty?),
67
+ general: (parse_general(h(m, 'general')) unless h(m, 'general').empty?),
68
+ integrations: (parse_integrations(h(m, 'integrations')) unless h(m, 'integrations').empty?),
69
+ )
70
+ end
71
+
72
+ def self.parse_integrations(m)
73
+ AnnIntegrations.new(
74
+ fastlane: m.fetch('fastlane', false),
75
+ melos: m.fetch('melos', false),
76
+ firebase: m.fetch('firebase', false),
77
+ )
78
+ end
79
+
80
+ # ── Android ───────────────────────────────────────────────────────────────
81
+
82
+ def self.parse_android(m)
83
+ AndroidPlatform.new(
84
+ default: parse_android_default(h(m, 'default')),
85
+ flavors: parse_flavors(h(m, 'flavor')) { |v| parse_android_flavor(v) },
86
+ )
87
+ end
88
+
89
+ def self.parse_android_default(m)
90
+ AndroidDefault.new(
91
+ id: m['id'],
92
+ name: m['name'],
93
+ version_name: m['version_name']&.to_s,
94
+ version_code: m['version_code']&.to_i,
95
+ main_file: m['main_file'],
96
+ admob: (AdmobConfig.new(gms_ads_id: h(m, 'admob')['gms_ads_id'] || m['gms_ads_id']) unless (h(m, 'admob')['gms_ads_id'] || m['gms_ads_id']).nil?),
97
+ icon: m['icon'],
98
+ sdk: (parse_android_sdk(h(m, 'sdk')) unless h(m, 'sdk').empty?),
99
+ credentials: (parse_android_credentials(h(m, 'credentials')) unless h(m, 'credentials').empty?),
100
+ firebase: (parse_firebase_config(h(m, 'firebase')) unless h(m, 'firebase').empty?),
101
+ build_types: parse_build_type_configs(h(m, 'build_types')),
102
+ custom: parse_custom_config(h(m, 'custom')),
103
+ dart_defines: parse_dart_defines(h(m, 'dart_defines')),
104
+ )
105
+ end
106
+
107
+ def self.parse_android_flavor(m)
108
+ AndroidFlavor.new(
109
+ id: m['id'],
110
+ id_suffix: m.fetch('id_suffix', '').to_s,
111
+ name: m['name'],
112
+ name_suffix: m.fetch('name_suffix', '').to_s,
113
+ version_name: m['version_name']&.to_s,
114
+ version_code: m['version_code']&.to_i,
115
+ main_file: m['main_file'],
116
+ admob: (AdmobConfig.new(gms_ads_id: h(m, 'admob')['gms_ads_id'] || m['gms_ads_id']) unless (h(m, 'admob')['gms_ads_id'] || m['gms_ads_id']).nil?),
117
+ icon: m['icon'],
118
+ firebase: (parse_firebase_config(h(m, 'firebase')) unless h(m, 'firebase').empty?),
119
+ stores: (parse_flavor_stores(h(m, 'stores')) unless h(m, 'stores').empty?),
120
+ credentials: (parse_android_credentials(h(m, 'credentials')) unless h(m, 'credentials').empty?),
121
+ build_types: parse_build_type_configs(h(m, 'build_types')),
122
+ custom: parse_custom_config(h(m, 'custom')),
123
+ dart_defines: parse_dart_defines(h(m, 'dart_defines')),
124
+ )
125
+ end
126
+
127
+ def self.parse_android_sdk(m)
128
+ AndroidSdk.new(min_sdk: m['minSdk'], compile_sdk: m['compileSdk'], target_sdk: m['targetSdk'])
129
+ end
130
+
131
+ def self.parse_android_credentials(m)
132
+ AndroidCredentials.new(
133
+ signing: (AndroidSigning.new(key_file: h(m, 'signing')['key_file']) unless h(m, 'signing').empty?),
134
+ google_play: (GooglePlayCredentials.new(api_key: h(m, 'google_play')['api_key']) unless h(m, 'google_play').empty?),
135
+ samsung_galaxy: (SamsungGalaxyCredentials.new(seller_id: h(m, 'samsung_galaxy')['seller_id'],
136
+ api_key: h(m, 'samsung_galaxy')['api_key']) unless h(m, 'samsung_galaxy').empty?),
137
+ amazon: (AmazonCredentials.new(client_id: h(m, 'amazon')['client_id'],
138
+ client_secret: h(m, 'amazon')['client_secret']) unless h(m, 'amazon').empty?),
139
+ )
140
+ end
141
+
142
+ # ── iOS ───────────────────────────────────────────────────────────────────
143
+
144
+ def self.parse_ios(m)
145
+ IosPlatform.new(
146
+ default: parse_ios_default(h(m, 'default')),
147
+ flavors: parse_flavors(h(m, 'flavor')) { |v| parse_ios_flavor(v) },
148
+ )
149
+ end
150
+
151
+ def self.parse_ios_default(m)
152
+ IosDefault.new(
153
+ id: m['id'],
154
+ name: m['name'],
155
+ version_name: m['version_name']&.to_s,
156
+ version_code: m['version_code']&.to_i,
157
+ main_file: m['main_file'],
158
+ admob: (AdmobConfig.new(gms_ads_id: h(m, 'admob')['gms_ads_id'] || m['gms_ads_id']) unless (h(m, 'admob')['gms_ads_id'] || m['gms_ads_id']).nil?),
159
+ icon: m['icon'],
160
+ credentials: (parse_ios_credentials(h(m, 'credentials')) unless h(m, 'credentials').empty?),
161
+ firebase: (parse_firebase_config(h(m, 'firebase')) unless h(m, 'firebase').empty?),
162
+ build_types: parse_build_type_configs(h(m, 'build_types')),
163
+ custom: parse_custom_config(h(m, 'custom')),
164
+ dart_defines: parse_dart_defines(h(m, 'dart_defines')),
165
+ )
166
+ end
167
+
168
+ def self.parse_ios_flavor(m)
169
+ IosFlavor.new(
170
+ id: m['id'],
171
+ id_suffix: m.fetch('id_suffix', '').to_s,
172
+ name: m['name'],
173
+ name_suffix: m.fetch('name_suffix', '').to_s,
174
+ version_name: m['version_name']&.to_s,
175
+ version_code: m['version_code']&.to_i,
176
+ main_file: m['main_file'],
177
+ admob: (AdmobConfig.new(gms_ads_id: h(m, 'admob')['gms_ads_id'] || m['gms_ads_id']) unless (h(m, 'admob')['gms_ads_id'] || m['gms_ads_id']).nil?),
178
+ icon: m['icon'],
179
+ firebase: (parse_firebase_config(h(m, 'firebase')) unless h(m, 'firebase').empty?),
180
+ stores: (parse_flavor_stores(h(m, 'stores')) unless h(m, 'stores').empty?),
181
+ credentials: (parse_ios_credentials(h(m, 'credentials')) unless h(m, 'credentials').empty?),
182
+ build_types: parse_build_type_configs(h(m, 'build_types')),
183
+ custom: parse_custom_config(h(m, 'custom')),
184
+ dart_defines: parse_dart_defines(h(m, 'dart_defines')),
185
+ )
186
+ end
187
+
188
+ def self.parse_ios_credentials(m)
189
+ IosCredentials.new(
190
+ signing: (IosSigning.new(team_id: h(m, 'signing')['team_id']) unless h(m, 'signing').empty?),
191
+ app_store: (AppStoreCredentials.new(
192
+ api_key: h(m, 'app_store')['api_key'],
193
+ export_options_plist: h(m, 'app_store')['export_options_plist'],
194
+ export_options_team_id: h(m, 'app_store')['export_options_team_id'],
195
+ export_options_signing_certificate: h(m, 'app_store')['export_options_signing_certificate'],
196
+ ) unless h(m, 'app_store').empty?),
197
+ )
198
+ end
199
+
200
+ # ── Web ───────────────────────────────────────────────────────────────────
201
+
202
+ def self.parse_web(m)
203
+ WebPlatform.new(
204
+ default: parse_web_default(h(m, 'default')),
205
+ flavors: parse_flavors(h(m, 'flavor')) { |v| parse_web_flavor(v) },
206
+ )
207
+ end
208
+
209
+ def self.parse_web_default(m)
210
+ WebDefault.new(
211
+ id: m['id'], name: m['name'],
212
+ version_name: m['version_name']&.to_s, version_code: m['version_code']&.to_i,
213
+ main_file: m['main_file'], icon: m['icon'],
214
+ cloudflare: parse_cloudflare(h(m, 'cloudflare')),
215
+ firebase: (parse_firebase_config(h(m, 'firebase')) unless h(m, 'firebase').empty?),
216
+ build_types: parse_build_type_configs(h(m, 'build_types')),
217
+ custom: parse_custom_config(h(m, 'custom')),
218
+ dart_defines: parse_dart_defines(h(m, 'dart_defines')),
219
+ )
220
+ end
221
+
222
+ def self.parse_web_flavor(m)
223
+ WebFlavor.new(
224
+ id: m['id'], id_suffix: m.fetch('id_suffix', '').to_s,
225
+ name: m['name'], name_suffix: m.fetch('name_suffix', '').to_s,
226
+ version_name: m['version_name']&.to_s, version_code: m['version_code']&.to_i,
227
+ main_file: m['main_file'], icon: m['icon'],
228
+ cloudflare: parse_cloudflare(h(m, 'cloudflare')),
229
+ firebase: (parse_firebase_config(h(m, 'firebase')) unless h(m, 'firebase').empty?),
230
+ build_types: parse_build_type_configs(h(m, 'build_types')),
231
+ custom: parse_custom_config(h(m, 'custom')),
232
+ dart_defines: parse_dart_defines(h(m, 'dart_defines')),
233
+ )
234
+ end
235
+
236
+ def self.parse_cloudflare(m)
237
+ return nil if m.empty?
238
+ CloudflareConfig.new(
239
+ project_name: m['project_name'],
240
+ account_id: m['account_id'],
241
+ branch: m['branch'],
242
+ mode: m['mode'],
243
+ )
244
+ end
245
+
246
+ # ── Windows ───────────────────────────────────────────────────────────────
247
+
248
+ def self.parse_windows(m)
249
+ WindowsPlatform.new(
250
+ default: parse_windows_default(h(m, 'default')),
251
+ flavors: parse_flavors(h(m, 'flavor')) { |v| parse_windows_flavor(v) },
252
+ )
253
+ end
254
+
255
+ def self.parse_windows_default(m)
256
+ WindowsDefault.new(
257
+ id: m['id'], name: m['name'],
258
+ version_name: m['version_name']&.to_s, version_code: m['version_code']&.to_i,
259
+ main_file: m['main_file'],
260
+ firebase: (parse_firebase_config(h(m, 'firebase')) unless h(m, 'firebase').empty?),
261
+ build_types: parse_build_type_configs(h(m, 'build_types')),
262
+ custom: parse_custom_config(h(m, 'custom')),
263
+ dart_defines: parse_dart_defines(h(m, 'dart_defines')),
264
+ )
265
+ end
266
+
267
+ def self.parse_windows_flavor(m)
268
+ WindowsFlavor.new(
269
+ id: m['id'], id_suffix: m.fetch('id_suffix', '').to_s,
270
+ name: m['name'], name_suffix: m.fetch('name_suffix', '').to_s,
271
+ version_name: m['version_name']&.to_s, version_code: m['version_code']&.to_i,
272
+ main_file: m['main_file'],
273
+ firebase: (parse_firebase_config(h(m, 'firebase')) unless h(m, 'firebase').empty?),
274
+ build_types: parse_build_type_configs(h(m, 'build_types')),
275
+ custom: parse_custom_config(h(m, 'custom')),
276
+ dart_defines: parse_dart_defines(h(m, 'dart_defines')),
277
+ )
278
+ end
279
+
280
+ # ── Shared parsers ────────────────────────────────────────────────────────
281
+
282
+ def self.parse_firebase_config(m)
283
+ FirebaseConfig.new(
284
+ project_id: m['project_id'],
285
+ config_file: m['config_file'],
286
+ service_account: m['service_account'],
287
+ target: m['target'],
288
+ )
289
+ end
290
+
291
+ def self.parse_auth_config(m)
292
+ AuthConfig.new(
293
+ client_id: m['clientId'],
294
+ reversed_client_id: m['reversedClientId'],
295
+ others: h(m, 'others'),
296
+ )
297
+ end
298
+
299
+ def self.parse_build_type_configs(m)
300
+ m.transform_values do |v|
301
+ v = v.is_a?(Hash) ? v : {}
302
+ BuildTypeConfig.new(
303
+ id_suffix: v.fetch('id_suffix', '').to_s,
304
+ name_suffix: v.fetch('name_suffix', '').to_s,
305
+ firebase: (parse_firebase_config(h(v, 'firebase')) unless h(v, 'firebase').empty?),
306
+ auth: (parse_auth_config(h(v, 'auth')) unless h(v, 'auth').empty?),
307
+ admob: (AdmobConfig.new(gms_ads_id: h(v, 'admob')['gms_ads_id']) unless h(v, 'admob').empty?),
308
+ minify_enabled: v['minifyEnabled'],
309
+ shrink_resources: v['shrinkResources'],
310
+ lint_check_release_builds: v['lintCheckReleaseBuilds'],
311
+ ndk_version: v['ndkVersion'],
312
+ ndk_debug_symbol_level: v['ndkDebugSymbolLevel'],
313
+ ndk_abi_filters: (v['ndkAbiFilters'].is_a?(Array) ? v['ndkAbiFilters'] : []),
314
+ custom: parse_custom_config(h(v, 'custom')),
315
+ dart_defines: parse_dart_defines(h(v, 'dart_defines')),
316
+ )
317
+ end
318
+ end
319
+
320
+ def self.parse_flavor_stores(m)
321
+ FlavorStores.new(
322
+ google_play: (GooglePlayFlavorStore.new(priority: h(m, 'google_play')['priority']) unless h(m, 'google_play').empty?),
323
+ samsung_galaxy: (SamsungGalaxyFlavorStore.new(app_id: h(m, 'samsung_galaxy')['app_id']) unless h(m, 'samsung_galaxy').empty?),
324
+ amazon: (AmazonFlavorStore.new(app_id: h(m, 'amazon')['app_id']) unless h(m, 'amazon').empty?),
325
+ app_store: (AppStoreFlavorStore.new(apple_id: h(m, 'app_store')['apple_id'],
326
+ team_id: h(m, 'app_store')['team_id']) unless h(m, 'app_store').empty?),
327
+ )
328
+ end
329
+
330
+ def self.parse_general(_m)
331
+ GeneralConfig.new
332
+ end
333
+
334
+ def self.parse_debug_config(m)
335
+ DebugConfig.new(
336
+ print_debug: m.fetch('printDebug', false),
337
+ print_build_and_flavor_info: m.fetch('printBuildAndFlavorInfo', true),
338
+ print_sdk_versions: m.fetch('printSdkVersions', true),
339
+ print_release_build_type_info: m.fetch('printReleaseBuildTypeInfo', false),
340
+ )
341
+ end
342
+
343
+ def self.parse_flavors(flavor_map, &parse_fn)
344
+ return {} unless flavor_map.is_a?(Hash)
345
+ flavor_map.transform_values { |v| parse_fn.call(v.is_a?(Hash) ? v : {}) }
346
+ end
347
+
348
+ # Safe Hash accessor — returns {} when key is missing or value is not a Hash
349
+ def self.h(map, key)
350
+ val = map.is_a?(Hash) ? map[key] : nil
351
+ val.is_a?(Hash) ? val : {}
352
+ end
353
+
354
+ DART_DEFINES_ACTION_KEYS = %w[compile run deploy].freeze
355
+
356
+ # Parses dart_defines: — plain string entries are the common map; the three
357
+ # reserved sub-keys compile/run/deploy (each a nested map) become the
358
+ # corresponding action scope. Values are coerced to strings.
359
+ def self.parse_dart_defines(m)
360
+ return DartDefines.defaults unless m.is_a?(Hash)
361
+
362
+ string_map = ->(raw) {
363
+ return {} unless raw.is_a?(Hash)
364
+ raw.transform_values { |v| v.nil? ? '' : v.to_s }
365
+ }
366
+
367
+ common = m.reject { |k, _| DART_DEFINES_ACTION_KEYS.include?(k) }
368
+ .transform_values { |v| v.nil? ? '' : v.to_s }
369
+
370
+ DartDefines.new(
371
+ common: common,
372
+ compile: string_map.call(m['compile']),
373
+ run: string_map.call(m['run']),
374
+ deploy: string_map.call(m['deploy']),
375
+ )
376
+ end
377
+ end
378
+ end
@@ -0,0 +1,266 @@
1
+ require_relative 'model'
2
+
3
+ module AnnFlavorCore
4
+ module Resolver
5
+ STANDARD_BUILD_TYPES = %w[debug release profile].freeze
6
+
7
+ # ── Low-level cascade helpers (used by all plugins) ───────────────────────
8
+
9
+ def self.effective_build_type_suffix(flavor_suffix, default_suffix)
10
+ flavor_suffix.to_s.empty? ? default_suffix.to_s : flavor_suffix.to_s
11
+ end
12
+
13
+ def self.resolve_id(base_id, id_suffix, build_type_id_suffix = '')
14
+ base_id.to_s + id_suffix.to_s + build_type_id_suffix.to_s
15
+ end
16
+
17
+ def self.resolve_name(base_name, name_suffix, build_type_name_suffix = '')
18
+ base_name.to_s + name_suffix.to_s + build_type_name_suffix.to_s
19
+ end
20
+
21
+ # ── High-level resolver ───────────────────────────────────────────────────
22
+ #
23
+ # Usage patterns:
24
+ #
25
+ # # From YAML (batch — all platforms, all build types)
26
+ # spec = AnnFlavorCore::Parser.parse_file('annspec.yaml')
27
+ # resolved = AnnFlavorCore::Resolver.resolve_all(spec)
28
+ #
29
+ # # From in-memory model (e.g. live UI data before saving to YAML)
30
+ # output = AnnFlavorCore::Resolver.resolve_android_flavor(flavor, defaults, 'debug')
31
+
32
+ def self.resolve_all(spec, build_types: STANDARD_BUILD_TYPES)
33
+ ResolvedSpec.new(
34
+ android: spec.app&.android ? resolve_android(spec.app.android, build_types: build_types) : {},
35
+ ios: spec.app&.ios ? resolve_ios(spec.app.ios, build_types: build_types) : {},
36
+ web: spec.app&.web ? resolve_web(spec.app.web, build_types: build_types) : {},
37
+ windows: spec.app&.windows ? resolve_windows(spec.app.windows, build_types: build_types) : {},
38
+ )
39
+ end
40
+
41
+ def self.resolve_android(platform, build_types: STANDARD_BUILD_TYPES)
42
+ platform.flavors.each_with_object({}) do |(name, flavor), result|
43
+ result[name] = ResolvedFlavor.new(
44
+ flavor_name: name,
45
+ by_build_type: build_types.each_with_object({}) { |bt, h| h[bt] = resolve_android_flavor(flavor, platform.default, bt) },
46
+ )
47
+ end
48
+ end
49
+
50
+ def self.resolve_ios(platform, build_types: STANDARD_BUILD_TYPES)
51
+ platform.flavors.each_with_object({}) do |(name, flavor), result|
52
+ result[name] = ResolvedFlavor.new(
53
+ flavor_name: name,
54
+ by_build_type: build_types.each_with_object({}) { |bt, h| h[bt] = resolve_ios_flavor(flavor, platform.default, bt) },
55
+ )
56
+ end
57
+ end
58
+
59
+ def self.resolve_web(platform, build_types: STANDARD_BUILD_TYPES)
60
+ platform.flavors.each_with_object({}) do |(name, flavor), result|
61
+ result[name] = ResolvedFlavor.new(
62
+ flavor_name: name,
63
+ by_build_type: build_types.each_with_object({}) { |bt, h| h[bt] = resolve_web_flavor(flavor, platform.default, bt) },
64
+ )
65
+ end
66
+ end
67
+
68
+ def self.resolve_windows(platform, build_types: STANDARD_BUILD_TYPES)
69
+ platform.flavors.each_with_object({}) do |(name, flavor), result|
70
+ result[name] = ResolvedFlavor.new(
71
+ flavor_name: name,
72
+ by_build_type: build_types.each_with_object({}) { |bt, h| h[bt] = resolve_windows_flavor(flavor, platform.default, bt) },
73
+ )
74
+ end
75
+ end
76
+
77
+ # ── Per-flavor resolvers (callable directly from UI / in-memory data) ─────
78
+
79
+ def self.resolve_android_flavor(flavor, defaults, build_type)
80
+ bt = effective_bt_config(flavor.build_types, defaults.build_types, build_type)
81
+ ResolvedBuildOutput.new(
82
+ flavor_name: nil,
83
+ build_type: build_type,
84
+ effective_id: resolve_id((flavor.id || defaults.id).to_s, flavor.id_suffix.to_s, bt.id_suffix),
85
+ effective_name: resolve_name((flavor.name || defaults.name).to_s, flavor.name_suffix.to_s, bt.name_suffix),
86
+ effective_version_name: (flavor.version_name || defaults.version_name).to_s,
87
+ effective_version_code: flavor.version_code || defaults.version_code,
88
+ effective_main_file: flavor.main_file || defaults.main_file,
89
+ effective_firebase: resolve_firebase(flavor.build_types[build_type]&.firebase, flavor.firebase, defaults.build_types[build_type]&.firebase, defaults.firebase),
90
+ effective_auth: bt.auth,
91
+ effective_gms_ads_id: flavor.admob&.gms_ads_id || defaults.admob&.gms_ads_id,
92
+ effective_icon: flavor.icon || defaults.icon,
93
+ effective_custom: resolve_custom(defaults.custom, defaults.build_types[build_type]&.custom, flavor.custom, flavor.build_types[build_type]&.custom),
94
+ effective_dart_defines: resolve_dart_defines(defaults.dart_defines, defaults.build_types[build_type]&.dart_defines, flavor.dart_defines, flavor.build_types[build_type]&.dart_defines),
95
+ )
96
+ end
97
+
98
+ def self.resolve_ios_flavor(flavor, defaults, build_type)
99
+ bt = effective_bt_config(flavor.build_types, defaults.build_types, build_type)
100
+ ResolvedBuildOutput.new(
101
+ flavor_name: nil,
102
+ build_type: build_type,
103
+ effective_id: resolve_id((flavor.id || defaults.id).to_s, flavor.id_suffix.to_s, bt.id_suffix),
104
+ effective_name: resolve_name((flavor.name || defaults.name).to_s, flavor.name_suffix.to_s, bt.name_suffix),
105
+ effective_version_name: (flavor.version_name || defaults.version_name).to_s,
106
+ effective_version_code: flavor.version_code || defaults.version_code,
107
+ effective_main_file: flavor.main_file || defaults.main_file,
108
+ effective_firebase: resolve_firebase(flavor.build_types[build_type]&.firebase, flavor.firebase, defaults.build_types[build_type]&.firebase, defaults.firebase),
109
+ effective_auth: bt.auth,
110
+ effective_gms_ads_id: flavor.admob&.gms_ads_id || defaults.admob&.gms_ads_id,
111
+ effective_icon: flavor.icon || defaults.icon,
112
+ effective_custom: resolve_custom(defaults.custom, defaults.build_types[build_type]&.custom, flavor.custom, flavor.build_types[build_type]&.custom),
113
+ effective_dart_defines: resolve_dart_defines(defaults.dart_defines, defaults.build_types[build_type]&.dart_defines, flavor.dart_defines, flavor.build_types[build_type]&.dart_defines),
114
+ )
115
+ end
116
+
117
+ def self.resolve_web_flavor(flavor, defaults, build_type)
118
+ bt = effective_bt_config(flavor.build_types, defaults.build_types, build_type)
119
+ ResolvedBuildOutput.new(
120
+ flavor_name: nil,
121
+ build_type: build_type,
122
+ effective_id: resolve_id((flavor.id || defaults.id).to_s, flavor.id_suffix.to_s, bt.id_suffix),
123
+ effective_name: resolve_name((flavor.name || defaults.name).to_s, flavor.name_suffix.to_s, bt.name_suffix),
124
+ effective_version_name: (flavor.version_name || defaults.version_name).to_s,
125
+ effective_version_code: flavor.version_code || defaults.version_code,
126
+ effective_main_file: flavor.main_file || defaults.main_file,
127
+ effective_firebase: resolve_firebase(flavor.build_types[build_type]&.firebase, flavor.firebase, defaults.build_types[build_type]&.firebase, defaults.firebase),
128
+ effective_auth: bt.auth,
129
+ effective_gms_ads_id: nil,
130
+ effective_icon: flavor.icon || defaults.icon,
131
+ effective_cloudflare: resolve_cloudflare(flavor.cloudflare, defaults.cloudflare),
132
+ effective_custom: resolve_custom(defaults.custom, defaults.build_types[build_type]&.custom, flavor.custom, flavor.build_types[build_type]&.custom),
133
+ effective_dart_defines: resolve_dart_defines(defaults.dart_defines, defaults.build_types[build_type]&.dart_defines, flavor.dart_defines, flavor.build_types[build_type]&.dart_defines),
134
+ )
135
+ end
136
+
137
+ def self.resolve_cloudflare(flavor_cf, default_cf)
138
+ CloudflareConfig.new(
139
+ project_name: flavor_cf&.project_name,
140
+ account_id: flavor_cf&.account_id || default_cf&.account_id,
141
+ branch: flavor_cf&.branch || default_cf&.branch || 'main',
142
+ mode: flavor_cf&.mode || default_cf&.mode || 'pages',
143
+ )
144
+ end
145
+
146
+ def self.resolve_windows_flavor(flavor, defaults, build_type)
147
+ bt = effective_bt_config(flavor.build_types, defaults.build_types, build_type)
148
+ ResolvedBuildOutput.new(
149
+ flavor_name: nil,
150
+ build_type: build_type,
151
+ effective_id: resolve_id((flavor.id || defaults.id).to_s, flavor.id_suffix.to_s, bt.id_suffix),
152
+ effective_name: resolve_name((flavor.name || defaults.name).to_s, flavor.name_suffix.to_s, bt.name_suffix),
153
+ effective_version_name: (flavor.version_name || defaults.version_name).to_s,
154
+ effective_version_code: flavor.version_code || defaults.version_code,
155
+ effective_main_file: flavor.main_file || defaults.main_file,
156
+ effective_firebase: resolve_firebase(flavor.build_types[build_type]&.firebase, flavor.firebase, defaults.build_types[build_type]&.firebase, defaults.firebase),
157
+ effective_auth: bt.auth,
158
+ effective_gms_ads_id: nil,
159
+ effective_icon: nil,
160
+ effective_custom: resolve_custom(defaults.custom, defaults.build_types[build_type]&.custom, flavor.custom, flavor.build_types[build_type]&.custom),
161
+ effective_dart_defines: resolve_dart_defines(defaults.dart_defines, defaults.build_types[build_type]&.dart_defines, flavor.dart_defines, flavor.build_types[build_type]&.dart_defines),
162
+ )
163
+ end
164
+
165
+ # ── Internal helpers ──────────────────────────────────────────────────────
166
+
167
+ # Deep-merges custom config — lowest priority first, key-by-key within each group.
168
+ def self.resolve_custom(default_custom, default_bt_custom, flavor_custom, flavor_bt_custom)
169
+ merge = lambda do |base, over|
170
+ next base if over.nil? || over.empty?
171
+ result = base.dup
172
+ over.each { |group, group_map| result[group] = (result[group] || {}).merge(group_map) }
173
+ result
174
+ end
175
+ merge.call(merge.call(merge.call(default_custom || {}, default_bt_custom), flavor_custom), flavor_bt_custom)
176
+ end
177
+
178
+ # Shallow key-union across all four cascade levels — most specific key wins.
179
+ def self.merge_defines_map(default_map, default_bt_map, flavor_map, flavor_bt_map)
180
+ (default_map || {})
181
+ .merge(default_bt_map || {})
182
+ .merge(flavor_map || {})
183
+ .merge(flavor_bt_map || {})
184
+ end
185
+
186
+ # Resolves all four dart_defines sub-maps (common + three action scopes)
187
+ # independently.
188
+ def self.resolve_dart_defines(default_defines, default_bt_defines, flavor_defines, flavor_bt_defines)
189
+ d = default_defines || DartDefines.defaults
190
+ db = default_bt_defines || DartDefines.defaults
191
+ f = flavor_defines || DartDefines.defaults
192
+ fb = flavor_bt_defines || DartDefines.defaults
193
+ DartDefines.new(
194
+ common: merge_defines_map(d.common, db.common, f.common, fb.common),
195
+ compile: merge_defines_map(d.compile, db.compile, f.compile, fb.compile),
196
+ run: merge_defines_map(d.run, db.run, f.run, fb.run),
197
+ deploy: merge_defines_map(d.deploy, db.deploy, f.deploy, fb.deploy),
198
+ )
199
+ end
200
+
201
+ def self.effective_bt_config(flavor_bts, default_bts, build_type)
202
+ f = (flavor_bts || {})[build_type]
203
+ d = (default_bts || {})[build_type]
204
+ BuildTypeConfig.new(
205
+ id_suffix: effective_build_type_suffix(f&.id_suffix, d&.id_suffix),
206
+ name_suffix: effective_build_type_suffix(f&.name_suffix, d&.name_suffix),
207
+ firebase: merge_firebase_config(f&.firebase, d&.firebase),
208
+ auth: merge_auth_config(f&.auth, d&.auth),
209
+ )
210
+ end
211
+
212
+ # Full 4-level cascade for every firebase field (most specific wins):
213
+ # L1: flavor_bt — flavor.build_types[bt].firebase
214
+ # L2: flavor_top — flavor.firebase
215
+ # L3: default_bt — default.build_types[bt].firebase
216
+ # L4: default_top — defaults.firebase
217
+ def self.resolve_firebase(flavor_bt, flavor_top, default_bt, default_top)
218
+ project_id = flavor_bt&.project_id ||
219
+ flavor_top&.project_id ||
220
+ default_bt&.project_id ||
221
+ default_top&.project_id
222
+ config_file = flavor_bt&.config_file ||
223
+ flavor_top&.config_file ||
224
+ default_bt&.config_file ||
225
+ default_top&.config_file
226
+ sa = flavor_bt&.service_account ||
227
+ flavor_top&.service_account ||
228
+ default_bt&.service_account ||
229
+ default_top&.service_account
230
+ target = flavor_bt&.target ||
231
+ flavor_top&.target ||
232
+ default_bt&.target ||
233
+ default_top&.target
234
+ return nil if project_id.nil? && config_file.nil? && sa.nil? && target.nil?
235
+ FirebaseConfig.new(
236
+ project_id: project_id,
237
+ config_file: config_file,
238
+ service_account: sa,
239
+ target: target,
240
+ )
241
+ end
242
+
243
+ def self.merge_firebase_config(flavor, default)
244
+ return default if flavor.nil?
245
+ return flavor if default.nil?
246
+ # service_account cascades independently — a flavor can override project_id without having to
247
+ # repeat the service_account key, and vice versa.
248
+ FirebaseConfig.new(
249
+ project_id: flavor.project_id || default.project_id,
250
+ config_file: flavor.config_file || default.config_file,
251
+ service_account: flavor.service_account || default.service_account,
252
+ target: flavor.target || default.target,
253
+ )
254
+ end
255
+
256
+ def self.merge_auth_config(flavor, default)
257
+ return default if flavor.nil?
258
+ return flavor if default.nil?
259
+ AuthConfig.new(
260
+ client_id: flavor.client_id || default.client_id,
261
+ reversed_client_id: flavor.reversed_client_id || default.reversed_client_id,
262
+ others: (default.others || {}).merge(flavor.others || {}),
263
+ )
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,136 @@
1
+ require_relative 'model'
2
+
3
+ module AnnFlavorCore
4
+ #
5
+ # Opt-in unknown-key detection for annspec.yaml.
6
+ #
7
+ # Per-level VALID KEYS are derived automatically from each Struct's +members+ (Ruby's
8
+ # built-in reflection for Struct.new(...)-defined classes) -- this part can never
9
+ # drift out of sync with the model, the same way Kotlin's reflection-based checker
10
+ # can't. Ruby has no static typing, so which nested Struct class a field recurses
11
+ # into cannot be derived by reflection alone; RECURSION_TARGETS is the one small,
12
+ # explicit map needed for that -- it is a target-class lookup, not a key list, so it
13
+ # stays small (~20 entries) and stable even as fields are added to existing structs.
14
+ #
15
+ # Does NOT modify AnnFlavorCore::Parser in any way -- a fully separate, additive pass
16
+ # over the same raw Hash the parser already receives.
17
+ #
18
+ # Usage: AnnFlavorCore::StrictModeChecker.check(raw_hash, AnnFlavorCore::AnnSpec)
19
+ #
20
+ module StrictModeChecker
21
+ # Fields whose value is a free-form Hash (arbitrary user-declared keys are data,
22
+ # not schema) -- exempted from unknown-key checking entirely, at any level.
23
+ FREE_FORM_FIELDS = %i[custom dart_defines].freeze
24
+
25
+ # (containing_struct, field_name) => target_struct — only the "does this field
26
+ # recurse, and into what" relationship; the valid-keys-within-that-struct question
27
+ # is still fully derived from Struct#members.
28
+ RECURSION_TARGETS = {
29
+ [AnnSpec, :app] => AnnApp,
30
+ [AnnSpec, :tooling] => ToolingConfig,
31
+ [AnnApp, :android] => AndroidPlatform,
32
+ [AnnApp, :ios] => IosPlatform,
33
+ [AnnApp, :web] => WebPlatform,
34
+ [AnnApp, :windows] => WindowsPlatform,
35
+ [AnnApp, :integrations] => AnnIntegrations,
36
+
37
+ [AndroidPlatform, :default] => AndroidDefault,
38
+ [AndroidPlatform, :flavors] => AndroidFlavor, # via `flavor:` YAML key, map values
39
+ [IosPlatform, :default] => IosDefault,
40
+ [IosPlatform, :flavors] => IosFlavor,
41
+ [WebPlatform, :default] => WebDefault,
42
+ [WebPlatform, :flavors] => WebFlavor,
43
+ [WindowsPlatform, :default] => WindowsDefault,
44
+ [WindowsPlatform, :flavors] => WindowsFlavor,
45
+
46
+ [AndroidDefault, :admob] => AdmobConfig,
47
+ [AndroidDefault, :sdk] => AndroidSdk,
48
+ [AndroidDefault, :credentials] => AndroidCredentials,
49
+ [AndroidDefault, :firebase] => FirebaseConfig,
50
+ [AndroidDefault, :build_types] => BuildTypeConfig, # map values
51
+ [AndroidFlavor, :admob] => AdmobConfig,
52
+ [AndroidFlavor, :credentials] => AndroidCredentials,
53
+ [AndroidFlavor, :firebase] => FirebaseConfig,
54
+ [AndroidFlavor, :stores] => FlavorStores,
55
+ [AndroidFlavor, :build_types] => BuildTypeConfig,
56
+
57
+ [IosDefault, :admob] => AdmobConfig,
58
+ [IosDefault, :credentials] => IosCredentials,
59
+ [IosDefault, :firebase] => FirebaseConfig,
60
+ [IosDefault, :build_types] => BuildTypeConfig,
61
+ [IosFlavor, :admob] => AdmobConfig,
62
+ [IosFlavor, :credentials] => IosCredentials,
63
+ [IosFlavor, :firebase] => FirebaseConfig,
64
+ [IosFlavor, :stores] => FlavorStores,
65
+ [IosFlavor, :build_types] => BuildTypeConfig,
66
+
67
+ [WebDefault, :cloudflare] => CloudflareConfig,
68
+ [WebDefault, :build_types] => BuildTypeConfig,
69
+ [WebFlavor, :cloudflare] => CloudflareConfig,
70
+ [WebFlavor, :build_types] => BuildTypeConfig,
71
+
72
+ [WindowsDefault, :build_types] => BuildTypeConfig,
73
+ [WindowsFlavor, :build_types] => BuildTypeConfig,
74
+
75
+ [BuildTypeConfig, :admob] => AdmobConfig,
76
+ [BuildTypeConfig, :firebase] => FirebaseConfig,
77
+ [BuildTypeConfig, :auth] => AuthConfig,
78
+
79
+ [AndroidCredentials, :signing] => AndroidSigning,
80
+ [AndroidCredentials, :google_play] => GooglePlayCredentials,
81
+ [AndroidCredentials, :samsung_galaxy] => SamsungGalaxyCredentials,
82
+ [AndroidCredentials, :amazon] => AmazonCredentials,
83
+ [IosCredentials, :signing] => IosSigning,
84
+ [IosCredentials, :app_store] => AppStoreCredentials,
85
+
86
+ [FlavorStores, :google_play] => GooglePlayFlavorStore,
87
+ [FlavorStores, :samsung_galaxy] => SamsungGalaxyFlavorStore,
88
+ [FlavorStores, :amazon] => AmazonFlavorStore,
89
+ [FlavorStores, :app_store] => AppStoreFlavorStore,
90
+ }.freeze
91
+
92
+ # Fields whose target struct's instances live inside a Hash keyed by an arbitrary
93
+ # user-chosen name (flavor name, build-type name), not a single nested Hash.
94
+ MAP_VALUED_FIELDS = %i[flavors build_types].freeze
95
+
96
+ def self.check(raw, root_struct)
97
+ issues = []
98
+ walk(raw, root_struct, '', issues)
99
+ issues
100
+ end
101
+
102
+ def self.walk(raw, struct_class, path, issues)
103
+ return unless raw.is_a?(Hash)
104
+
105
+ valid_keys = struct_class.members.map(&:to_s)
106
+ # `flavors` (Struct member) reads YAML key `flavor` (singular) -- the one
107
+ # deliberate field-name <-> YAML-key exception in the schema.
108
+ valid_keys = valid_keys.map { |k| k == 'flavors' ? 'flavor' : k }
109
+
110
+ raw.each do |raw_key, raw_value|
111
+ key = raw_key.to_s
112
+ field_path = path.empty? ? key : "#{path}.#{key}"
113
+
114
+ unless valid_keys.include?(key)
115
+ context = path.empty? ? 'top-level annspec.yaml' : "#{path} block"
116
+ issues << "Unknown field \"#{key}\" in #{context}. Check for typos or remove the field."
117
+ next
118
+ end
119
+
120
+ field_sym = (key == 'flavor' ? 'flavors' : key).to_sym
121
+ next if FREE_FORM_FIELDS.include?(field_sym)
122
+
123
+ target_struct = RECURSION_TARGETS[[struct_class, field_sym]]
124
+ next unless target_struct
125
+
126
+ if MAP_VALUED_FIELDS.include?(field_sym)
127
+ raw_value.is_a?(Hash) && raw_value.each_value do |entry_val|
128
+ walk(entry_val, target_struct, field_path, issues)
129
+ end
130
+ else
131
+ walk(raw_value, target_struct, field_path, issues)
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,3 @@
1
+ module AnnFlavorCore
2
+ VERSION = '1.0.0'
3
+ end
@@ -0,0 +1,6 @@
1
+ require_relative 'ann_flavor_core/version'
2
+ require_relative 'ann_flavor_core/model'
3
+ require_relative 'ann_flavor_core/parser'
4
+ require_relative 'ann_flavor_core/resolver'
5
+ require_relative 'ann_flavor_core/ios_resolution'
6
+ require_relative 'ann_flavor_core/strict_mode_checker'
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ann-flavor-flutter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.5
4
+ version: 0.4.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - ANN Solutions
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-08-24 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: fastlane
@@ -66,7 +65,6 @@ dependencies:
66
65
  - - "~>"
67
66
  - !ruby/object:Gem::Version
68
67
  version: '0.6'
69
- description:
70
68
  email:
71
69
  - support@annaibrands.com
72
70
  executables: []
@@ -102,12 +100,18 @@ files:
102
100
  - lib/fastlane/plugin/ann_flavor_flutter/helper/utils_project_config.rb
103
101
  - lib/fastlane/plugin/ann_flavor_flutter/helper/utils_spec_loader.rb
104
102
  - lib/fastlane/plugin/ann_flavor_flutter/helper/utils_status.rb
103
+ - lib/vendor/ann_flavor_core/lib/ann_flavor_core.rb
104
+ - lib/vendor/ann_flavor_core/lib/ann_flavor_core/ios_resolution.rb
105
+ - lib/vendor/ann_flavor_core/lib/ann_flavor_core/model.rb
106
+ - lib/vendor/ann_flavor_core/lib/ann_flavor_core/parser.rb
107
+ - lib/vendor/ann_flavor_core/lib/ann_flavor_core/resolver.rb
108
+ - lib/vendor/ann_flavor_core/lib/ann_flavor_core/strict_mode_checker.rb
109
+ - lib/vendor/ann_flavor_core/lib/ann_flavor_core/version.rb
105
110
  - plugin_manager.sh
106
111
  homepage: https://github.com/anntech-dev
107
112
  licenses:
108
113
  - MIT
109
114
  metadata: {}
110
- post_install_message:
111
115
  rdoc_options: []
112
116
  require_paths:
113
117
  - lib
@@ -122,8 +126,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
122
126
  - !ruby/object:Gem::Version
123
127
  version: '0'
124
128
  requirements: []
125
- rubygems_version: 3.4.19
126
- signing_key:
129
+ rubygems_version: 3.6.9
127
130
  specification_version: 4
128
131
  summary: A fastlane plugin to manage Flutter build flavors and shared setup.
129
132
  test_files: []