fastlane-plugin-firebase_app_distribution 0.2.9 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6396f055da2f0d98f6f8d675cf08af13e80d66a3a0d421cb905f5af075c6544
4
- data.tar.gz: 6c81776db0daf3eef997d090cb6d1a51c62a44d69080e7f7efde05523d2e4c2e
3
+ metadata.gz: cf7cfe204f09a005063f099ef379887cda33351ed6f1dfd1bb84773b1c989a4f
4
+ data.tar.gz: 3fc3e7c82e6506a670ab6b137ab8a4a55d968ba5f89ff0f20bcb0448ca0d2f76
5
5
  SHA512:
6
- metadata.gz: 675c0c9c0452e0dad1ad47edb31caa1d374869eb03a414adec8ffc3276b6e959b97016481fcf9fb66f416020f870341436de8a579480de35d30d9906107a93e2
7
- data.tar.gz: 9177cc9fefed5010d2e5aa2395fda013235f4621f330a2615e617377028d20da292577b6d38f3a9d9f59c2363669086e03eb6f66ae29f8245b006341721207aa
6
+ metadata.gz: c25cb0b20720cd286c3665d8496b7c66e90121f34c2802fa7e63e52835a54aff81cc7fe2c91313af45a9e69ded27560b495917da498d0ea13a34712ae323dbbf
7
+ data.tar.gz: 1b45cb5e6657fac457d9bf34bbe4e5a624a8f912b193eaf58138d964cdb3d793aac0bf5036cd56dd997dee1b25e57ab2d30602a122e2c9f24d7171c8f7b25ccc
@@ -19,6 +19,7 @@ module Fastlane
19
19
  params.values # to validate all inputs before looking for the ipa/apk/aab
20
20
 
21
21
  app_id = app_id_from_params(params)
22
+ app_name = app_name_from_app_id(app_id)
22
23
  platform = lane_platform || platform_from_app_id(app_id)
23
24
 
24
25
  binary_path = get_binary_path(platform, params)
@@ -26,35 +27,39 @@ module Fastlane
26
27
  UI.user_error!("Couldn't find binary at path #{binary_path}") unless File.exist?(binary_path)
27
28
  binary_type = binary_type_from_path(binary_path)
28
29
 
29
- auth_token = fetch_auth_token(params[:service_credentials_file], params[:firebase_cli_token])
30
+ auth_token = fetch_auth_token(
31
+ params[:service_credentials_file], params[:firebase_cli_token], params[:debug]
32
+ )
30
33
  fad_api_client = Client::FirebaseAppDistributionApiClient.new(auth_token, params[:debug])
31
34
 
32
- # If binary is an AAB get FULL view of app which includes the aab_state
33
- app_view = binary_type == :AAB ? 'FULL' : 'BASIC'
34
- app = fad_api_client.get_app(app_id, app_view)
35
- validate_app!(app, binary_type)
36
- release_id = fad_api_client.upload(app.project_number, app_id, binary_path, platform.to_s)
35
+ # If binary is an AAB, get the AAB info for this app, which includes the integration state and certificate data
36
+ if binary_type == :AAB
37
+ aab_info = fad_api_client.get_aab_info(app_name)
38
+ validate_aab_setup!(aab_info)
39
+ end
40
+
41
+ release_name = fad_api_client.upload(app_name, binary_path, platform.to_s)
37
42
 
38
- if binary_type == :AAB && app.aab_certificate.empty?
39
- updated_app = fad_api_client.get_app(app_id)
40
- unless updated_app.aab_certificate.empty?
43
+ if binary_type == :AAB && aab_info && !aab_info.certs_provided?
44
+ updated_aab_info = fad_api_client.get_aab_info(app_name)
45
+ if updated_aab_info.certs_provided?
41
46
  UI.message("After you upload an AAB for the first time, App Distribution " \
42
47
  "generates a new test certificate. All AAB uploads are re-signed with this test " \
43
48
  "certificate. Use the certificate fingerprints below to register your app " \
44
49
  "signing key with API providers, such as Google Sign-In and Google Maps.\n" \
45
- "MD-1 certificate fingerprint: #{updated_app.aab_certificate.md5_certificate_hash}\n" \
46
- "SHA-1 certificate fingerprint: #{updated_app.aab_certificate.sha1_certificate_hash}\n" \
47
- "SHA-256 certificate fingerprint: #{updated_app.aab_certificate.sha256_certificate_hash}")
50
+ "MD-1 certificate fingerprint: #{updated_aab_info.md5_certificate_hash}\n" \
51
+ "SHA-1 certificate fingerprint: #{updated_aab_info.sha1_certificate_hash}\n" \
52
+ "SHA-256 certificate fingerprint: #{updated_aab_info.sha256_certificate_hash}")
48
53
  end
49
54
  end
50
55
 
51
- fad_api_client.post_notes(app_id, release_id, release_notes(params))
56
+ fad_api_client.update_release_notes(release_name, release_notes(params))
52
57
 
53
58
  testers = get_value_from_value_or_file(params[:testers], params[:testers_file])
54
59
  groups = get_value_from_value_or_file(params[:groups], params[:groups_file])
55
60
  emails = string_to_array(testers)
56
- group_ids = string_to_array(groups)
57
- fad_api_client.enable_access(app_id, release_id, emails, group_ids)
61
+ group_aliases = string_to_array(groups)
62
+ fad_api_client.distribute(release_name, emails, group_aliases)
58
63
  UI.success("🎉 App Distribution upload finished successfully.")
59
64
  end
60
65
 
@@ -84,6 +89,10 @@ module Fastlane
84
89
  app_id
85
90
  end
86
91
 
92
+ def self.app_name_from_app_id(app_id)
93
+ "projects/#{app_id.split(':')[1]}/apps/#{app_id}"
94
+ end
95
+
87
96
  def self.xcode_archive_path
88
97
  # prevents issues on cross-platform build environments where an XCode build happens within
89
98
  # the same lane
@@ -126,23 +135,19 @@ module Fastlane
126
135
  end
127
136
  end
128
137
 
