ann-flavor-flutter 0.4.5 → 0.4.6

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: 2884061122825135d9f07bb18db889d23509649848aca1fca2c45b63b9b8c3bd
4
+ data.tar.gz: 5ed44c51bcf6c29195763aaa2615f4bb59b71a5b0908f26fdbc83677419273a4
5
5
  SHA512:
6
- metadata.gz: 79151855669eea4e3c328da868b4024a47b5669e43cb38e72f47333f480da0bb48c52f1a81e8ea85fb9b5095d75face5a88c72289873f8137b134b565927a464
7
- data.tar.gz: e366bb91225e5adcca0e31bc5d4dce65909d165e4b1735a710f19b4f484b61094fa2b660ddf78fd270b6b5d30bf4c3679596332e4efca3f81b5a63bf462d0633
6
+ metadata.gz: 8635f03da486e2aa4da6a7c9ed423d76260624ff5710a58c4a174951ecf2922bd874e4ff197edcb551db650ae7ef3fc76725d4f58935a076785f0c3f203e3a81
7
+ data.tar.gz: 0e5a13e1e4c03ddf24b31bb7b7c07fd330934213601b7a373408fdb051b8cfb2bb0bf298a9ad254df1ede039315b4871f6b2ff44a90818c7574a7361f2f197b6
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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.6
4
+
5
+ ### Fixed
6
+ - 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))
7
+
3
8
  ## 0.4.5
4
9
 
5
10
  ### Changed
@@ -1,3 +1,3 @@
1
1
  module AnnFlavorFlutter
2
- VERSION = "0.4.5"
2
+ VERSION = "0.4.6"
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
 
@@ -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.6
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: []