Class: Deliver::ItunesConnect

Inherits:
Object
  • Object
show all
Includes:
Capybara::DSL
Defined in:
lib/deliver/itunes_connect/itunes_connect.rb,
lib/deliver/itunes_connect/itunes_connect_login.rb,
lib/deliver/itunes_connect/itunes_connect_helper.rb,
lib/deliver/itunes_connect/itunes_connect_reader.rb,
lib/deliver/itunes_connect/itunes_connect_app_icon.rb,
lib/deliver/itunes_connect/itunes_connect_additional.rb,
lib/deliver/itunes_connect/itunes_connect_app_rating.rb,
lib/deliver/itunes_connect/itunes_connect_submission.rb,
lib/deliver/itunes_connect/itunes_connect_new_version.rb

Overview

Everything related to submitting the app

Defined Under Namespace

Classes: ItunesConnectGeneralError, ItunesConnectLoginError

Constant Summary collapse

ITUNESCONNECT_URL =
"https://itunesconnect.apple.com/"
APP_DETAILS_URL =
"https://itunesconnect.apple.com/WebObjects/iTunesConnect.woa/ra/ng/app/[[app_id]]"
BUTTON_STRING_NEW_VERSION =
"New Version"
BUTTON_STRING_SUBMIT_FOR_REVIEW =
"Submit for Review"
BUTTON_ADD_NEW_BUILD =
'Click + to add a build before you submit your app.'
WAITING_FOR_REVIEW =
"Waiting For Review"
PROCESSING_TEXT =
"Processing"

Instance Method Summary collapse

Constructor Details

#initializeItunesConnect

Returns a new instance of ItunesConnect.



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/deliver/itunes_connect/itunes_connect.rb', line 43

def initialize
  super

  return if Helper.is_test?

  DependencyChecker.check_dependencies
  
  Capybara.run_server = false
  Capybara.default_driver = :poltergeist
  Capybara.javascript_driver = :poltergeist
  Capybara.current_driver = :poltergeist
  Capybara.app_host = ITUNESCONNECT_URL

  # Since Apple has some SSL errors, we have to configure the client properly:
  # https://github.com/ariya/phantomjs/issues/11239
  Capybara.register_driver :poltergeist do |a|
    conf = ['--debug=no', '--ignore-ssl-errors=yes', '--ssl-protocol=TLSv1']
    Capybara::Poltergeist::Driver.new(a, {
      phantomjs_options: conf,
      phantomjs_logger: File.open("/tmp/poltergeist_log.txt", "a"),
      js_errors: false
    })
  end

  page.driver.headers = { "Accept-Language" => "en" }

  
end

Instance Method Details

#create_new_version!(app, version_number) ⇒ Object

This method creates a new version of your app using the iTunesConnect frontend. This will happen directly after calling this method. the new version that should be created

Parameters:

  • app (Deliver::App)

    the app you want to modify

  • version_number (String)

    the version number as string for



9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/deliver/itunes_connect/itunes_connect_new_version.rb', line 9

def create_new_version!(app, version_number)
  begin
    current_version = get_live_version(app)

    verify_app(app)
    open_app_page(app)

    if page.has_content?BUTTON_STRING_NEW_VERSION

      if current_version == version_number
        # This means, this version is already live on the App Store
        raise "Version #{version_number} is already created, submitted and released on iTC. Please verify you're using a new version number."
      end

      click_on BUTTON_STRING_NEW_VERSION

      Helper.log.info "Creating a new version (#{version_number})"
      
      all(".fullWidth.nobottom.ng-isolate-scope.ng-pristine").last.set(version_number.to_s)
      click_on "Create"

      while not page.has_content?"Prepare for Submission"
        sleep 1
        Helper.log.debug("Waiting for 'Prepare for Submission'")
      end
    else
      Helper.log.warn "Can not create version #{version_number} on iTunesConnect. Maybe it was already created."

      begin
        created_version = wait_for_elements("input[ng-model='versionInfo.version.value']").first.value
        if created_version != version_number
          update_existing_version_number!(app, version_number)
        end
      rescue => ex
        # Can not fetch the version number of the new version (this happens, when it's e.g. 'Developer Rejected')
        Helper.log.error ex
        unless page.has_content?version_number
          raise "Some other version was created instead of the one you defined ('#{version_number}')."
        end
      end
    end

    true
  rescue => ex
    error_occured(ex)
  end
end

#get_app_status(app) ⇒ Object

This method will fetch the current status (App::AppStatus) of your app and return it. This method uses a headless browser under the hood, so it might take some time until you get the result

Parameters:

  • app (Deliver::App)

    the app you want this information from

