/*
* Copyright (C) 2013 The Android Open Source Project
*
* 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.android.server;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.graphics.Atlas;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.drawable.Drawable;
import android.os.Environment;
import android.os.RemoteException;
import android.os.SystemProperties;
import android.util.Log;
import android.util.LongSparseArray;
import android.view.GraphicBuffer;
import android.view.IAssetAtlas;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* This service is responsible for packing preloaded bitmaps into a single
* atlas texture. The resulting texture can be shared across processes to
* reduce overall memory usage.
*
* @hide
*/
public class AssetAtlasService extends IAssetAtlas.Stub {
/**
* Name of the AssetAtlasService
.
*/
public static final String ASSET_ATLAS_SERVICE = "assetatlas";
private static final String LOG_TAG = "Atlas";
// Turns debug logs on/off. Debug logs are kept to a minimum and should
// remain on to diagnose issues
private static final boolean DEBUG_ATLAS = true;
// When set to true the content of the atlas will be saved to disk
// in /data/system/atlas.png. The shared GraphicBuffer may be empty
private static final boolean DEBUG_ATLAS_TEXTURE = false;
// Minimum size in pixels to consider for the resulting texture
private static final int MIN_SIZE = 768;
// Maximum size in pixels to consider for the resulting texture
private static final int MAX_SIZE = 2048;
// Increment in number of pixels between size variants when looking
// for the best texture dimensions
private static final int STEP = 64;
// This percentage of the total number of pixels represents the minimum
// number of pixels we want to be able to pack in the atlas
private static final float PACKING_THRESHOLD = 0.8f;
// Defines the number of int fields used to represent a single entry
// in the atlas map. This number defines the size of the array returned
// by the getMap(). See the mAtlasMap field for more information
private static final int ATLAS_MAP_ENTRY_FIELD_COUNT = 4;
// Specifies how our GraphicBuffer will be used. To get proper swizzling
// the buffer will be written to using OpenGL (from JNI) so we can leave
// the software flag set to "never"
private static final int GRAPHIC_BUFFER_USAGE = GraphicBuffer.USAGE_SW_READ_NEVER |
GraphicBuffer.USAGE_SW_WRITE_NEVER | GraphicBuffer.USAGE_HW_TEXTURE;
// This boolean is set to true if an atlas was successfully
// computed and rendered
private final AtomicBoolean mAtlasReady = new AtomicBoolean(false);
private final Context mContext;
// Version name of the current build, used to identify changes to assets list
private final String mVersionName;
// Holds the atlas' data. This buffer can be mapped to
// OpenGL using an EGLImage
private GraphicBuffer mBuffer;
// Describes how bitmaps are placed in the atlas. Each bitmap is
// represented by several entries in the array:
// int0: SkBitmap*, the native bitmap object
// int1: x position
// int2: y position
// int3: rotated, 1 if the bitmap must be rotated, 0 otherwise
// NOTE: This will need to be handled differently to support 64 bit pointers
private int[] mAtlasMap;
/**
* Creates a new service. Upon creating, the service will gather the list of
* assets to consider for packing into the atlas and spawn a new thread to
* start the packing work.
*
* @param context The context giving access to preloaded resources
*/
public AssetAtlasService(Context context) {
mContext = context;
mVersionName = queryVersionName(context);
ArrayList bitmaps = new ArrayList(300);
int totalPixelCount = 0;
// We only care about drawables that hold bitmaps
final Resources resources = context.getResources();
final LongSparseArray drawables = resources.getPreloadedDrawables();
final int count = drawables.size();
for (int i = 0; i < count; i++) {
final Bitmap bitmap = drawables.valueAt(i).getBitmap();
if (bitmap != null && bitmap.getConfig() == Bitmap.Config.ARGB_8888) {
bitmaps.add(bitmap);
totalPixelCount += bitmap.getWidth() * bitmap.getHeight();
}
}
// Our algorithms perform better when the bitmaps are first sorted
// The comparator will sort the bitmap by width first, then by height
Collections.sort(bitmaps, new Comparator() {
@Override
public int compare(Bitmap b1, Bitmap b2) {
if (b1.getWidth() == b2.getWidth()) {
return b2.getHeight() - b1.getHeight();
}
return b2.getWidth() - b1.getWidth();
}
});
// Kick off the packing work on a worker thread
new Thread(new Renderer(bitmaps, totalPixelCount)).start();
}
/**
* Queries the version name stored in framework's AndroidManifest.
* The version name can be used to identify possible changes to
* framework resources.
*
* @see #getBuildIdentifier(String)
*/
private static String queryVersionName(Context context) {
try {
String packageName = context.getPackageName();
PackageInfo info = context.getPackageManager().getPackageInfo(packageName, 0);
return info.versionName;
} catch (PackageManager.NameNotFoundException e) {
Log.w(LOG_TAG, "Could not get package info", e);
}
return null;
}
/**
* Callback invoked by the server thread to indicate we can now run
* 3rd party code.
*/
public void systemRunning() {
}
/**
* The renderer does all the work:
*/
private class Renderer implements Runnable {
private final ArrayList mBitmaps;
private final int mPixelCount;
private int mNativeBitmap;
// Used for debugging only
private Bitmap mAtlasBitmap;
Renderer(ArrayList bitmaps, int pixelCount) {
mBitmaps = bitmaps;
mPixelCount = pixelCount;
}
/**
* 1. On first boot or after every update, brute-force through all the
* possible atlas configurations and look for the best one (maximimize
* number of packed assets and minimize texture size)
* a. If a best configuration was computed, write it out to disk for
* future use
* 2. Read best configuration from disk
* 3. Compute the packing using the best configuration
* 4. Allocate a GraphicBuffer
* 5. Render assets in the buffer
*/
@Override
public void run() {
Configuration config = chooseConfiguration(mBitmaps, mPixelCount, mVersionName);
if (DEBUG_ATLAS) Log.d(LOG_TAG, "Loaded configuration: " + config);
if (config != null) {
mBuffer = GraphicBuffer.create(config.width, config.height,
PixelFormat.RGBA_8888, GRAPHIC_BUFFER_USAGE);
if (mBuffer != null) {
Atlas atlas = new Atlas(config.type, config.width, config.height, config.flags);
if (renderAtlas(mBuffer, atlas, config.count)) {
mAtlasReady.set(true);
}
}
}
}
/**
* Renders a list of bitmaps into the atlas. The position of each bitmap
* was decided by the packing algorithm and will be honored by this
* method. If need be this method will also rotate bitmaps.
*
* @param buffer The buffer to render the atlas entries into
* @param atlas The atlas to pack the bitmaps into
* @param packCount The number of bitmaps that will be packed in the atlas
*
* @return true if the atlas was rendered, false otherwise
*/
@SuppressWarnings("MismatchedReadAndWriteOfArray")
private boolean renderAtlas(GraphicBuffer buffer, Atlas atlas, int packCount) {
// Use a Source blend mode to improve performance, the target bitmap
// will be zero'd out so there's no need to waste time applying blending
final Paint paint = new Paint();
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC));
// We always render the atlas into a bitmap. This bitmap is then
// uploaded into the GraphicBuffer using OpenGL to swizzle the content
final Canvas canvas = acquireCanvas(buffer.getWidth(), buffer.getHeight());
if (canvas == null) return false;
final Atlas.Entry entry = new Atlas.Entry();
mAtlasMap = new int[packCount * ATLAS_MAP_ENTRY_FIELD_COUNT];
int[] atlasMap = mAtlasMap;
int mapIndex = 0;
boolean result = false;
try {
final long startRender = System.nanoTime();
final int count = mBitmaps.size();
for (int i = 0; i < count; i++) {
final Bitmap bitmap = mBitmaps.get(i);
if (atlas.pack(bitmap.getWidth(), bitmap.getHeight(), entry) != null) {
// We have more bitmaps to pack than the current configuration
// says, we were most likely not able to detect a change in the
// list of preloaded drawables, abort and delete the configuration
if (mapIndex >= mAtlasMap.length) {
deleteDataFile();
break;
}
canvas.save();
canvas.translate(entry.x, entry.y);
if (entry.rotated) {
canvas.translate(bitmap.getHeight(), 0.0f);
canvas.rotate(90.0f);
}
canvas.drawBitmap(bitmap, 0.0f, 0.0f, null);
canvas.restore();
atlasMap[mapIndex++] = bitmap.mNativeBitmap;
atlasMap[mapIndex++] = entry.x;
atlasMap[mapIndex++] = entry.y;
atlasMap[mapIndex++] = entry.rotated ? 1 : 0;
}
}
final long endRender = System.nanoTime();
if (mNativeBitmap != 0) {
result = nUploadAtlas(buffer, mNativeBitmap);
}
final long endUpload = System.nanoTime();
if (DEBUG_ATLAS) {
float renderDuration = (endRender - startRender) / 1000.0f / 1000.0f;
float uploadDuration = (endUpload - endRender) / 1000.0f / 1000.0f;
Log.d(LOG_TAG, String.format("Rendered atlas in %.2fms (%.2f+%.2fms)",
renderDuration + uploadDuration, renderDuration, uploadDuration));
}
} finally {
releaseCanvas(canvas);
}
return result;
}
/**
* Returns a Canvas for the specified buffer. If {@link #DEBUG_ATLAS_TEXTURE}
* is turned on, the returned Canvas will render into a local bitmap that
* will then be saved out to disk for debugging purposes.
* @param width
* @param height
*/
private Canvas acquireCanvas(int width, int height) {
if (DEBUG_ATLAS_TEXTURE) {
mAtlasBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
return new Canvas(mAtlasBitmap);
} else {
Canvas canvas = new Canvas();
mNativeBitmap = nAcquireAtlasCanvas(canvas, width, height);
return canvas;
}
}
/**
* Releases the canvas used to render into the buffer. Calling this method
* will release any resource previously acquired. If {@link #DEBUG_ATLAS_TEXTURE}
* is turend on, calling this method will write the content of the atlas
* to disk in /data/system/atlas.png for debugging.
*/
private void releaseCanvas(Canvas canvas) {
if (DEBUG_ATLAS_TEXTURE) {
canvas.setBitmap(null);
File systemDirectory = new File(Environment.getDataDirectory(), "system");
File dataFile = new File(systemDirectory, "atlas.png");
try {
FileOutputStream out = new FileOutputStream(dataFile);
mAtlasBitmap.compress(Bitmap.CompressFormat.PNG, 100, out);
out.close();
} catch (FileNotFoundException e) {
// Ignore
} catch (IOException e) {
// Ignore
}
mAtlasBitmap.recycle();
mAtlasBitmap = null;
} else {
nReleaseAtlasCanvas(canvas, mNativeBitmap);
}
}
}
private static native int nAcquireAtlasCanvas(Canvas canvas, int width, int height);
private static native void nReleaseAtlasCanvas(Canvas canvas, int bitmap);
private static native boolean nUploadAtlas(GraphicBuffer buffer, int bitmap);
@Override
public boolean isCompatible(int ppid) {
return ppid == android.os.Process.myPpid();
}
@Override
public GraphicBuffer getBuffer() throws RemoteException {
return mAtlasReady.get() ? mBuffer : null;
}
@Override
public int[] getMap() throws RemoteException {
return mAtlasReady.get() ? mAtlasMap : null;
}
/**
* Finds the best atlas configuration to pack the list of supplied bitmaps.
* This method takes advantage of multi-core systems by spawning a number
* of threads equal to the number of available cores.
*/
private static Configuration computeBestConfiguration(
ArrayList bitmaps, int pixelCount) {
if (DEBUG_ATLAS) Log.d(LOG_TAG, "Computing best atlas configuration...");
long begin = System.nanoTime();
List results = Collections.synchronizedList(new ArrayList());
// Don't bother with an extra thread if there's only one processor
int cpuCount = Runtime.getRuntime().availableProcessors();
if (cpuCount == 1) {
new ComputeWorker(MIN_SIZE, MAX_SIZE, STEP, bitmaps, pixelCount, results, null).run();
} else {
int start = MIN_SIZE;
int end = MAX_SIZE - (cpuCount - 1) * STEP;
int step = STEP * cpuCount;
final CountDownLatch signal = new CountDownLatch(cpuCount);
for (int i = 0; i < cpuCount; i++, start += STEP, end += STEP) {
ComputeWorker worker = new ComputeWorker(start, end, step,
bitmaps, pixelCount, results, signal);
new Thread(worker, "Atlas Worker #" + (i + 1)).start();
}
try {
signal.await(10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Log.w(LOG_TAG, "Could not complete configuration computation");
return null;
}
}
// Maximize the number of packed bitmaps, minimize the texture size
Collections.sort(results, new Comparator() {
@Override
public int compare(WorkerResult r1, WorkerResult r2) {
int delta = r2.count - r1.count;
if (delta != 0) return delta;
return r1.width * r1.height - r2.width * r2.height;
}
});
if (DEBUG_ATLAS) {
float delay = (System.nanoTime() - begin) / 1000.0f / 1000.0f / 1000.0f;
Log.d(LOG_TAG, String.format("Found best atlas configuration in %.2fs", delay));
}
WorkerResult result = results.get(0);
return new Configuration(result.type, result.width, result.height, result.count);
}
/**
* Returns the path to the file containing the best computed
* atlas configuration.
*/
private static File getDataFile() {
File systemDirectory = new File(Environment.getDataDirectory(), "system");
return new File(systemDirectory, "framework_atlas.config");
}
private static void deleteDataFile() {
Log.w(LOG_TAG, "Current configuration inconsistent with assets list");
if (!getDataFile().delete()) {
Log.w(LOG_TAG, "Could not delete the current configuration");
}
}
private File getFrameworkResourcesFile() {
return new File(mContext.getApplicationInfo().sourceDir);
}
/**
* Returns the best known atlas configuration. This method will either
* read the configuration from disk or start a brute-force search
* and save the result out to disk.
*/
private Configuration chooseConfiguration(ArrayList bitmaps, int pixelCount,
String versionName) {
Configuration config = null;
final File dataFile = getDataFile();
if (dataFile.exists()) {
config = readConfiguration(dataFile, versionName);
}
if (config == null) {
config = computeBestConfiguration(bitmaps, pixelCount);
if (config != null) writeConfiguration(config, dataFile, versionName);
}
return config;
}
/**
* Writes the specified atlas configuration to the specified file.
*/
private void writeConfiguration(Configuration config, File file, String versionName) {
BufferedWriter writer = null;
try {
writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file)));
writer.write(getBuildIdentifier(versionName));
writer.newLine();
writer.write(config.type.toString());
writer.newLine();
writer.write(String.valueOf(config.width));
writer.newLine();
writer.write(String.valueOf(config.height));
writer.newLine();
writer.write(String.valueOf(config.count));
writer.newLine();
writer.write(String.valueOf(config.flags));
writer.newLine();
} catch (FileNotFoundException e) {
Log.w(LOG_TAG, "Could not write " + file, e);
} catch (IOException e) {
Log.w(LOG_TAG, "Could not write " + file, e);
} finally {
if (writer != null) {
try {
writer.close();
} catch (IOException e) {
// Ignore
}
}
}
}
/**
* Reads an atlas configuration from the specified file. This method
* returns null if an error occurs or if the configuration is invalid.
*/
private Configuration readConfiguration(File file, String versionName) {
BufferedReader reader = null;
Configuration config = null;
try {
reader = new BufferedReader(new InputStreamReader(new FileInputStream(file)));
if (checkBuildIdentifier(reader, versionName)) {
Atlas.Type type = Atlas.Type.valueOf(reader.readLine());
int width = readInt(reader, MIN_SIZE, MAX_SIZE);
int height = readInt(reader, MIN_SIZE, MAX_SIZE);
int count = readInt(reader, 0, Integer.MAX_VALUE);
int flags = readInt(reader, Integer.MIN_VALUE, Integer.MAX_VALUE);
config = new Configuration(type, width, height, count, flags);
}
} catch (IllegalArgumentException e) {
Log.w(LOG_TAG, "Invalid parameter value in " + file, e);
} catch (FileNotFoundException e) {
Log.w(LOG_TAG, "Could not read " + file, e);
} catch (IOException e) {
Log.w(LOG_TAG, "Could not read " + file, e);
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
// Ignore
}
}
}
return config;
}
private static int readInt(BufferedReader reader, int min, int max) throws IOException {
return Math.max(min, Math.min(max, Integer.parseInt(reader.readLine())));
}
/**
* Compares the next line in the specified buffered reader to the current
* build identifier. Returns whether the two values are equal.
*
* @see #getBuildIdentifier(String)
*/
private boolean checkBuildIdentifier(BufferedReader reader, String versionName)
throws IOException {
String deviceBuildId = getBuildIdentifier(versionName);
String buildId = reader.readLine();
return deviceBuildId.equals(buildId);
}
/**
* Returns an identifier for the current build that can be used to detect
* likely changes to framework resources. The build identifier is made of
* several distinct values:
*
* build fingerprint/framework version name/file size of framework resources apk
*
* Only the build fingerprint should be necessary on user builds but
* the other values are useful to detect changes on eng builds during
* development.
*
* This identifier does not attempt to be exact: a new identifier does not
* necessarily mean the preloaded drawables have changed. It is important
* however that whenever the list of preloaded drawables changes, this
* identifier changes as well.
*
* @see #checkBuildIdentifier(java.io.BufferedReader, String)
*/
private String getBuildIdentifier(String versionName) {
return SystemProperties.get("ro.build.fingerprint", "") + '/' + versionName + '/' +
String.valueOf(getFrameworkResourcesFile().length());
}
/**
* Atlas configuration. Specifies the algorithm, dimensions and flags to use.
*/
private static class Configuration {
final Atlas.Type type;
final int width;
final int height;
final int count;
final int flags;
Configuration(Atlas.Type type, int width, int height, int count) {
this(type, width, height, count, Atlas.FLAG_DEFAULTS);
}
Configuration(Atlas.Type type, int width, int height, int count, int flags) {
this.type = type;
this.width = width;
this.height = height;
this.count = count;
this.flags = flags;
}
@Override
public String toString() {
return type.toString() + " (" + width + "x" + height + ") flags=0x" +
Integer.toHexString(flags) + " count=" + count;
}
}
/**
* Used during the brute-force search to gather information about each
* variant of the packing algorithm.
*/
private static class WorkerResult {
Atlas.Type type;
int width;
int height;
int count;
WorkerResult(Atlas.Type type, int width, int height, int count) {
this.type = type;
this.width = width;
this.height = height;
this.count = count;
}
@Override
public String toString() {
return String.format("%s %dx%d", type.toString(), width, height);
}
}
/**
* A compute worker will try a finite number of variations of the packing
* algorithms and save the results in a supplied list.
*/
private static class ComputeWorker implements Runnable {
private final int mStart;
private final int mEnd;
private final int mStep;
private final List mBitmaps;
private final List mResults;
private final CountDownLatch mSignal;
private final int mThreshold;
/**
* Creates a new compute worker to brute-force through a range of
* packing algorithms variants.
*
* @param start The minimum texture width to try
* @param end The maximum texture width to try
* @param step The number of pixels to increment the texture width by at each step
* @param bitmaps The list of bitmaps to pack in the atlas
* @param pixelCount The total number of pixels occupied by the list of bitmaps
* @param results The list of results in which to save the brute-force search results
* @param signal Latch to decrement when this worker is done, may be null
*/
ComputeWorker(int start, int end, int step, List bitmaps, int pixelCount,
List results, CountDownLatch signal) {
mStart = start;
mEnd = end;
mStep = step;
mBitmaps = bitmaps;
mResults = results;
mSignal = signal;
// Minimum number of pixels we want to be able to pack
int threshold = (int) (pixelCount * PACKING_THRESHOLD);
// Make sure we can find at least one configuration
while (threshold > MAX_SIZE * MAX_SIZE) {
threshold >>= 1;
}
mThreshold = threshold;
}
@Override
public void run() {
if (DEBUG_ATLAS) Log.d(LOG_TAG, "Running " + Thread.currentThread().getName());
Atlas.Entry entry = new Atlas.Entry();
for (Atlas.Type type : Atlas.Type.values()) {
for (int width = mStart; width < mEnd; width += mStep) {
for (int height = MIN_SIZE; height < MAX_SIZE; height += STEP) {
// If the atlas is not big enough, skip it
if (width * height <= mThreshold) continue;
final int count = packBitmaps(type, width, height, entry);
if (count > 0) {
mResults.add(new WorkerResult(type, width, height, count));
// If we were able to pack everything let's stop here
// Increasing the height further won't make things better
if (count == mBitmaps.size()) {
break;
}
}
}
}
}
if (mSignal != null) {
mSignal.countDown();
}
}
private int packBitmaps(Atlas.Type type, int width, int height, Atlas.Entry entry) {
int total = 0;
Atlas atlas = new Atlas(type, width, height);
final int count = mBitmaps.size();
for (int i = 0; i < count; i++) {
final Bitmap bitmap = mBitmaps.get(i);
if (atlas.pack(bitmap.getWidth(), bitmap.getHeight(), entry) != null) {
total++;
}
}
return total;
}
}
}