129
- def self.validate_app!(app, binary_type)
130
- if app.contact_email.nil? || app.contact_email.strip.empty?
131
- UI.user_error!(ErrorMessage::GET_APP_NO_CONTACT_EMAIL_ERROR)
132
- end
133
-
134
- if binary_type == :AAB && app.aab_state != App::AabState::ACTIVE && app.aab_state != App::AabState::UNAVAILABLE
135
- case app.aab_state
136
- when App::AabState::PLAY_ACCOUNT_NOT_LINKED
138
+ def self.validate_aab_setup!(aab_info)
139
+ if aab_info && aab_info.integration_state != AabInfo::AabState::INTEGRATED && aab_info.integration_state != AabInfo::AabState::UNAVAILABLE
140
+ case aab_info.integration_state
141
+ when AabInfo::AabState::PLAY_ACCOUNT_NOT_LINKED
137
142
  UI.user_error!(ErrorMessage::PLAY_ACCOUNT_NOT_LINKED)
138
- when App::AabState::APP_NOT_PUBLISHED
143
+ when AabInfo::AabState::APP_NOT_PUBLISHED
139
144
  UI.user_error!(ErrorMessage::APP_NOT_PUBLISHED)
140
- when App::AabState::NO_APP_WITH_GIVEN_BUNDLE_ID_IN_PLAY_ACCOUNT
145
+ when AabInfo::AabState::NO_APP_WITH_GIVEN_BUNDLE_ID_IN_PLAY_ACCOUNT
141
146
  UI.user_error!(ErrorMessage::NO_APP_WITH_GIVEN_BUNDLE_ID_IN_PLAY_ACCOUNT)
142
- when App::AabState::PLAY_IAS_TERMS_NOT_ACCEPTED
147
+ when AabInfo::AabState::PLAY_IAS_TERMS_NOT_ACCEPTED
143
148
  UI.user_error!(ErrorMessage::PLAY_IAS_TERMS_NOT_ACCEPTED)
144
149
  else
145
- UI.user_error!(ErrorMessage.aab_upload_error(app.aab_state))
150
+ UI.user_error!(ErrorMessage.aab_upload_error(aab_info.integration_state))
146
151
  end
147
152
  end
148
153
  end