Raises:



11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/deliver/itunes_connect/itunes_connect_reader.rb', line 11

def get_app_status(app)
  begin
    verify_app(app)

    open_app_page(app)

    if page.has_content?WAITING_FOR_REVIEW
      # That's either Upload Received or Waiting for Review
      if page.has_content?"To submit a new build, you must remove this version from review"
        return App::AppStatus::WAITING_FOR_REVIEW
      else
        return App::AppStatus::UPLOAD_RECEIVED
      end
    elsif page.has_content?BUTTON_STRING_NEW_VERSION
      return App::AppStatus::READY_FOR_SALE
    elsif page.has_content?BUTTON_STRING_SUBMIT_FOR_REVIEW
      return App::AppStatus::PREPARE_FOR_SUBMISSION
    else
      raise "App status not yet implemented"
    end
  rescue Exception => ex
    error_occured(ex)
  end
end

#get_live_version(app) ⇒ Object

This method will fetch the version number of the currently live version of your app and return it. This method uses a headless browser under the hood, so it might take some time until you get the result

Parameters:

  • app (Deliver::App)

    the app you want this information from

Raises:



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/deliver/itunes_connect/itunes_connect_reader.rb', line 43

def get_live_version(app)
  begin
    verify_app(app)

    open_app_page(app)

    begin
      version_number = wait_for_elements("input[ng-model='versionInfo.version.value']").first.value
      version_number ||= first(".status.ready").text.split(" ").first
      return version_number
    rescue
      Helper.log.debug "Could not fetch version number of the live version for app #{app}."
      return nil
    end
  rescue => ex
    error_occured(ex)
  end
end

#login(user = nil, password = nil) ⇒ bool

Loggs in a user with the given login data on the iTC Frontend. You don’t need to pass a username and password. It will Automatically be fetched using the CredentialsManager::PasswordManager. This method will also automatically be called when triggering other actions like #open_app_page

Parameters:

  • user (String) (defaults to: nil)

    (optional) The username/email address

  • password (String) (defaults to: nil)

    (optional) The password

Returns:

  • (bool)

    true if everything worked fine

Raises:



15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/deliver/itunes_connect/itunes_connect_login.rb', line 15

def (user = nil, password = nil)
  Helper.log.info "Logging into iTunesConnect"

  user ||= CredentialsManager::PasswordManager.shared_manager.username
  password ||= CredentialsManager::PasswordManager.shared_manager.password

  result = visit ITUNESCONNECT_URL
  raise "Could not open iTunesConnect" unless result['status'] == 'success'

  sleep 3
  
  if page.has_content?"My Apps"
    # Already logged in
    return true
  end

  begin
    wait_for_elements('#accountpassword')
  rescue => ex
    # when the user is already logged in, this will raise an exception
  end

  fill_in "accountname", with: user
  fill_in "accountpassword", with: password

  begin
    (wait_for_elements(".enabled").first.click rescue nil) # Login Button
    wait_for_elements('.homepageWrapper.ng-scope')
    
    if page.has_content?"My Apps"
      # Everything looks good
    else
      raise ItunesConnectLoginError.new("Looks like your login data was correct, but you do not have access to the apps.")
    end
  rescue => ex
    Helper.log.debug(ex)
    raise ItunesConnectLoginError.new("Error logging in user #{user} with the given password. Make sure you entered them correctly.")
  end

  Helper.log.info "Successfully logged into iTunesConnect"

  true
rescue => ex
  error_occured(ex)
end

#put_build_into_beta_testing!(app, version_number) ⇒ Object

This will put the latest uploaded build as a new beta build



6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/deliver/itunes_connect/itunes_connect_submission.rb', line 6

def put_build_into_beta_testing!(app, version_number)
  begin
    verify_app(app)
    open_app_page(app)

    Helper.log.info("Choosing the latest build on iTunesConnect for beta distribution")

    sleep 3
    click_on "Prerelease"
    sleep 3

    wait_for_preprocessing

    # Beta Switches
    if all(".switcher.ng-binding.checked").count == 0
      raise "Looks like Beta Testing is not yet enabled for this app. Open '#{current_url}' and enable TestFlight Beta Testing.".red
    end
    
    if all(".bt-version > a").count == 0
      raise "Couldn't find any builds. Please check the iTunes Conncet page: '#{current_url}'".red
    end

    first(".bt-version > a").click

    email = wait_for_elements("input[ng-model='testinfo.data.details[currentLoc].feedbackEmail.value']").first
    if email.value.to_s.length == 0
      # Some value is needed to actually distribute the beta version
      email.set CredentialsManager::PasswordManager.shared_manager.username
      click_on "Save"
    end

    Helper.log.info "Successfully enabled latest beta build.".green

    return true
  rescue => ex
    error_occured(ex)
  end
