-
Notifications
You must be signed in to change notification settings - Fork 5.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[supply] introduce a new synchronization logic for screenshots #21498
Changes from 2 commits
c89d346
5bad9ce
0df5632
fba7f23
c0ee77d
51c147e
7f10c95
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -532,6 +532,11 @@ def update_obb(apk_version_code, expansion_file_type, references_version, file_s | |
# @!group Screenshots | ||
##################################################### | ||
|
||
# @param [String] image_type Typically one of the elements of either Supply::IMAGES_TYPES or Supply::SCREENSHOT_TYPES | ||
# @param [String] language Localization code (a BCP-47 language tag; for example, "de-AT" for Austrian German). | ||
# | ||
# @return [Array<ImageListing>] A list of PlayStoreImage instances describing each image | ||
# | ||
def fetch_images(image_type: nil, language: nil) | ||
ensure_active_edit! | ||
|
||
|
@@ -544,29 +549,14 @@ def fetch_images(image_type: nil, language: nil) | |
) | ||
end | ||
|
||
urls = (result.images || []).map(&:url) | ||
images = urls.map do |url| | ||
uri = URI.parse(url) | ||
clean_url = [ | ||
uri.scheme, | ||
uri.userinfo, | ||
uri.host, | ||
uri.port, | ||
uri.path | ||
].join | ||
|
||
UI.verbose("Initial URL received: '#{url}'") | ||
UI.verbose("Removed params ('#{uri.query}') from the URL") | ||
UI.verbose("URL after removing params: '#{clean_url}'") | ||
|
||
full_url = "#{url}=s0" # '=s0' param ensures full image size is returned (https://github.com/fastlane/fastlane/pull/14322#issuecomment-473012462) | ||
full_url | ||
(result.images || []).map do |row| | ||
full_url = "#{row.url}=s0" # '=s0' param ensures full image size is returned (https://github.com/fastlane/fastlane/pull/14322#issuecomment-473012462) | ||
ImageListing.new(row.id, row.sha1, row.sha256, full_url) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Really nice find to see it has all this info (sha256, etc) 🔥 |
||
end | ||
|
||
return images | ||
end | ||
|
||
# @param image_type (e.g. phoneScreenshots, sevenInchScreenshots, ...) | ||
# @param [String] image_type (e.g. phoneScreenshots, sevenInchScreenshots, ...) | ||
# @param [String] language localization code (i.e. BCP-47 language tag as in `pt-BR`) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🇧🇷 🥳 |
||
def upload_image(image_path: nil, image_type: nil, language: nil) | ||
ensure_active_edit! | ||
|
||
|
@@ -595,6 +585,20 @@ def clear_screenshots(image_type: nil, language: nil) | |
end | ||
end | ||
|
||
def clear_screenshot(image_type: nil, language: nil, image_id: nil) | ||
ensure_active_edit! | ||
|
||
call_google_api do | ||
client.delete_edit_image( | ||
current_package_name, | ||
current_edit.id, | ||
language, | ||
image_type, | ||
image_id | ||
) | ||
end | ||
end | ||
|
||
def upload_obb(obb_file_path: nil, apk_version_code: nil, expansion_file_type: nil) | ||
ensure_active_edit! | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
module Supply | ||
class ImageListing | ||
attr_reader :id | ||
attr_reader :sha1 | ||
attr_reader :sha256 | ||
attr_reader :url | ||
|
||
def initialize(id, sha1, sha256, url) | ||
@id = id | ||
@sha1 = sha1 | ||
@sha256 = sha256 | ||
@url = url | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -210,6 +210,12 @@ def self.available_options | |
description: "Whether to skip uploading SCREENSHOTS", | ||
type: Boolean, | ||
default_value: false), | ||
FastlaneCore::ConfigItem.new(key: :sync_image_upload, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we give it same name as There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The reason I didn't name it I thought calling it But I couldn't find a term that would encompass both "each image file, be it a screenshot or an artwork image" other than just the term "image", hence settling for this. As for trying to make the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good points! 😅 I think you named it well 😊 Thanks for clarifying! |
||
env_name: "SUPPLY_SYNC_IMAGE_UPLOAD", | ||
optional: true, | ||
AliSoftware marked this conversation as resolved.
Show resolved
Hide resolved
|
||
description: "Weather to use sha256 comparison to skip upload of images and screenshots that are already in Play Store", | ||
AliSoftware marked this conversation as resolved.
Show resolved
Hide resolved
|
||
type: Boolean, | ||
default_value: false), | ||
FastlaneCore::ConfigItem.new(key: :track_promote_to, | ||
env_name: "SUPPLY_TRACK_PROMOTE_TO", | ||
optional: true, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -270,7 +270,17 @@ def upload_images(language) | |
path = Dir.glob(search, File::FNM_CASEFOLD).last | ||
next unless path | ||
|
||
UI.message("Uploading image file #{path}...") | ||
if Supply.config[:sync_image_upload] | ||
UI.message("🔍 Checking #{image_type} checksum...") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wouldn't call it a checksum I think. The term is usually used to check whether the fetched is what the sender sent (checking data integrity, so see if it didn't get corrupted during the transfer, or altered by a middleman, etc). Here I'd probably just call "Checking […] hash…" or "Comparing existing image with remote one…" or something like that? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair point. I've already merged the PR though (in the hope that we'd include this PR in the new release that @joshdholtz said he'd do later today to fix the Google API random errors as we talked about in Slack), so it's a bit too late 😅. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Either way works for me! Not strongly opinionated, just think it'd be more befitting for what's actually happening :) I'll leave this up to you! If you decide to open the PR, tag me to review and I'll approve and merge it asap 😊 |
||
existing_images = client.fetch_images(image_type: image_type, language: language) | ||
sha256 = Digest::SHA256.file(path).hexdigest | ||
if existing_images.map(&:sha256).include?(sha256) | ||
UI.message("🟰 Skipping upload of screenshot #{path} as remote sha256 matches.") | ||
next | ||
end | ||
end | ||
|
||
UI.message("⬆️ Uploading image file #{path}...") | ||
client.upload_image(image_path: File.expand_path(path), | ||
image_type: image_type, | ||
language: language) | ||
|
@@ -280,13 +290,30 @@ def upload_images(language) | |
def upload_screenshots(language) | ||
Supply::SCREENSHOT_TYPES.each do |screenshot_type| | ||
search = File.join(metadata_path, language, Supply::IMAGES_FOLDER_NAME, screenshot_type, "*.#{IMAGE_FILE_EXTENSIONS}") | ||
paths = Dir.glob(search, File::FNM_CASEFOLD) | ||
paths = Dir.glob(search, File::FNM_CASEFOLD).sort | ||
next unless paths.count > 0 | ||
|
||
client.clear_screenshots(image_type: screenshot_type, language: language) | ||
if Supply.config[:sync_image_upload] | ||
UI.message("🔍 Checking #{screenshot_type} checksums...") | ||
existing_images = client.fetch_images(image_type: screenshot_type, language: language) | ||
# Don't keep images that either don't exist locally, or that are out of order compared to the `paths` to upload | ||
first_path_checksum = Digest::SHA256.file(paths.first).hexdigest | ||
existing_images.each do |image| | ||
if image.sha256 == first_path_checksum | ||
UI.message("🟰 Skipping upload of screenshot #{paths.first} as remote sha256 matches.") | ||
paths.shift # Remove first path from the list of paths to be uploaded | ||
first_path_checksum = paths.empty? ? nil : Digest::SHA256.file(paths.first).hexdigest | ||
else | ||
UI.message("🚮 Deleting #{language} screenshot id ##{image.id} as it does not exist locally or is out of order...") | ||
client.clear_screenshot(image_type: screenshot_type, language: language, image_id: image.id) | ||
end | ||
end | ||
Comment on lines
+301
to
+310
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sadly, there doesn't seem to be an Google API on This is why I only compare with the checksum of the next local file (well, in practice, checksum of |
||
else | ||
client.clear_screenshots(image_type: screenshot_type, language: language) unless Supply.config[:sync_image_upload] | ||
AliSoftware marked this conversation as resolved.
Show resolved
Hide resolved
|
||
end | ||
|
||
paths.sort.each do |path| | ||
UI.message("Uploading screenshot #{path}...") | ||
paths.each do |path| | ||
UI.message("⬆️ Uploading screenshot #{path}...") | ||
client.upload_image(image_path: File.expand_path(path), | ||
image_type: screenshot_type, | ||
language: language) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -221,5 +221,200 @@ def find_obbs | |
uploader.perform_upload | ||
end | ||
end | ||
|
||
context 'when sync_image_upload is set' do | ||
let(:client) { double('client') } | ||
let(:language) { 'pt-BR' } | ||
let(:config) { { metadata_path: 'spec_metadata', sync_image_upload: true } } | ||
|
||
before do | ||
Supply.config = config | ||
allow(Supply::Client).to receive(:make_from_config).and_return(client) | ||
end | ||
|
||
describe '#upload_images' do | ||
it 'should upload and replace image if sha256 does not match remote image' do | ||
allow(Digest::SHA256).to receive(:file) { |file| instance_double(Digest::SHA256, hexdigest: "sha256-of-#{file}") } | ||
allow(Dir).to receive(:glob).and_return(['image.png']) | ||
remote_images = [Supply::ImageListing.new('id123', '_unused_', 'different-remote-sha256', '_unused_')] | ||
|
||
Supply::IMAGES_TYPES.each do |image_type| | ||
allow(client).to receive(:fetch_images).with(image_type: image_type, language: language).and_return(remote_images) | ||
expect(client).to receive(:upload_image).with(image_path: File.expand_path('image.png'), image_type: image_type, language: language) | ||
end | ||
|
||
uploader = Supply::Uploader.new | ||
uploader.upload_images(language) | ||
end | ||
|
||
it 'should skip image upload if sha256 matches remote image' do | ||
allow(Digest::SHA256).to receive(:file) { |file| instance_double(Digest::SHA256, hexdigest: "sha256-of-#{file}") } | ||
allow(Dir).to receive(:glob).and_return(['image.png']) | ||
remote_images = [Supply::ImageListing.new('id123', '_unused_', 'sha256-of-image.png', '_unused_')] | ||
|
||
Supply::IMAGES_TYPES.each do |image_type| | ||
allow(client).to receive(:fetch_images).with(image_type: image_type, language: language).and_return(remote_images) | ||
expect(client).not_to receive(:upload_image).with(image_path: File.expand_path('image.png'), image_type: image_type, language: language) | ||
end | ||
|
||
uploader = Supply::Uploader.new | ||
uploader.upload_images(language) | ||
end | ||
end | ||
|
||
describe '#upload_screenshots' do | ||
it 'should upload and replace all screenshots if no sha256 matches any remote screenshot' do | ||
allow(Digest::SHA256).to receive(:file) { |file| instance_double(Digest::SHA256, hexdigest: "local-sha256-of-#{file}") } | ||
local_images = %w[image1.png image2.png image3.png] | ||
allow(Dir).to receive(:glob).and_return(local_images) | ||
remote_images = [1, 2, 3].map do |idx| | ||
Supply::ImageListing.new("id_#{idx}", '_unused_', "remote-sha256-#{idx}", '_unused_') | ||
end | ||
|
||
Supply::SCREENSHOT_TYPES.each do |screenshot_type| | ||
allow(client).to receive(:fetch_images).with(image_type: screenshot_type, language: language).and_return(remote_images) | ||
remote_images.each do |image| | ||
expect(client).to receive(:clear_screenshot).with(image_type: screenshot_type, language: language, image_id: image.id) | ||
end | ||
local_images.each do |path| | ||
expect(client).to receive(:upload_image).with(image_path: File.expand_path(path), image_type: screenshot_type, language: language) | ||
end | ||
end | ||
|
||
uploader = Supply::Uploader.new | ||
uploader.upload_screenshots(language) | ||
end | ||
|
||
it 'should skip all screenshots if all sha256 matches the remote screenshots' do | ||
allow(Digest::SHA256).to receive(:file) { |file| instance_double(Digest::SHA256, hexdigest: "common-sha256-of-#{file}") } | ||
local_images = %w[image1.png image2.png image3.png] | ||
allow(Dir).to receive(:glob).and_return(local_images) | ||
remote_images = local_images.map do |path| | ||
Supply::ImageListing.new("id_#{path}", '_unused_', "common-sha256-of-#{path}", '_unused_') | ||
end | ||
|
||
Supply::SCREENSHOT_TYPES.each do |screenshot_type| | ||
allow(client).to receive(:fetch_images).with(image_type: screenshot_type, language: language).and_return(remote_images) | ||
remote_images.each do |image| | ||
expect(client).not_to receive(:clear_screenshot).with(image_type: screenshot_type, language: language, image_id: image.id) | ||
end | ||
local_images.each do |path| | ||
expect(client).not_to receive(:upload_image).with(image_path: File.expand_path(path), image_type: screenshot_type, language: language) | ||
end | ||
end | ||
|
||
uploader = Supply::Uploader.new | ||
uploader.upload_screenshots(language) | ||
end | ||
|
||
it 'should delete and re-upload screenshots that changed locally, as long as start of list is in order' do | ||
allow(Digest::SHA256).to receive(:file) { |file| instance_double(Digest::SHA256, hexdigest: "sha256-of-#{file}") } | ||
local_images = %w[image0.png image1.png new-image2.png new-image3.png] | ||
allow(Dir).to receive(:glob).and_return(local_images) | ||
|
||
remote_images = %w[image0.png image1.png old-image2.png old-image3.png].map.with_index do |path, idx| | ||
Supply::ImageListing.new("id_#{idx}", '_unused_', "sha256-of-#{path}", '_unused_') | ||
end | ||
|
||
Supply::SCREENSHOT_TYPES.each do |screenshot_type| | ||
allow(client).to receive(:fetch_images).with(image_type: screenshot_type, language: language).and_return(remote_images) | ||
local_images[0..1].each_with_index do |path, idx| | ||
expect(client).not_to receive(:clear_screenshot).with(image_type: screenshot_type, language: language, image_id: "id_#{idx}") | ||
expect(client).not_to receive(:upload_image).with(image_path: File.expand_path(path), image_type: screenshot_type, language: language) | ||
end | ||
local_images[2..3].each_with_index do |path, idx| | ||
expect(client).to receive(:clear_screenshot).with(image_type: screenshot_type, language: language, image_id: "id_#{idx + 2}") | ||
expect(client).to receive(:upload_image).with(image_path: File.expand_path(path), image_type: screenshot_type, language: language) | ||
end | ||
end | ||
|
||
uploader = Supply::Uploader.new | ||
uploader.upload_screenshots(language) | ||
end | ||
|
||
it 'should delete remote screenshots that are no longer present locally' do | ||
allow(Digest::SHA256).to receive(:file) { |file| instance_double(Digest::SHA256, hexdigest: "common-sha256-of-#{file}") } | ||
local_images = %w[image1.png image2.png image3.png] | ||
allow(Dir).to receive(:glob).and_return(local_images) | ||
|
||
same_remote_images = local_images.map do |path| | ||
Supply::ImageListing.new("id_#{path}", '_unused_', "common-sha256-of-#{path}", '_unused_') | ||
end | ||
extra_remote_image = Supply::ImageListing.new("id_extra", '_unused_', "common-sha256-of-extra-image", '_unused_') | ||
remote_images = [same_remote_images[0], extra_remote_image, same_remote_images[1], same_remote_images[2]] | ||
|
||
Supply::SCREENSHOT_TYPES.each do |screenshot_type| | ||
allow(client).to receive(:fetch_images).with(image_type: screenshot_type, language: language).and_return(remote_images) | ||
same_remote_images.each do |image| | ||
expect(client).not_to receive(:clear_screenshot).with(image_type: screenshot_type, language: language, image_id: image.id) | ||
end | ||
expect(client).to receive(:clear_screenshot).with(image_type: screenshot_type, language: language, image_id: extra_remote_image.id) | ||
local_images.each do |path| | ||
expect(client).not_to receive(:upload_image).with(image_path: File.expand_path(path), image_type: screenshot_type, language: language) | ||
end | ||
end | ||
|
||
uploader = Supply::Uploader.new | ||
uploader.upload_screenshots(language) | ||
end | ||
|
||
it 'should delete screenshots that are out of order and re-upload them in the correct order' do | ||
allow(Digest::SHA256).to receive(:file) { |file| instance_double(Digest::SHA256, hexdigest: "sha256-of-#{file}") } | ||
local_images = %w[image0.png image1.png image2.png image4.png image3.png image5.png image6.png] # those will be sorted after Dir.glob | ||
allow(Dir).to receive(:glob).and_return(local_images) | ||
|
||
# Record the mocked deletions and uploads in list of remote images to check the final state at the end | ||
final_remote_images_ids = {} | ||
allow(client).to receive(:clear_screenshot) do |**args| | ||
image_type = args[:image_id].split('_')[1] | ||
final_remote_images_ids[image_type].delete(args[:image_id]) | ||
end | ||
allow(client).to receive(:upload_image) do |**args| | ||
path = File.basename(args[:image_path]) | ||
image_type = args[:image_type] | ||
final_remote_images_ids[image_type] << "new-id_#{image_type}_#{path}" | ||
end | ||
|
||
Supply::SCREENSHOT_TYPES.each do |screenshot_type| | ||
remote_images = local_images.map do |path| | ||
Supply::ImageListing.new("id_#{screenshot_type}_#{path}", '_unused_', "sha256-of-#{path}", '_unused_') | ||
end # remote images will be in order 0124356 though | ||
allow(client).to receive(:fetch_images).with(image_type: screenshot_type, language: language).and_return(remote_images) | ||
|
||
final_remote_images_ids[screenshot_type] = remote_images.map(&:id) | ||
|
||
# We should skip image0, image1, image2 from remote as they are the same as the first local images, | ||
# But also skip image3 (which was in-between 2 and 3 in remote listing, but is still present in local images) | ||
AliSoftware marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# While deleting image4 (because it was in-between image2 and image3 in the `remote_images`, so out of order) | ||
# And finally deleting image5 and image6, before re-uploading image4, image5 and image6 in the right order | ||
local_images.sort[0..3].each do |path| | ||
expect(client).not_to receive(:clear_screenshot).with(image_type: screenshot_type, language: language, image_id: "id_#{screenshot_type}_#{path}") | ||
expect(client).not_to receive(:upload_image).with(image_path: File.expand_path(path), image_type: screenshot_type, language: language) | ||
end | ||
local_images.sort[4..6].each do |path| | ||
expect(client).to receive(:clear_screenshot).with(image_type: screenshot_type, language: language, image_id: "id_#{screenshot_type}_#{path}") | ||
expect(client).to receive(:upload_image).with(image_path: File.expand_path(path), image_type: screenshot_type, language: language) | ||
end | ||
end | ||
|
||
uploader = Supply::Uploader.new | ||
uploader.upload_screenshots(language) | ||
|
||
# Check the final order of the remote images after the whole skip/delete/upload dance | ||
Supply::SCREENSHOT_TYPES.each do |screenshot_type| | ||
expected_final_images_ids = %W[ | ||
id_#{screenshot_type}_image0.png | ||
id_#{screenshot_type}_image1.png | ||
id_#{screenshot_type}_image2.png | ||
id_#{screenshot_type}_image3.png | ||
new-id_#{screenshot_type}_image4.png | ||
new-id_#{screenshot_type}_image5.png | ||
new-id_#{screenshot_type}_image6.png | ||
] | ||
expect(final_remote_images_ids[screenshot_type]).to eq(expected_final_images_ids) | ||
end | ||
end | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This unit test is the most interesting case I wanted to get right, as it shows a case where you have the same images locally and remotely, but you swapped the order of some of your screenshots:
This looks like this when running this spec with
|
||
end | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All those lines were dead code even before my change: as you can see,
clean_url
was never used in the returned value after all.