@@ -0,0 +1,90 @@
1
+ require 'fastlane/action'
2
+ require 'fastlane_core/ui/ui'
3
+
4
+ require_relative '../helper/firebase_app_distribution_helper'
5
+ require_relative '../helper/firebase_app_distribution_auth_client'
6
+
7
+ module Fastlane
8
+ module Actions
9
+ class FirebaseAppDistributionAddTestersAction < Action
10
+ extend Auth::FirebaseAppDistributionAuthClient
11
+ extend Helper::FirebaseAppDistributionHelper
12
+
13
+ def self.run(params)
14
+ auth_token = fetch_auth_token(params[:service_credentials_file], params[:firebase_cli_token])
15
+ fad_api_client = Client::FirebaseAppDistributionApiClient.new(auth_token, params[:debug])
16
+
17
+ if blank?(params[:emails]) && blank?(params[:file])
18
+ UI.user_error!("Must specify `emails` or `file`.")
19
+ end
20
+
21
+ emails = string_to_array(get_value_from_value_or_file(params[:emails], params[:file]))
22
+
23
+ UI.user_error!("Must pass at least one email") if blank?(emails)
24
+
25
+ if emails.count > 1000
26
+ UI.user_error!("A maximum of 1000 testers can be added at a time.")
27
+ end
28
+
29
+ UI.message("⏳ Adding #{emails.count} testers to project #{params[:project_number]}...")
30
+
31
+ fad_api_client.add_testers(params[:project_number], emails)
32
+
33
+ # The add_testers response lists all the testers from the request
34
+ # regardless of whether or not they were created or if they already
35
+ # exists so can't get an accurate count of the number of newly created testers
36
+ UI.success("✅ Tester(s) successfully added.")
37
+ end
38
+
39
+ def self.description
40
+ "Create testers in bulk from a comma-separated list or a file"
41
+ end
42
+
43
+ def self.authors
44
+ ["Tunde Agboola"]
45
+ end
46
+
47
+ # supports markdown.
48
+ def self.details
49
+ "Create testers in bulk from a comma-separated list or a file"
50
+ end
51
+
52
+ def self.available_options
53
+ [
54
+ FastlaneCore::ConfigItem.new(key: :project_number,
55
+ env_name: "FIREBASEAPPDISTRO_PROJECT_NUMBER",
56
+ description: "Your Firebase project number. You can find the project number in the Firebase console, on the General Settings page",
57
+ type: Integer,
58
+ optional: false),
59
+ FastlaneCore::ConfigItem.new(key: :emails,
60
+ env_name: "FIREBASEAPPDISTRO_ADD_TESTERS_EMAILS",
61
+ description: "Comma separated list of tester emails to be created. A maximum of 1000 testers can be created at a time",
62
+ optional: true,
63
+ type: String),
64
+ FastlaneCore::ConfigItem.new(key: :file,
65
+ env_name: "FIREBASEAPPDISTRO_ADD_TESTERS_FILE",
66
+ description: "Path to a file containing a comma separated list of tester emails to be created. A maximum of 1000 testers can be deleted at a time",
67
+ optional: true,
68
+ type: String),
69
+ FastlaneCore::ConfigItem.new(key: :service_credentials_file,
70
+ description: "Path to Google service credentials file",
71
+ optional: true,
72
+ type: String),
73
+ FastlaneCore::ConfigItem.new(key: :firebase_cli_token,
74
+ description: "Auth token for firebase cli",
75
+ optional: true,
76
+ type: String),
77
+ FastlaneCore::ConfigItem.new(key: :debug,
78
+ description: "Print verbose debug output",
79
+ optional: true,
80
+ default_value: false,
81
+ is_string: false)
82
+ ]
83
+ end
84
+
85
+ def self.is_supported?(platform)
86
+ true
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,88 @@
1
+ require 'fastlane/action'
2
+ require 'fastlane_core/ui/ui'
3
+
4
+ require_relative '../helper/firebase_app_distribution_helper'
5
+ require_relative '../helper/firebase_app_distribution_auth_client'
6
+
7
+ module Fastlane
8
+ module Actions
9
+ class FirebaseAppDistributionRemoveTestersAction < Action
10
+ extend Auth::FirebaseAppDistributionAuthClient
11
+ extend Helper::FirebaseAppDistributionHelper
12
+
13
+ def self.run(params)
14
+ auth_token = fetch_auth_token(params[:service_credentials_file], params[:firebase_cli_token])
15
+ fad_api_client = Client::FirebaseAppDistributionApiClient.new(auth_token, params[:debug])
16
+
17
+ if blank?(params[:emails]) && blank?(params[:file])
18
+ UI.user_error!("Must specify `emails` or `file`.")
19
+ end
20
+
21
+ emails = string_to_array(get_value_from_value_or_file(params[:emails], params[:file]))
22
+
23
+ UI.user_error!("Must pass at least one email") if blank?(emails)
24
+
25
+ if emails.count > 1000
26
+ UI.user_error!("A maximum of 1000 testers can be removed at a time.")
27
+ end
28
+
29
+ UI.message("⏳ Removing #{emails.count} testers from project #{params[:project_number]}...")
30
+
31
+ count = fad_api_client.remove_testers(params[:project_number], emails)
32
+
33
+ UI.success("✅ #{count} tester(s) removed successfully.")
34
+ end
35
+
36
+ def self.description
37
+ "Delete testers in bulk from a comma-separated list or a file"
38
+ end
39
+
40
+ def self.authors
41
+ ["Tunde Agboola"]
42
+ end
43
+
44
+ # supports markdown.
45
+ def self.details
46
+ "Delete testers in bulk from a comma-separated list or a file"
47
+ end
48
+
49
+ def self.available_options
50
+ [
51
+ FastlaneCore::ConfigItem.new(key: :project_number,
52
+ env_name: "FIREBASEAPPDISTRO_PROJECT_NUMBER",
53
+ description: "Your Firebase project number. You can find the project number in the Firebase console, on the General Settings page",
54
+ type: Integer,
55
+ optional: false),
56
+ FastlaneCore::ConfigItem.new(key: :emails,
57
+ env_name: "FIREBASEAPPDISTRO_REMOVE_TESTERS_EMAILS",
58
+ description: "Comma separated list of tester emails to be deleted. A maximum of 1000 testers can be deleted at a time",
59
+ optional: true,
60
+ type: String),
61
+ FastlaneCore::ConfigItem.new(key: :file,
62
+ env_name: "FIREBASEAPPDISTRO_REMOVE_TESTERS_FILE",
63
+ description: "Path to a file containing a comma separated list of tester emails to be deleted. A maximum of 1000 testers can be deleted at a time",
64
+ optional: true,
65
+ type: String),
66
+ FastlaneCore::ConfigItem.new(key: :service_credentials_file,
67
+ description: "Path to Google service credentials file",
68
+ optional: true,
69
+ type: String),
70
+ FastlaneCore::ConfigItem.new(key: :firebase_cli_token,
71
+ description: "Auth token for firebase cli",
72
+ optional: true,
73
+ type: String),
74
+ FastlaneCore::ConfigItem.new(key: :debug,
75
+ description: "Print verbose debug output",
76
+ optional: true,
77
+ default_value: false,
78
+ is_string: false)
79
+
80
+ ]
81
+ end
82
+
83
+ def self.is_supported?(platform)
84
+ true
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,42 @@
1
+ class AabInfo
2
+ # AAB states
3
+ class AabState
4
+ UNSPECIFIED = 'AAB_STATE_UNSPECIFIED'
5
+ PLAY_ACCOUNT_NOT_LINKED = 'PLAY_ACCOUNT_NOT_LINKED'
6
+ NO_APP_WITH_GIVEN_BUNDLE_ID_IN_PLAY_ACCOUNT = 'NO_APP_WITH_GIVEN_BUNDLE_ID_IN_PLAY_ACCOUNT'
7
+ APP_NOT_PUBLISHED = 'APP_NOT_PUBLISHED'
8
+ PLAY_IAS_TERMS_NOT_ACCEPTED = 'PLAY_IAS_TERMS_NOT_ACCEPTED'
9
+ INTEGRATED = 'INTEGRATED'
10
+ UNAVAILABLE = 'AAB_STATE_UNAVAILABLE'
11
+ end
12
+
13
+ def initialize(response)
14
+ @response = response || {}
15
+ end
16
+
17
+ def integration_state
18
+ @response[:integrationState]
19
+ end
20
+
21
+ def test_certificate
22
+ @response[:testCertificate] || {}
23
+ end
24
+
25
+ def md5_certificate_hash
26
+ test_certificate[:hashMd5]
27
+ end
28
+
29
+ def sha1_certificate_hash
30
+ test_certificate[:hashSha1]
31
+ end
32
+
33
+ def sha256_certificate_hash
34
+ test_certificate[:hashSha256]
35
+ end
36
+
37
+ def certs_provided?
38
+ (!md5_certificate_hash.nil? && !md5_certificate_hash.empty?) &&
39
+ (!sha1_certificate_hash.nil? && !sha1_certificate_hash.empty?) &&
40
+ (!sha256_certificate_hash.nil? && !sha256_certificate_hash.empty?)
41
+ end
42
+ end
@@ -1,7 +1,7 @@
1
1
  require 'fastlane_core/ui/ui'
2
2
  require_relative '../actions/firebase_app_distribution_login'
3
3
  require_relative '../client/error_response'
4
- require_relative '../client/app'
4
+ require_relative '../client/aab_info'
5
5
  require_relative '../helper/firebase_app_distribution_helper'
6
6
 
7
7
  module Fastlane
@@ -12,12 +12,13 @@ module Fastlane
12
12
  BASE_URL = "https://firebaseappdistribution.googleapis.com"
13
13
  TOKEN_CREDENTIAL_URI = "https://oauth2.googleapis.com/token"
14
14
  MAX_POLLING_RETRIES = 60
15
- POLLING_INTERVAL_SECONDS = 2
15
+ POLLING_INTERVAL_SECONDS = 5
16
16
 