end

#put_build_into_production!(app, version_number) ⇒ Object

This will choose the latest uploaded build on iTunesConnect as the production one After this method, you still have to call submit_for_review to actually submit the whole update

Parameters:

  • app (Deliver::App)

    the app you want to choose the build for

  • version_number (String)

    the version number as string for



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/deliver/itunes_connect/itunes_connect_submission.rb', line 50

def put_build_into_production!(app, version_number)
  begin
    verify_app(app)
    open_app_page(app)

    Helper.log.info("Choosing the latest build on iTunesConnect for release")

    click_on "Prerelease"

    wait_for_preprocessing

    ################# Apple is finished processing the ipa file #################

    Helper.log.info("Apple finally finished processing the ipa file")
    open_app_page(app)

    begin
      first('a', :text => BUTTON_ADD_NEW_BUILD).click
      wait_for_elements(".buildModalList")
      sleep 5
    rescue
      if page.has_content?"Upload Date"
        # That's fine, the ipa was already selected
        return true
      else
        raise "Could not find Build Button. It looks like the ipa file was not properly uploaded."
      end
    end

    if page.all('td', :text => version_number).count > 1
      Helper.log.fatal "There were multiple submitted builds found. Don't know which one to choose. Just choosing the top one for now"
    end

    result = page.first('td', :text => version_number).first(:xpath,"./..").first(:css, ".small").click
    click_on "Done" # Save the modal dialog
    click_on "Save" # on the top right to save everything else

    error = page.has_content?BUTTON_ADD_NEW_BUILD
    raise "Could not put build itself onto production. Try opening '#{current_url}'" if error

    return true
  rescue => ex
    error_occured(ex)
  end
end

#set_app_rating!(app, path_to_json) ⇒ Object

Setting the app’s age restrictions



7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/deliver/itunes_connect/itunes_connect_app_rating.rb', line 7

def set_app_rating!(app, path_to_json)
  path_to_json = File.expand_path(path_to_json)
  raise "Could not find app rating JSON file" unless File.exists?(path_to_json)

  config = JSON.parse(File.read(path_to_json))

  verify_app(app)
  open_app_page(app)

  Helper.log.info "Updating the app's rating".green

  first("a[ng-show='versionInfo.ratings.isEditable']").click # open the ratings screen

  rows = wait_for_elements(".defaultTable.ratingsTable > tbody > tr.ng-scope") # .ng-scope, since there is one empty row

  if rows.count != config.count
    raise "The number of options passed in the config file does not match the number of options available on iTC!".red
  end



  # Setting all the values based on config file
  rows.each_with_index do |row, index|
    current = config[index]

    level = name_for_level(current['level'], current['type'] == 'boolean')

    Helper.log.info "Setting '#{current['comment']}' to #{level}.".green

    radio_value = "ITC.apps.ratings.level.#{level}"

    row.first("td > div[radio-value='#{radio_value}']").click
  end

  # Check if there is a warning or error message because of this rating
  error_message = first("p[ng-show='tempPageContent.ratingDialog.showErrorMessage']")
  Helper.log.error error_message.text if error_message

  Helper.log.info "Finished setting updated app rating"
  
  (click_on "Done" rescue nil)

  (click_on "Save" rescue nil) # if nothing has changed, there is no back button and we don't care
rescue => ex
  error_occured(ex)
end

#set_app_review_information!(app, hash) ⇒ Object



18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/deliver/itunes_connect/itunes_connect_additional.rb', line 18

def set_app_review_information!(app, hash)
  verify_app(app)
  open_app_page(app)

  Helper.log.info "Setting review information: #{hash}"

  first("input[ng-model='versionInfo.appReviewInfo.firstName.value']").set                     hash[:first_name]
  first("input[ng-model='versionInfo.appReviewInfo.lastName.value']").set                      hash[:last_name]
  first("input[ng-model='versionInfo.appReviewInfo.phoneNumber.value']").set                   hash[:phone_number]
  first("input[ng-model='versionInfo.appReviewInfo.emailAddress.value']").set                  hash[:email_address]
  first("input[ng-model='versionInfo.appReviewInfo.userName.value']").set                      hash[:demo_user]
  first("input[ng-model='versionInfo.appReviewInfo.password.value']").set                      hash[:demo_password]
  first("span[ng-show='versionInfo.appReviewInfo.reviewNotes.isEditable'] > * > textarea").set hash[:notes]

  (click_on "Save" rescue nil) # if nothing has changed, there is no back button and we don't care

  Helper.log.info "Successfully saved review information".green
