module Fastlane module Actions class UploadAppPrivacyDetailsToAppStoreAction < Action DEFAULT_PATH = Fastlane::Helper.fastlane_enabled_folder_path DEFAULT_FILE_NAME = "app_privacy_details.json" def self.run(params) require 'spaceship' # Prompts select team if multiple teams and none specified UI.message("Login to App Store Connect (#{params[:username]})") Spaceship::ConnectAPI.login(params[:username], use_portal: false, use_tunes: true, tunes_team_id: params[:team_id], team_name: params[:team_name]) UI.message("Login successful") # Get App app = Spaceship::ConnectAPI::App.find(params[:app_identifier]) unless app UI.user_error!("Could not find app with bundle identifier '#{params[:app_identifier]}' on account #{params[:username]}") end # Attempt to load JSON file usages_config = load_json_file(params) # Start interactive questions to generate and save JSON file unless usages_config usages_config = ask_interactive_questions_for_json if params[:skip_json_file_saving] UI.message("Skipping JSON file saving...") else json = JSON.pretty_generate(usages_config) path = output_path(params) UI.message("Writing file to #{path}") File.write(path, json) end end # Process JSON file to save app data usages to API if params[:skip_upload] UI.message("Skipping uploading of data... (so you can verify your JSON file)") else upload_app_data_usages(params, app, usages_config) end end def self.load_json_file(params) path = params[:json_path] return nil if path.nil? return JSON.parse(File.read(path)) end def self.output_path(params) path = params[:output_json_path] return File.absolute_path(path) end def self.ask_interactive_questions_for_json(show_intro = true) if show_intro UI.important("You did not provide a JSON file for updating the app data usages") UI.important("fastlane will now run you through interactive question to generate the JSON file") UI.important("") UI.important("This JSON file can be saved in source control and used in this action with the :json_file option") unless UI.confirm("Ready to start?") UI.user_error!("Cancelled") end end # Fetch categories and purposes used for generating interactive questions categories = Spaceship::ConnectAPI::AppDataUsageCategory.all(includes: "grouping") purposes = Spaceship::ConnectAPI::AppDataUsagePurpose.all json = [] unless UI.confirm("Are you collecting data?") json << { "data_protections" => [Spaceship::ConnectAPI::AppDataUsageDataProtection::ID::DATA_NOT_COLLECTED] } return json end categories.each do |category| # Ask if using category next unless UI.confirm("Collect data for #{category.id}?") purpose_names = purposes.map(&:id).join(', ') UI.message("How will this data be used? You'll be offered with #{purpose_names}") # Ask purposes selected_purposes = [] loop do purposes.each do |purpose| selected_purposes << purpose if UI.confirm("Used for #{purpose.id}?") end break unless selected_purposes.empty? break unless UI.confirm("No purposes selected. Do you want to try again?") end # Skip asking protections if purposes were skipped next if selected_purposes.empty? # Ask protections is_linked_to_user = UI.confirm("Is #{category.id} linked to the user?") is_used_for_tracking = UI.confirm("Is #{category.id} used for tracking purposes?") # Map answers to values for API requests protection_id = is_linked_to_user ? Spaceship::ConnectAPI::AppDataUsageDataProtection::ID::DATA_LINKED_TO_YOU : Spaceship::ConnectAPI::AppDataUsageDataProtection::ID::DATA_NOT_LINKED_TO_YOU tracking_id = is_used_for_tracking ? Spaceship::ConnectAPI::AppDataUsageDataProtection::ID::DATA_USED_TO_TRACK_YOU : nil json << { "category" => category.id, "purposes" => selected_purposes.map(&:id).sort.uniq, "data_protections" => [ protection_id, tracking_id ].compact.sort.uniq } end json.sort_by! { |c| c["category"] } # Recursively call this method if no categories were selected for data collection if json.empty? UI.error("No categories were selected for data collection.") json = ask_interactive_questions_for_json(false) end return json end def self.upload_app_data_usages(params, app, usages_config) UI.message("Preparing to upload App Data Usage") # Delete all existing usages for new ones all_usages = Spaceship::ConnectAPI::AppDataUsage.all(app_id: app.id, includes: "category,grouping,purpose,dataProtection", limit: 500) all_usages.each(&:delete!) usages_config.each do |usage_config| category = usage_config["category"] purposes = usage_config["purposes"] || [] data_protections = usage_config["data_protections"] || [] # There will not be any purposes if "not collecting data" # However, an AppDataUsage still needs to be created for not collecting data # Creating an array with nil so that purposes can be iterated over and # that AppDataUsage can be created purposes = [nil] if purposes.empty? purposes.each do |purpose| data_protections.each do |data_protection| if data_protection == Spaceship::ConnectAPI::AppDataUsageDataProtection::ID::DATA_NOT_COLLECTED UI.message("Setting #{data_protection}") else UI.message("Setting #{category} and #{purpose} to #{data_protection}") end Spaceship::ConnectAPI::AppDataUsage.create( app_id: app.id, app_data_usage_category_id: category, app_data_usage_protection_id: data_protection, app_data_usage_purpose_id: purpose ) end end end # Publish if params[:skip_publish] UI.message("Skipping app data usage publishing... (so you can verify on App Store Connect)") else publish_state = Spaceship::ConnectAPI::AppDataUsagesPublishState.get(app_id: app.id) if publish_state.published UI.important("App data usage is already published") else UI.important("App data usage not published! Going to publish...") publish_state.publish! UI.important("App data usage is now published") end end end def self.description "Upload App Privacy Details for an app in App Store Connect" end def self.available_options user = CredentialsManager::AppfileConfig.try_fetch_value(:itunes_connect_id) user ||= CredentialsManager::AppfileConfig.try_fetch_value(:apple_id) [ FastlaneCore::ConfigItem.new(key: :username, env_name: "FASTLANE_USER", description: "Your Apple ID Username for App Store Connect", default_value: user, default_value_dynamic: true), FastlaneCore::ConfigItem.new(key: :app_identifier, env_name: "UPLOAD_APP_PRIVACY_DETAILS_TO_APP_STORE_APP_IDENTIFIER", description: "The bundle identifier of your app", code_gen_sensitive: true, default_value: CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier), default_value_dynamic: true), FastlaneCore::ConfigItem.new(key: :team_id, env_name: "FASTLANE_ITC_TEAM_ID", description: "The ID of your App Store Connect team if you're in multiple teams", optional: true, skip_type_validation: true, # as we also allow integers, which we convert to strings anyway code_gen_sensitive: true, default_value: CredentialsManager::AppfileConfig.try_fetch_value(:itc_team_id), default_value_dynamic: true), FastlaneCore::ConfigItem.new(key: :team_name, env_name: "FASTLANE_ITC_TEAM_NAME", description: "The name of your App Store Connect team if you're in multiple teams", optional: true, code_gen_sensitive: true, default_value: CredentialsManager::AppfileConfig.try_fetch_value(:itc_team_name), default_value_dynamic: true), # JSON paths FastlaneCore::ConfigItem.new(key: :json_path, env_name: "UPLOAD_APP_PRIVACY_DETAILS_TO_APP_STORE_JSON_PATH", description: "Path to the app usage data JSON", optional: true, verify_block: proc do |value| UI.user_error!("Could not find JSON file at path '#{File.expand_path(value)}'") unless File.exist?(value) UI.user_error!("'#{value}' doesn't seem to be a JSON file") unless FastlaneCore::Helper.json_file?(File.expand_path(value)) end), FastlaneCore::ConfigItem.new(key: :output_json_path, env_name: "UPLOAD_APP_PRIVACY_DETAILS_TO_APP_STORE_OUTPUT_JSON_PATH", description: "Path to the app usage data JSON file generated by interactive questions", conflicting_options: [:skip_json_file_saving], default_value: File.join(DEFAULT_PATH, DEFAULT_FILE_NAME)), # Skipping options FastlaneCore::ConfigItem.new(key: :skip_json_file_saving, env_name: "UPLOAD_APP_PRIVACY_DETAILS_TO_APP_STORE_OUTPUT_SKIP_JSON_FILE_SAVING", description: "Whether to skip the saving of the JSON file", conflicting_options: [:skip_output_json_path], type: Boolean, default_value: false), FastlaneCore::ConfigItem.new(key: :skip_upload, env_name: "UPLOAD_APP_PRIVACY_DETAILS_TO_APP_STORE_OUTPUT_SKIP_UPLOAD", description: "Whether to skip the upload and only create the JSON file with interactive questions", conflicting_options: [:skip_publish], type: Boolean, default_value: false), FastlaneCore::ConfigItem.new(key: :skip_publish, env_name: "UPLOAD_APP_PRIVACY_DETAILS_TO_APP_STORE_OUTPUT_SKIP_PUBLISH", description: "Whether to skip the publishing", conflicting_options: [:skip_upload], type: Boolean, default_value: false) ] end def self.author "joshdholtz" end def self.is_supported?(platform) [:ios, :mac, :tvos].include?(platform) end def self.details "Upload App Privacy Details for an app in App Store Connect. For more detail information, view https://docs.fastlane.tools/uploading-app-privacy-details" end def self.example_code [ 'upload_app_privacy_details_to_app_store( username: "your@email.com", team_name: "Your Team", app_identifier: "com.your.bundle" )', 'upload_app_privacy_details_to_app_store( username: "your@email.com", team_name: "Your Team", app_identifier: "com.your.bundle", json_path: "fastlane/app_data_usages.json" )' ] end def self.category :production end end end end