17
17
  AUTHORIZATION = "Authorization"
18
18
  CONTENT_TYPE = "Content-Type"
19
19
  APPLICATION_JSON = "application/json"
20
20
  APPLICATION_OCTET_STREAM = "application/octet-stream"
21
+ CLIENT_VERSION = "X-Client-Version"
21
22
 
22
23
  def initialize(auth_token, debug = false)
23
24
  @auth_token = auth_token
@@ -25,49 +26,52 @@ module Fastlane
25
26
  end
26
27
 
27
28
  # Enables tester access to the specified app release. Skips this
28
- # step if no testers are passed in (emails and group_ids are nil/empty).
29
+ # step if no testers are passed in (emails and group_aliases are nil/empty).
29
30
  #
30
31
  # args
31
- # app_id - Firebase App ID
32
- # release_id - App release ID, returned by upload_status endpoint
32
+ # release_name - App release resource name, returned by upload_status endpoint
33
33
  # emails - String array of app testers' email addresses
34
- # group_ids - String array of Firebase tester group IDs
34
+ # group_aliases - String array of Firebase tester group aliases
35
35
  #
36
- # Throws a user_error if emails or group_ids are invalid
37
- def enable_access(app_id, release_id, emails, group_ids)
38
- if (emails.nil? || emails.empty?) && (group_ids.nil? || group_ids.empty?)
36
+ # Throws a user_error if emails or group_aliases are invalid
37
+ def distribute(release_name, emails, group_aliases)
38
+ if (emails.nil? || emails.empty?) && (group_aliases.nil? || group_aliases.empty?)
39
39
  UI.success("✅ No testers passed in. Skipping this step.")
40
40
  return
41
41
  end
42
- payload = { emails: emails, groupIds: group_ids }
42
+ payload = { testerEmails: emails, groupAliases: group_aliases }
43
43
  begin
44
- connection.post(enable_access_url(app_id, release_id), payload.to_json) do |request|
44
+ connection.post(distribute_url(release_name), payload.to_json) do |request|
45
45
  request.headers[AUTHORIZATION] = "Bearer " + @auth_token
46
46
  request.headers[CONTENT_TYPE] = APPLICATION_JSON
47
47
  end
48
48
  rescue Faraday::ClientError
49
- UI.user_error!("#{ErrorMessage::INVALID_TESTERS} \nEmails: #{emails} \nGroups: #{group_ids}")
49
+ UI.user_error!("#{ErrorMessage::INVALID_TESTERS} \nEmails: #{emails} \nGroups: #{group_aliases}")
50
50
  end
51
51
  UI.success("✅ Added testers/groups.")
52
52
  end
53
53
 
54
- # Posts notes for the specified app release. Skips this
54
+ # Update release notes for the specified app release. Skips this
55
55
  # step if no notes are passed in (release_notes is nil/empty).
56
56
  #
57
57
  # args
58
- # app_id - Firebase App ID
59
- # release_id - App release ID, returned by upload_status endpoint
58
+ # release_name - App release resource name, returned by upload_status endpoint
60
59
  # release_notes - String of notes for this release
61
60
  #
62
61
  # Throws a user_error if the release_notes are invalid
63
- def post_notes(app_id, release_id, release_notes)
64
- payload = { releaseNotes: { releaseNotes: release_notes } }
62
+ def update_release_notes(release_name, release_notes)
65
63
  if release_notes.nil? || release_notes.empty?
66
64
  UI.success("✅ No release notes passed in. Skipping this step.")
67
65
  return
68
66
  end
69
67
  begin
70
- connection.post(release_notes_create_url(app_id, release_id), payload.to_json) do |request|
68
+ payload = {
69
+ name: release_name,
70
+ releaseNotes: {
71
+ text: release_notes
72
+ }
73
+ }
74
+ connection.patch(update_release_notes_url(release_name), payload.to_json) do |request|
71
75
  request.headers[AUTHORIZATION] = "Bearer " + @auth_token
72
76
  request.headers[CONTENT_TYPE] = APPLICATION_JSON
73
77
  end
@@ -78,42 +82,42 @@ module Fastlane
78
82
  UI.success("✅ Posted release notes.")
79
83
  end
80
84
 
81
- # Get app
85
+ # Get AAB info (Android apps only)
82
86
  #
83
87
  # args
84
- # app_id - Firebase App ID
88
+ # app_name - Firebase App resource name
85
89
  #
86
90
  # Throws a user_error if the app hasn't been onboarded to App Distribution
87
- def get_app(app_id, app_view = 'BASIC')
91
+ def get_aab_info(app_name)
88
92
  begin
89
- response = connection.get("#{v1_apps_url(app_id)}?appView=#{app_view}") do |request|
93
+ response = connection.get(aab_info_url(app_name)) do |request|
90
94
  request.headers[AUTHORIZATION] = "Bearer " + @auth_token
91
95
  end
92
96
  rescue Faraday::ResourceNotFound
93
- UI.user_error!("#{ErrorMessage::INVALID_APP_ID}: #{app_id}")
97
+ UI.user_error!("#{ErrorMessage::INVALID_APP_ID}: #{app_name}")
94
98
  end
95
99
 
96
- App.new(response.body)
100
+ AabInfo.new(response.body)
97
101
  end
98
102
 
99
103
  # Uploads the app binary to the Firebase API
100
104
  #
101
105
  # args
102
- # app_id - Firebase App ID
106
+ # app_name - Firebase App resource name
103
107
  # binary_path - Absolute path to your app's aab/apk/ipa file
104
108
  # platform - 'android' or 'ios'
105
109
  #
106
110
  # Throws a user_error if the binary file does not exist
107
- def upload_binary(app_id, binary_path, platform)
108
- connection.post(binary_upload_url(app_id), read_binary(binary_path)) do |request|
111
+ def upload_binary(app_name, binary_path, platform)
112
+ response = connection.post(binary_upload_url(app_name), read_binary(binary_path)) do |request|
109
113
  request.headers[AUTHORIZATION] = "Bearer " + @auth_token
110
114
  request.headers[CONTENT_TYPE] = APPLICATION_OCTET_STREAM
