/* * Copyright (c) Facebook, Inc. and its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.facebook.testing.screenshot.internal; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.util.Log; import android.util.Xml; import java.io.BufferedOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.zip.Deflater; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; import javax.annotation.Nullable; import org.xmlpull.v1.XmlSerializer; /** A "local" implementation of Album. */ @SuppressWarnings("deprecation") public class AlbumImpl implements Album { private static final int COMPRESSION_QUALITY = 90; private static final int BUFFER_SIZE = 1 << 16; // 64k private static final String SCREENSHOT_BUNDLE_FILE_NAME = "screenshot_bundle.zip"; private final File mDir; private final Set<String> mAllNames = new HashSet<>(); private ZipOutputStream mZipOutputStream; private XmlSerializer mXmlSerializer; private OutputStream mOutputStream; /* VisibleForTesting */ AlbumImpl(ScreenshotDirectories screenshotDirectories, String name) { mDir = screenshotDirectories.get(name); } /** Creates a "local" album that stores all the images on device. */ public static AlbumImpl create(Context context, String name) { return new AlbumImpl(new ScreenshotDirectories(context), name); } @SuppressLint("SetWorldReadable") private ZipOutputStream getOrCreateZipOutputStream() throws IOException { if (mZipOutputStream == null) { File file = new File(mDir, SCREENSHOT_BUNDLE_FILE_NAME); file.createNewFile(); file.setReadable(/* readable = */ true, /* ownerOnly = */ false); mZipOutputStream = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(file), BUFFER_SIZE)); mZipOutputStream.setLevel(Deflater.NO_COMPRESSION); } return mZipOutputStream; } @Override public void flush() { if (mOutputStream != null) { endXml(); } try { if (mZipOutputStream != null) { mZipOutputStream.closeEntry(); mZipOutputStream.close(); } } catch (IOException e) { Log.d(AlbumImpl.class.getName(), "Couldn't close zip file.", e); } } private void initXml() { if (mOutputStream != null) { return; } try { mOutputStream = new BufferedOutputStream(new FileOutputStream(getMetadataFile()), BUFFER_SIZE); mXmlSerializer = Xml.newSerializer(); mXmlSerializer.setOutput(mOutputStream, "utf-8"); mXmlSerializer.startDocument("utf-8", null); mXmlSerializer.startTag(null, "screenshots"); } catch (IOException e) { throw new RuntimeException(e); } } private void endXml() { try { mXmlSerializer.endTag(null, "screenshots"); mXmlSerializer.endDocument(); mXmlSerializer.flush(); } catch (IOException e) { throw new RuntimeException(e); } try { mOutputStream.close(); } catch (IOException e) { throw new RuntimeException(e); } } /** Returns the stored screenshot in the album, or null if no such test case exists. */ @Nullable Bitmap getScreenshot(String name) throws IOException { if (getScreenshotFile(name) == null) { return null; } return BitmapFactory.decodeFile(getScreenshotFile(name).getAbsolutePath()); } /** * Returns the file in which the screenshot is stored, or null if this is not a valid screenshot * * <p>TODO: Adjust tests to no longer use this method. It's quite sketchy and inefficient. */ @Nullable File getScreenshotFile(String name) throws IOException { if (mZipOutputStream != null) { // This needs to be a valid file before we can read from it. mZipOutputStream.close(); } File bundle = new File(mDir, SCREENSHOT_BUNDLE_FILE_NAME); if (!bundle.isFile()) { return null; } ZipInputStream zipInputStream = new ZipInputStream(new FileInputStream(bundle)); try { String filename = getScreenshotFilenameInternal(name); byte[] buffer = new byte[BUFFER_SIZE]; ZipEntry entry; while ((entry = zipInputStream.getNextEntry()) != null) { if (!filename.equals(entry.getName())) { continue; } File file = File.createTempFile(name, ".png"); FileOutputStream fileOutputStream = new FileOutputStream(file); try { int len; while ((len = zipInputStream.read(buffer)) > 0) { fileOutputStream.write(buffer, 0, len); } } finally { fileOutputStream.close(); } return file; } } finally { zipInputStream.close(); } return null; } @Override public String writeBitmap(String name, int tilei, int tilej, Bitmap bitmap) throws IOException { String tileName = generateTileName(name, tilei, tilej); String filename = getScreenshotFilenameInternal(tileName); ZipOutputStream zipOutputStream = getOrCreateZipOutputStream(); ZipEntry entry = new ZipEntry(filename); zipOutputStream.putNextEntry(entry); bitmap.compress(Bitmap.CompressFormat.PNG, COMPRESSION_QUALITY, zipOutputStream); return tileName; } /** Delete all screenshots associated with this album */ @Override public void cleanup() { if (!mDir.exists()) { // We probably failed to even create it, so nothing to clean up return; } for (String s : mDir.list()) { new File(mDir, s).delete(); } } /** * Same as the public getScreenshotFile() except it returns the File even if the screenshot * doesn't exist. */ private static String getScreenshotFilenameInternal(String name) { return name + ".png"; } private static String getViewHierarchyFilename(String name) { return name + "_dump.json"; } private static String getAxIssuesFilename(String name) { return name + "_issues.json"; } @Override public void writeAxIssuesFile(String name, String data) throws IOException { writeMetadataFile(getAxIssuesFilename(name), data); } @Override public void writeViewHierarchyFile(String name, String data) throws IOException { writeMetadataFile(getViewHierarchyFilename(name), data); } public void writeMetadataFile(String name, String data) throws IOException { byte[] out = data.getBytes(); ZipEntry zipEntry = new ZipEntry(name); ZipOutputStream zipOutputStream = getOrCreateZipOutputStream(); zipOutputStream.putNextEntry(zipEntry); zipOutputStream.write(out); } /** * Add the given record to the album. This is called by RecordBuilderImpl#record() and so is an * internal detail. */ @SuppressLint("SetWorldReadable") @Override public void addRecord(RecordBuilderImpl recordBuilder) throws IOException { initXml(); recordBuilder.checkState(); if (mAllNames.contains(recordBuilder.getName())) { if (recordBuilder.hasExplicitName()) { throw new AssertionError( "Can't create multiple screenshots with the same name: " + recordBuilder.getName()); } throw new AssertionError( "Can't create multiple screenshots from the same test, or " + "use .setName() to name each screenshot differently"); } mXmlSerializer.startTag(null, "screenshot"); Tiling tiling = recordBuilder.getTiling(); addTextNode("description", recordBuilder.getDescription()); addTextNode("name", recordBuilder.getName()); addTextNode("test_class", recordBuilder.getTestClass()); addTextNode("test_name", recordBuilder.getTestName()); addTextNode("tile_width", String.valueOf(tiling.getWidth())); addTextNode("tile_height", String.valueOf(tiling.getHeight())); addTextNode("view_hierarchy", getViewHierarchyFilename(recordBuilder.getName())); addTextNode("ax_issues", getAxIssuesFilename(recordBuilder.getName())); mXmlSerializer.startTag(null, "extras"); for (Map.Entry<String, String> entry : recordBuilder.getExtras().entrySet()) { addTextNode(entry.getKey(), entry.getValue()); } mXmlSerializer.endTag(null, "extras"); if (recordBuilder.getError() != null) { addTextNode("error", recordBuilder.getError()); } else { saveTiling(recordBuilder); } if (recordBuilder.getGroup() != null) { addTextNode("group", recordBuilder.getGroup()); } mAllNames.add(recordBuilder.getName()); mXmlSerializer.endTag(null, "screenshot"); } private void saveTiling(RecordBuilderImpl recordBuilder) throws IOException { Tiling tiling = recordBuilder.getTiling(); for (int i = 0; i < tiling.getWidth(); i++) { for (int j = 0; j < tiling.getHeight(); j++) { File file = new File(mDir, generateTileName(recordBuilder.getName(), i, j)); addTextNode("absolute_file_name", file.getAbsolutePath()); addTextNode("relative_file_name", getRelativePath(file, mDir)); } } } /** Returns the relative path of file from dir */ private String getRelativePath(File file, File dir) { try { String filePath = file.getCanonicalPath(); String dirPath = dir.getCanonicalPath(); if (filePath.startsWith(dirPath)) { return filePath.substring(dirPath.length() + 1); } return filePath; } catch (IOException e) { throw new RuntimeException(e); } } private void addTextNode(String name, String value) throws IOException { mXmlSerializer.startTag(null, name); if (value != null) { mXmlSerializer.text(value); } mXmlSerializer.endTag(null, name); } public File getMetadataFile() { return new File(mDir, "metadata.xml"); } /** * For a given screenshot, and a tile position, generates a name where we store the screenshot in * the album. * * <p>For backward compatibility with existing screenshot scripts, for the tile (0, 0) we use the * name directly. */ private String generateTileName(String name, int i, int j) { if (i == 0 && j == 0) { return name; } return String.format("%s_%s_%s", name, String.valueOf(i), String.valueOf(j)); } }