rescue => ex
  error_occured(ex)
end

#set_categories!(app, primary, secondary) ⇒ Object



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/deliver/itunes_connect/itunes_connect_additional.rb', line 53

def set_categories!(app, primary, secondary)
  verify_app(app)
  open_app_page(app)

  Helper.log.info "Setting primary/secondary category.'".green
  if primary
    all("select[ng-model='versionInfo.primaryCategory.value'] > option").each do |category|
      if category.text.to_s == primary.to_s
        category.select_option
        primary = nil
        break
      end
    end
    if primary
      Helper.log.info "Could not find category '#{primary}'. Make sure it's available on iTC".red
    end
  end

  if secondary
    all("select[ng-model='versionInfo.secondaryCategory.value'] > option").each do |category|
      if category.text.to_s == secondary.to_s
        category.select_option
        secondary = nil
        break
      end
    end
    if secondary
      Helper.log.info "Could not find category '#{secondary}'. Make sure it's available on iTC".red
    end
  end


  (click_on "Save" rescue nil) # if nothing has changed, there is no back button and we don't care
rescue => ex
  error_occured(ex)
end

#set_copyright!(app, text) ⇒ Object

This file sets additional information like copyright and age rating



5
6
7
8
9
10
11
12
13
14
15
16
# File 'lib/deliver/itunes_connect/itunes_connect_additional.rb', line 5

def set_copyright!(app, text)
  verify_app(app)
  open_app_page(app)

  Helper.log.info "Setting copyright to '#{text}'".green

  first("input[ng-model='versionInfo.copyright.value']").set text
  
  (click_on "Save" rescue nil) # if nothing has changed, there is no back button and we don't care
rescue => ex
  error_occured(ex)
end

#set_release_after_approval!(app, automatic_release) ⇒ Object



39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/deliver/itunes_connect/itunes_connect_additional.rb', line 39

def set_release_after_approval!(app, automatic_release)
  verify_app(app)
  open_app_page(app)

  Helper.log.info "Setting automatic release to '#{automatic_release}'".green

  # Find the correct radio button
  first("div[itc-radio='versionInfo.releaseOnApproval.value'][radio-value='#{automatic_release.to_s}'] > * > a").click

  (click_on "Save" rescue nil) # if nothing has changed, there is no back button and we don't care
rescue => ex
  error_occured(ex)
end

#submit_for_review!(app, perms = nil) ⇒ Object

Submits the update itself to Apple, this includes the app metadata and the ipa file This can easily cause exceptions, which will be shown on iTC.

Parameters:

  • app (Deliver::App)

    the app you want to submit

  • perms (Hash) (defaults to: nil)

    information about content rights, …



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/deliver/itunes_connect/itunes_connect_submission.rb', line 100