111
- request.headers["X-APP-DISTRO-API-CLIENT-ID"] = "fastlane"
112
- request.headers["X-APP-DISTRO-API-CLIENT-TYPE"] = platform
113
- request.headers["X-APP-DISTRO-API-CLIENT-VERSION"] = Fastlane::FirebaseAppDistribution::VERSION
114
- request.headers["X-GOOG-UPLOAD-FILE-NAME"] = File.basename(binary_path)
115
- request.headers["X-GOOG-UPLOAD-PROTOCOL"] = "raw"
115
+ request.headers[CLIENT_VERSION] = client_version_header_value
116
+ request.headers["X-Goog-Upload-File-Name"] = File.basename(binary_path)
117
+ request.headers["X-Goog-Upload-Protocol"] = "raw"
116
118
  end
119
+
120
+ response.body[:name] || ''
117
121
  rescue Errno::ENOENT # Raised when binary_path file does not exist
118
122
  binary_type = binary_type_from_path(binary_path)
119
123
  UI.user_error!("#{ErrorMessage.binary_not_found(binary_type)}: #{binary_path}")
@@ -123,66 +127,67 @@ module Fastlane
123
127
  # Takes at least POLLING_INTERVAL_SECONDS between polling get_upload_status
124
128
  #
125
129
  # args
126
- # project_number - Firebase project number
127
- # app_id - Firebase app ID
130
+ # app_name - Firebase App resource name
128
131
  # binary_path - Absolute path to your app's aab/apk/ipa file
129
132
  #
130
- # Returns the release_id of the uploaded release.
133
+ # Returns the release_name of the uploaded release.
131
134
  #
132
135
  # Crashes if the number of polling retries exceeds MAX_POLLING_RETRIES or if the binary cannot
133
136
  # be uploaded.
134
- def upload(project_number, app_id, binary_path, platform)
137
+ def upload(app_name, binary_path, platform)
135
138
  binary_type = binary_type_from_path(binary_path)
136
139
 
137
- upload_token = get_upload_token(project_number, app_id, binary_path)
138
- upload_status_response = get_upload_status(app_id, upload_token)
139
- if upload_status_response.success? || upload_status_response.already_uploaded?
140
- UI.success("✅ This #{binary_type} has been uploaded before. Skipping upload step.")
141
- else
142
- unless upload_status_response.in_progress?
143
- UI.message("⌛ Uploading the #{binary_type}.")
144
- upload_binary(app_id, binary_path, platform)
145
- end
146
- MAX_POLLING_RETRIES.times do
147
- upload_status_response = get_upload_status(app_id, upload_token)
148
- if upload_status_response.success? || upload_status_response.already_uploaded?
149
- UI.success("✅ Uploaded the #{binary_type}.")
140
+ UI.message("⌛ Uploading the #{binary_type}.")
141
+ operation_name = upload_binary(app_name, binary_path, platform)
142
+
143
+ upload_status_response = get_upload_status(operation_name)
144
+ MAX_POLLING_RETRIES.times do
145
+ if upload_status_response.success?
146
+ if upload_status_response.release_updated?
147
+ UI.success("✅ Uploaded #{binary_type} successfully; updated provisioning profile of existing release #{upload_status_response.release_version}.")
148
+ break
149
+ elsif upload_status_response.release_unmodified?
150
+ UI.success("✅ The same #{binary_type} was found in release #{upload_status_response.release_version} with no changes, skipping.")
150
151
  break
151
- elsif upload_status_response.in_progress?
152
- sleep(POLLING_INTERVAL_SECONDS)
153
152
  else
154
- if !upload_status_response.message.nil?
155
- UI.user_error!("#{ErrorMessage.upload_binary_error(binary_type)}: #{upload_status_response.message}")
156
- else
157
- UI.user_error!(ErrorMessage.upload_binary_error(binary_type))
158
- end
153
+ UI.success("✅ Uploaded #{binary_type} successfully and created release #{upload_status_response.release_version}.")
154
+ end
155
+ break
156
+ elsif upload_status_response.in_progress?
157
+ sleep(POLLING_INTERVAL_SECONDS)
158
+ upload_status_response = get_upload_status(operation_name)
159
+ else
160
+ if !upload_status_response.error_message.nil?
161
+ UI.user_error!("#{ErrorMessage.upload_binary_error(binary_type)}: #{upload_status_response.error_message}")
162
+ else
163
+ UI.user_error!(ErrorMessage.upload_binary_error(binary_type))
159
164
  end
160
- end
161
- unless upload_status_response.success?
162
- UI.crash!("It took longer than expected to process your #{binary_type}, please try again.")
163
165
  end
164
166
  end
165
- upload_status_response.release_id
167
+ unless upload_status_response.success?
168
+ UI.crash!("It took longer than expected to process your #{binary_type}, please try again.")
169
+ end
170
+
171
+ upload_status_response.release_name
166
172
  end
167
173
 
168
174
  # Fetches the status of an uploaded binary
169
175
  #
170
176
  # args
171
- # app_id - Firebase App ID
172
- # upload_token - URL encoded upload token
177
+ # operation_name - Upload operation name (with binary hash)
173
178
  #
174
- # Returns the release ID on a successful release, otherwise returns nil.
175
- def get_upload_status(app_id, upload_token)
176
- response = connection.get(upload_status_url(app_id, upload_token)) do |request|
179
+ # Returns the `done` status, as well as a release, error, or nil
180
+ def get_upload_status(operation_name)
181
+ response = connection.get(upload_status_url(operation_name)) do |request|
177
182
  request.headers[AUTHORIZATION] = "Bearer " + @auth_token
178
183
  end
179
- return UploadStatusResponse.new(response.body)
184
+ UploadStatusResponse.new(response.body)
180
185
  end
181
186
 
182
187
  # Get tester UDIDs
183
188
  #
184
189
  # args
185
- # app_id - Firebase App ID
190
+ # app_name - Firebase App resource name
186
191
  #
187
192
  # Returns a list of hashes containing tester device info
188
193
  def get_udids(app_id)
@@ -196,35 +201,96 @@ module Fastlane
196
201
  response.body[:testerUdids] || []
197
202
  end
