Skip to content
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

Merged
merged 7 commits into from
Sep 15, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions supply/lib/supply.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require 'supply/client'
require 'supply/listing'
require 'supply/apk_listing'
require 'supply/image_listing'
require 'supply/generated_universal_apk'
require 'supply/release_listing'
require 'supply/uploader'
Expand Down
44 changes: 24 additions & 20 deletions supply/lib/supply/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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!

Expand All @@ -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}'")
Comment on lines -549 to -560
Copy link
Contributor Author

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.


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)
Copy link
Member

Choose a reason for hiding this comment

The 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`)
Copy link
Member

Choose a reason for hiding this comment

The 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!

Expand Down Expand Up @@ -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!

Expand Down
15 changes: 15 additions & 0 deletions supply/lib/supply/image_listing.rb
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
6 changes: 6 additions & 0 deletions supply/lib/supply/options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we give it same name as deliver's option name (and a similar name to the env var)? I think that would make the API more familiar to devs that maintain fastlane for both platforms, or are just curious reading the Fastfile, etc. That'd make this sync_screenshots and SUPPLY_SYNC_SCREENSHOTS

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I didn't name it sync_screenshots is deliberate: this is because this not only applies to screenshots, but also to Play Store artwork — typically the featuredGraphic and icon images that are not screenshots but rather the banner and large icon shown in the Google Play's download page for the app.

I thought calling it sync_screenshots would make people think that this only applied to screenshots (as in skip_upload_screenshots: false) while it also applies to skip_upload_images: false too.
Arguably, because the app artwork is referenced by the term …images… in the skip_upload_images option, one could argue that calling my new parameter sync_image_upload would make people think that it applies to that option only (i.e. where image term here would be interpreted as "the artwork images uploaded as part of skip_upload_images: false), while the intent of the term …image… in sync_image_upload here is more to say "sync the upload of every kind of [PNG, JPEG, …] image file".

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 deliver and supply option names in sync, that ship as sadly already sailed: For example, deliver uses skip_screenshots while supply uses skip_upload_screenshots (and skip_upload_images), with _upload_ in that option name for supply while deliver doesn't have it 🤷

Copy link
Member

Choose a reason for hiding this comment

The 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,
Expand Down
2 changes: 1 addition & 1 deletion supply/lib/supply/setup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def download_images(listing)

UI.message("Downloading `#{image_type}` for #{listing.language}...")

urls = client.fetch_images(image_type: image_type, language: listing.language)
urls = client.fetch_images(image_type: image_type, language: listing.language).map(&:url)
next if urls.nil? || urls.empty?

image_counter = 1 # Used to prefix the downloaded files, so order is preserved.
Expand Down
37 changes: 32 additions & 5 deletions supply/lib/supply/uploader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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...")
Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 😅.
Not sure if it's worth opening a subsequent PR just to fix this wording? (but if you feel strongly about it I don't mind us doing so to fix it)

Copy link
Member

Choose a reason for hiding this comment

The 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)
Expand All @@ -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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sadly, there doesn't seem to be an Google API on AndroidPublisherService that allows to just reorder the screenshots, hence the need to delete the screenshots that are out of order and re-upload them, so that when new uploaded screenshots go at the end of the list, the final order is still correct in Google Play.

This is why I only compare with the checksum of the next local file (well, in practice, checksum of paths.first, while paths items are being shift'ed and removed from the paths array as they get skipped), instead of using something like all_local_checksums.include?(image.sha256) to just check if the remote image matches an existing local image, which would not guarantee that we'd end up with the expected order of the screenshots in the end.

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)
Expand Down
1 change: 1 addition & 0 deletions supply/spec/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
expect(subject.class.method_defined?(:list_edit_images)).to eq(true)
expect(subject.class.method_defined?(:upload_edit_image)).to eq(true)
expect(subject.class.method_defined?(:deleteall_edit_image)).to eq(true)
expect(subject.class.method_defined?(:delete_edit_image)).to eq(true)
expect(subject.class.method_defined?(:upload_edit_expansionfile)).to eq(true)
expect(subject.class.method_defined?(:uploadapk_internalappsharingartifact)).to eq(true)
expect(subject.class.method_defined?(:uploadbundle_internalappsharingartifact)).to eq(true)
Expand Down
195 changes: 195 additions & 0 deletions supply/spec/uploader_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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:

  • On the Play Store you initially had the images that correspond to image0.png, image1.png, image2.png, image4.png, image3.png, image5.png, image6.png, currently appearing in that order in the Play Store
  • But since then, you decided to swap the order of image4.png and image3.png and now you want to re-upload the images (which will be uploaded in alphabetical order, as supply has always done)
  • This will thus:
    • Keep image0.png, image1.png and image2.png untouched
    • Delete the image4.png, which is currently after image2.png in PlayStore — because it's now out of order compared to the local files
    • But keep image3.png in PlayStore (that came after image4.png, but will now come after image2.png since image4.png was deleted from the PlayStore edit)
    • And then delete image5.png and image6.png, to finally re-upload image4.png, image5.png and image6.png in the correct order.

This looks like this when running this spec with DEBUG=1 bundle exec rspec supply/spec/uploader_spec.rb:

…
[22:30:47]: 🔍 Checking phoneScreenshots checksums...
[22:30:47]: 🟰 Skipping upload of screenshot image0.png as remote sha256 matches.
[22:30:47]: 🟰 Skipping upload of screenshot image1.png as remote sha256 matches.
[22:30:47]: 🟰 Skipping upload of screenshot image2.png as remote sha256 matches.
[22:30:47]: 🚮 Deleting pt-BR screenshot id #id_phoneScreenshots_image4.png as it does not exist locally or is out of order...
[22:30:47]: 🟰 Skipping upload of screenshot image3.png as remote sha256 matches.
[22:30:47]: 🚮 Deleting pt-BR screenshot id #id_phoneScreenshots_image5.png as it does not exist locally or is out of order...
[22:30:47]: 🚮 Deleting pt-BR screenshot id #id_phoneScreenshots_image6.png as it does not exist locally or is out of order...
[22:30:47]: ⬆️  Uploading screenshot image4.png...
[22:30:47]: ⬆️  Uploading screenshot image5.png...
[22:30:47]: ⬆️  Uploading screenshot image6.png...
…

end
end
end
end