def submit_for_review!(app, perms = nil)
  begin
    verify_app(app)
    open_app_page(app)

    Helper.log.info("Submitting app for Review")

    if not page.has_content?BUTTON_STRING_SUBMIT_FOR_REVIEW
      if page.has_content?WAITING_FOR_REVIEW
        Helper.log.info("App is already Waiting For Review")
        return true
      else
        raise "Couldn't find button with name '#{BUTTON_STRING_SUBMIT_FOR_REVIEW}'!"
      end
    end

    click_on BUTTON_STRING_SUBMIT_FOR_REVIEW
    sleep 4

    errors = (all(".pagemessage.error") || []).count > 0
    raise "Some error occured when submitting the app for review: '#{current_url}'" if errors

    wait_for_elements(".savingWrapper.ng-scope.ng-pristine")
    wait_for_elements(".radiostyle")
    sleep 3
    
    if page.has_content?"Content Rights"
      # Looks good.. just a few more steps

      perms ||= {
        export_compliance: {
          encryption_updated: false,
          cryptography_enabled: false,
          is_exempt: false
        },
        third_party_content: {
          contains_third_party_content: false,
          has_rights: false
        },
        advertising_identifier: {
          use_idfa: false,
          serve_advertisement: false,
          attribute_advertisement: false,
          attribute_actions: false,
          limit_ad_tracking: false
        }
      }

      basic = "//*[@itc-radio='submitForReviewAnswers"
      checkbox = "//*[@itc-checkbox='submitForReviewAnswers"

      #####################
      # Export Compliance #
      #####################
      if page.has_content?"Export"
        
        if not perms[:export_compliance][:encryption_updated] and perms[:export_compliance][:cryptography_enabled]
          raise "encryption_updated must be enabled if cryptography_enabled is enabled!"
        end

        begin
          encryption_updated_control = all(:xpath, "#{basic}.exportCompliance.encryptionUpdated.value' and @radio-value='#{perms[:export_compliance][:encryption_updated]}']//input")
          encryption_updated_control[0].trigger('click') if encryption_updated_control.count > 0
          first(:xpath, "#{basic}.exportCompliance.usesEncryption.value' and @radio-value='#{perms[:export_compliance][:cryptography_enabled]}']//input").trigger('click')
          first(:xpath, "#{basic}.exportCompliance.isExempt.value' and @radio-value='#{perms[:export_compliance][:is_exempt]}']//input").trigger('click')
        rescue
        end
      end

      ##################
      # Content Rights #
      ##################
      if page.has_content?"Content Rights"
        if not perms[:third_party_content][:contains_third_party_content] and perms[:third_party_content][:has_rights]
          raise "contains_third_party_content must be enabled if has_rights is enabled".red
        end

        begin
          first(:xpath, "#{basic}.contentRights.containsThirdPartyContent.value' and @radio-value='#{perms[:third_party_content][:contains_third_party_content]}']//input").trigger('click')
          first(:xpath, "#{basic}.contentRights.hasRights.value' and @radio-value='#{perms[:third_party_content][:has_rights]}']//input").trigger('click')
        rescue
        end
      end

      ##########################
      # Advertising Identifier #
      ##########################
      if page.has_content?"Advertising Identifier"
        first(:xpath, "#{basic}.adIdInfo.usesIdfa.value' and @radio-value='#{perms[:advertising_identifier][:use_idfa]}']//a").click rescue nil

        if perms[:advertising_identifier][:use_idfa]
          if perms[:advertising_identifier][:serve_advertisement]
            first(:xpath, "#{checkbox}.adIdInfo.servesAds.value']//a").click
          end
          if perms[:advertising_identifier][:attribute_advertisement]
            first(:xpath, "#{checkbox}.adIdInfo.tracksInstall.value']//a").click
          end
          if perms[:advertising_identifier][:attribute_actions]
            first(:xpath, "#{checkbox}.adIdInfo.tracksAction.value']//a").click
          end
          if perms[:advertising_identifier][:limit_ad_tracking]
            first(:xpath, "#{checkbox}.adIdInfo.limitsTracking.value']//a").click
          end
        end
      end
      

      Helper.log.info("Filled out the export compliance and other information on iTC".green)

      click_on "Submit"
      sleep 5

      if page.has_content?WAITING_FOR_REVIEW
        # Everything worked :)
        Helper.log.info("Successfully submitted App for Review".green)
        return true
      else
        raise "So close, it looks like there went something wrong with the actual deployment. Checkout '#{current_url}'".red
      end
    else
      raise "Something is missing here.".red
    end
    return false
  rescue => ex
    error_occured(ex)
  end
end

#update_existing_version_number!(app, version_number) ⇒ Object



57
58
59
60
61
62
63
64
65
# File 'lib/deliver/itunes_connect/itunes_connect_new_version.rb', line 57

def update_existing_version_number!(app, version_number)
  Helper.log.warn "There is already a new version created, which does not match the current version: '#{version_number}'"

  version_input = wait_for_elements("input[ng-model='versionInfo.version.value']").first
  version_input.set version_number
  click_on "Save"

  Helper.log.warn "Changed the version number of the latest version to '#{version_number}'".green
end

#upload_app_icon!(app, path) ⇒ Object

Uploading a new full size app icon



7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# File 'lib/deliver/itunes_connect/itunes_connect_app_icon.rb', line 7

def upload_app_icon!(app, path)
  path = File.expand_path(path)
  raise "Could not find app icon at path '#{path}'".red unless File.exists?path

  size = FastImage.size(path)
  raise "App icon must have the resolution of 1024x1024px".red unless (size[0] == 1024 and size[1] == 1024)

  begin
    verify_app(app)
    open_app_page(app)

    Helper.log.info "Starting upload of new app icon".green

    evaluate_script("$('.appversionicon > .ios7-style-icon').prev().click()") # delete button
    evaluate_script("$('[style-class=\"appversionicon rounded\"] [itc-launch-filechooser] + input').attr('id', 'deliverFileUploadInput')") # set div
    evaluate_script("URL = webkitURL; URL.createObjectURL = function(){return 'blob:abc'}"); # shim URL
    page.attach_file("deliverFileUploadInput", path) # add file

    sleep 10

    click_on "Save"

    Helper.log.info "Finished uploading the new app icon".green
  rescue => ex
    error_occured(ex)
  end
end