198
203
 
204
+ # Create testers
205
+ #
206
+ # args
207
+ # project_number - Firebase project number
208
+ # emails - An array of emails to be created as testers. A maximum of
209
+ # 1000 testers can be created at a time.
210
+ #
211
+ def add_testers(project_number, emails)
212
+ payload = { emails: emails }
213
+ connection.post(add_testers_url(project_number), payload.to_json) do |request|
214
+ request.headers[AUTHORIZATION] = "Bearer " + @auth_token
215
+ request.headers[CONTENT_TYPE] = APPLICATION_JSON
216
+ request.headers[CLIENT_VERSION] = client_version_header_value
217
+ end
218
+ rescue Faraday::BadRequestError
219
+ UI.user_error!(ErrorMessage::INVALID_EMAIL_ADDRESS)
220
+ rescue Faraday::ResourceNotFound
221
+ UI.user_error!(ErrorMessage::INVALID_PROJECT)
222
+ rescue Faraday::ClientError => e
223
+ if e.response_status == 429
224
+ UI.user_error!(ErrorMessage::TESTER_LIMIT_VIOLATION)
225
+ else
226
+ raise e
227
+ end
228
+ end
229
+
230
+ # Delete testers
231
+ #
232
+ # args
233
+ # project_number - Firebase project number
234
+ # emails - An array of emails to be deleted as testers. A maximum of
235
+ # 1000 testers can be deleted at a time.
236
+ #
237
+ # Returns the number of testers that were deleted
238
+ def remove_testers(project_number, emails)
239
+ payload = { emails: emails }
240
+ response = connection.post(remove_testers_url(project_number), payload.to_json) do |request|
241
+ request.headers[AUTHORIZATION] = "Bearer " + @auth_token
242
+ request.headers[CONTENT_TYPE] = APPLICATION_JSON
243
+ request.headers[CLIENT_VERSION] = client_version_header_value
244
+ end
245
+ response.body[:emails] ? response.body[:emails].count : 0
246
+ rescue Faraday::ResourceNotFound
247
+ UI.user_error!(ErrorMessage::INVALID_PROJECT)
248
+ end
249
+
199
250
  private
200
251
 
201
- def v1_apps_url(app_id)
252
+ def client_version_header_value
253
+ "fastlane/#{Fastlane::FirebaseAppDistribution::VERSION}"
254
+ end
255
+
256
+ def v1alpha_apps_url(app_id)
202
257
  "/v1alpha/apps/#{app_id}"
203
258
  end
204
259
 
205
- def release_notes_create_url(app_id, release_id)
206
- "#{v1_apps_url(app_id)}/releases/#{release_id}/notes"
260
+ def v1_apps_url(app_name)
261
+ "/v1/#{app_name}"
262
+ end
263
+
264
+ def aab_info_url(app_name)
265
+ "#{v1_apps_url(app_name)}/aabInfo"
207
266
  end
208
267
 
209
- def enable_access_url(app_id, release_id)
210
- "#{v1_apps_url(app_id)}/releases/#{release_id}/enable_access"
268
+ def update_release_notes_url(release_name)
269
+ "/v1/#{release_name}?updateMask=release_notes.text"
211
270
  end
212
271
 
213
- def binary_upload_url(app_id)
214
- "/app-binary-uploads?app_id=#{app_id}"
272
+ def distribute_url(release_name)
273
+ "/v1/#{release_name}:distribute"
215
274
  end
216
275
 
217
- def upload_status_url(app_id, app_token)
218
- "#{v1_apps_url(app_id)}/upload_status/#{app_token}"
276
+ def binary_upload_url(app_name)
277
+ "/upload#{v1_apps_url(app_name)}/releases:upload"
278
+ end
279
+
280
+ def upload_status_url(operation_name)
281
+ "/v1/#{operation_name}"
219
282
  end
220
283
 
221
284
  def get_udids_url(app_id)
222
- "#{v1_apps_url(app_id)}/testers:getTesterUdids"
285
+ "#{v1alpha_apps_url(app_id)}/testers:getTesterUdids"
286
+ end
287
+
288
+ def add_testers_url(project_number)
289
+ "/v1/projects/#{project_number}/testers:batchAdd"
223
290
  end
224
291
 
225
- def get_upload_token(project_number, app_id, binary_path)
226
- binary_hash = Digest::SHA256.hexdigest(read_binary(binary_path))
227
- CGI.escape("projects/#{project_number}/apps/#{app_id}/releases/-/binaries/#{binary_hash}")
292
+ def remove_testers_url(project_number)
293
+ "/v1/projects/#{project_number}/testers:batchRemove"
228
294
  end
229
295
 
230
296
  def connection
@@ -13,28 +13,29 @@ module Fastlane
13
13
  # google_service_path - Absolute path to the Google service account file
14
14
  # firebase_cli_token - Firebase CLI refresh token from login action or
15
15
  # CI environment
16
+ # debug - Whether to enable debug-level logging
16
17
  #
17
18
  # env variables
18
19
  # GOOGLE_APPLICATION_CREDENTIALS - see google_service_path
19
20
  # FIREBASE_TOKEN - see firebase_cli_token
20
21
  #
21
22
  # Crashes if given invalid or missing credentials
22
- def fetch_auth_token(google_service_path, firebase_cli_token)
23
+ def fetch_auth_token(google_service_path, firebase_cli_token, debug = false)
23
24
  if !google_service_path.nil? && !google_service_path.empty?
24
25
  UI.message("Authenticating with --service_credentials_file path parameter: #{google_service_path}")
25
- token = service_account(google_service_path)
26
+ token = service_account(google_service_path, debug)
26
27
  elsif !firebase_cli_token.nil? && !firebase_cli_token.empty?
27
28
  UI.message("Authenticating with --firebase_cli_token parameter")
28
- token = firebase_token(firebase_cli_token)
29
+ token = firebase_token(firebase_cli_token, debug)
29
30
  elsif !ENV["FIREBASE_TOKEN"].nil? && !ENV["FIREBASE_TOKEN"].empty?
30
31
  UI.message("Authenticating with FIREBASE_TOKEN environment variable")
31
- token = firebase_token(ENV["FIREBASE_TOKEN"])
32
+ token = firebase_token(ENV["FIREBASE_TOKEN"], debug)
32
33
  elsif !ENV["GOOGLE_APPLICATION_CREDENTIALS"].nil? && !ENV["GOOGLE_APPLICATION_CREDENTIALS"].empty?
33
34
  UI.message("Authenticating with GOOGLE_APPLICATION_CREDENTIALS environment variable: #{ENV['GOOGLE_APPLICATION_CREDENTIALS']}")
34
- token = service_account(ENV["GOOGLE_APPLICATION_CREDENTIALS"])
35
+ token = service_account(ENV["GOOGLE_APPLICATION_CREDENTIALS"], debug)
35
36
  elsif (refresh_token = refresh_token_from_firebase_tools)
36
37
  UI.message("No authentication method specified. Using cached Firebase CLI credentials.")
37
- token = firebase_token(refresh_token)
38
+ token = firebase_token(refresh_token, debug)
38
39
  else
39
40
  UI.user_error!(ErrorMessage::MISSING_CREDENTIALS)
40
41
  end
@@ -62,7 +63,7 @@ module Fastlane
62
63
  end
63
64
  end
64
65
 
65
- def firebase_token(refresh_token)
66
+ def firebase_token(refresh_token, debug)
66
67
  client = Signet::OAuth2::Client.new(
67
68
  token_credential_uri: TOKEN_CREDENTIAL_URI,
68
69
  client_id: Fastlane::Actions::FirebaseAppDistributionLoginAction::CLIENT_ID,
@@ -71,11 +72,12 @@ module Fastlane
71
72
  )
72
73
  client.fetch_access_token!
73
74
  client.access_token
74
- rescue Signet::AuthorizationError
75
+ rescue Signet::AuthorizationError => error
76
+ log_authorization_error_details(error) if debug
75
77
  UI.user_error!(ErrorMessage::REFRESH_TOKEN_ERROR)
76
78
  end
77
79
 
78
- def service_account(google_service_path)
80
+ def service_account(google_service_path, debug)
79
81
  service_account_credentials = Google::Auth::ServiceAccountCredentials.make_creds(
80
82
  json_key_io: File.open(google_service_path),
81
83
  scope: Fastlane::Actions::FirebaseAppDistributionLoginAction::SCOPE
@@ -83,9 +85,16 @@ module Fastlane
83
85
  service_account_credentials.fetch_access_token!["access_token"]
84
86
  rescue Errno::ENOENT
85
87
  UI.user_error!("#{ErrorMessage::SERVICE_CREDENTIALS_NOT_FOUND}: #{google_service_path}")
86
- rescue Signet::AuthorizationError
88
+ rescue Signet::AuthorizationError => error
89
+ log_authorization_error_details(error) if debug
87
90
  UI.user_error!("#{ErrorMessage::SERVICE_CREDENTIALS_ERROR}: #{google_service_path}")
88
91
  end
92
+
93
+ def log_authorization_error_details(error)
94
+ UI.error("Error fetching access token:")
95
+ UI.error(error.message)
96
+ UI.error("Response status: #{error.response.status}")
97
+ end
89
98
  end
90
99
  end
91
100
  end
@@ -7,19 +7,19 @@ module ErrorMessage
7
7
  UPLOAD_TESTERS_ERROR = "App Distribution halted because it had a problem adding testers/groups"
8
8
  GET_RELEASE_TIMEOUT = "App Distribution failed to fetch release information"
9
9
  REFRESH_TOKEN_ERROR = "App Distribution could not generate credentials from the refresh token specified."
10
- GET_APP_ERROR = "App Distribution failed to fetch app information"
11
10
  APP_NOT_ONBOARDED_ERROR = "App Distribution not onboarded"
12
- GET_APP_NO_CONTACT_EMAIL_ERROR = "App Distribution could not find a contact email associated with this app. Contact Email"
13
11
  INVALID_APP_ID = "App Distribution could not find your app. Make sure to onboard your app by pressing the \"Get started\" button on the App Distribution page in the Firebase console: https://console.firebase.google.com/project/_/appdistribution. App ID"
12
+ INVALID_PROJECT = "App Distribution could not find your Firebase project. Make sure to onboard an app in your project by pressing the \"Get started\" button on the App Distribution page in the Firebase console: https://console.firebase.google.com/project/_/appdistribution."
14
13
  INVALID_PATH = "Could not read content from"
15
14
  INVALID_TESTERS = "Could not enable access for testers. Check that the groups exist and the tester emails are formatted correctly"
16
- INVALID_RELEASE_ID = "App distribution failed to fetch release with id"
17
15
  INVALID_RELEASE_NOTES = "Failed to add release notes"
18
16
  SERVICE_CREDENTIALS_ERROR = "App Distribution could not generate credentials from the service credentials file specified. Service Account Path"
19
17
  PLAY_ACCOUNT_NOT_LINKED = "This project is not linked to a Google Play account."
20
18
  APP_NOT_PUBLISHED = "This app is not published in the Google Play console."
21
19
  NO_APP_WITH_GIVEN_BUNDLE_ID_IN_PLAY_ACCOUNT = "App with matching package name does not exist in Google Play."
22
20
  PLAY_IAS_TERMS_NOT_ACCEPTED = "You must accept the Play Internal App Sharing (IAS) terms to upload AABs."
21
+ INVALID_EMAIL_ADDRESS = "You passed an invalid email address."
22
+ TESTER_LIMIT_VIOLATION = "Creating testers would exceed tester limit"
23
23
 
24
24
  def self.aab_upload_error(aab_state)
25
25
  "Failed to process the AAB: #{aab_state}"
@@ -44,6 +44,15 @@ module Fastlane
44
44
  UI.shell_error!("can't extract GOOGLE_APP_ID") if identifier.empty?
45
45
  return identifier
46
46
  end
47
+
48
+ def blank?(value)
49
+ # Taken from https://apidock.com/rails/Object/blank%3F
50
+ value.respond_to?(:empty?) ? value.empty? : !value
51
+ end
52
+
53
+ def present?(value)
54
+ !blank?(value)
55
+ end
47
56
  end
48
57
  end
49
58
  end
@@ -3,35 +3,63 @@ class UploadStatusResponse
3
3
  @response_json_hash = response_json_hash
4
4
  end
5
5
 
6
+ def done
7
+ !!@response_json_hash[:done]
8
+ end
9
+
10
+ def response
11
+ @response_json_hash[:response]
12
+ end
13
+
14
+ def release
15
+ response ? response[:release] : nil
16
+ end
17
+
18
+ def release_name
19
+ release ? release[:name] : nil
20
+ end
21
+
22
+ def release_version
23
+ if release
24
+ if release[:displayVersion] && release[:buildVersion]
25
+ "#{release[:displayVersion]} (#{release[:buildVersion]})"
26
+ elsif release[:displayVersion]
27
+ release[:displayVersion]
28
+ else
29
+ release[:buildVersion]
30
+ end
31
+ end
32
+ end
33
+
6
34
  def status
7
- @response_json_hash[:status]
35
+ response ? response[:result] : nil
8
36
  end
9
37
 
10
- def success?
11
- status == 'SUCCESS'
38
+ def error
39
+ @response_json_hash[:error]
12
40
  end
13
41
 
14
- def in_progress?
15
- status == 'IN_PROGRESS'
42
+ def error_message
43
+ error ? error[:message] : nil
16
44
  end
17
45
 
18
- def error?
19
- status == 'ERROR'
46
+ def success?
47
+ done && !!release
20
48
  end
21
49
 
22
- def already_uploaded?
23
- status == 'ALREADY_UPLOADED'
50
+ def in_progress?
51
+ !done
24
52
  end
25
53
 
26
- def release_hash
27
- @response_json_hash[:release]
54
+ def error?
55
+ done && message
28
56
  end
29
57
 
30
- def release_id
31
- release_hash ? release_hash[:id] : nil
58
+ def release_updated?
59
+ done && status == 'RELEASE_UPDATED'
32
60
  end
33
61
 
34
- def message
35
- @response_json_hash[:message]
62
+ def release_unmodified?
63
+ done && status == 'RELEASE_UNMODIFIED'
36
64
  end
37
65
  end
@@ -1,5 +1,5 @@
1
1
  module Fastlane
2
2
  module FirebaseAppDistribution
3
- VERSION = "0.2.9"
3
+ VERSION = "0.3.1"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fastlane-plugin-firebase_app_distribution
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.9
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Natchev
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2021-05-14 00:00:00.000000000 Z
13
+ date: 2021-08-27 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: pry
@@ -151,10 +151,11 @@ files:
151
151
  - README.md
152
152
  - lib/fastlane/plugin/firebase_app_distribution.rb
153
153
  - lib/fastlane/plugin/firebase_app_distribution/actions/firebase_app_distribution_action.rb
154
+ - lib/fastlane/plugin/firebase_app_distribution/actions/firebase_app_distribution_add_testers_action.rb
154
155
  - lib/fastlane/plugin/firebase_app_distribution/actions/firebase_app_distribution_get_udids.rb
155
156
  - lib/fastlane/plugin/firebase_app_distribution/actions/firebase_app_distribution_login.rb
156
- - lib/fastlane/plugin/firebase_app_distribution/client/aab_certificate.rb
157
- - lib/fastlane/plugin/firebase_app_distribution/client/app.rb
157
+ - lib/fastlane/plugin/firebase_app_distribution/actions/firebase_app_distribution_remove_testers_action.rb
158
+ - lib/fastlane/plugin/firebase_app_distribution/client/aab_info.rb
158
159
  - lib/fastlane/plugin/firebase_app_distribution/client/error_response.rb
159
160
  - lib/fastlane/plugin/firebase_app_distribution/client/firebase_app_distribution_api_client.rb
160
161
  - lib/fastlane/plugin/firebase_app_distribution/helper/firebase_app_distribution_auth_client.rb
@@ -1,23 +0,0 @@
1
- class AabCertificate
2
- def initialize(response)
3
- @response = response || {}
4
- end
5
-
6
- def md5_certificate_hash
7
- @response[:certificateHashMd5]
8
- end
9
-
10
- def sha1_certificate_hash
11
- @response[:certificateHashSha1]
12
- end
13
-
14
- def sha256_certificate_hash
15
- @response[:certificateHashSha256]
16
- end
17
-
18
- def empty?
19
- (md5_certificate_hash.nil? || md5_certificate_hash.empty?) &&
20
- (sha1_certificate_hash.nil? || sha1_certificate_hash.empty?) &&
21
- (sha256_certificate_hash.nil? || sha256_certificate_hash.empty?)
22
- end
23
- end
@@ -1,37 +0,0 @@
1
- require_relative 'aab_certificate'
2
-
3
- class App
4
- # AAB states
5
- class AabState
6
- UNSPECIFIED = "AAB_STATE_UNSPECIFIED"
7
- PLAY_ACCOUNT_NOT_LINKED = "PLAY_ACCOUNT_NOT_LINKED"
8
- NO_APP_WITH_GIVEN_BUNDLE_ID_IN_PLAY_ACCOUNT = "NO_APP_WITH_GIVEN_BUNDLE_ID_IN_PLAY_ACCOUNT"
9
- APP_NOT_PUBLISHED = "APP_NOT_PUBLISHED"
10
- PLAY_IAS_TERMS_NOT_ACCEPTED = "PLAY_IAS_TERMS_NOT_ACCEPTED"
11
- ACTIVE = "ACTIVE"
12
- UNAVAILABLE = "AAB_STATE_UNAVAILABLE"
13
- end
14
-
15
- attr_reader :aab_certificate
16
-
17
- def initialize(response)
18
- @response = response
19
- @aab_certificate = AabCertificate.new(response[:aabCertificate])
20
- end
21
-
22
- def app_id
23
- @response[:appId]
24
- end
25
-
26
- def project_number
27
- @response[:projectNumber]
28
- end
29
-
30
- def contact_email
31
- @response[:contactEmail]
32
- end
33
-
34
- def aab_state
35
- @response[:aabState]
36
- end
37
- end