/*
* Copyright (C) 2015 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 android.app.backup;
import android.os.ParcelFileDescriptor;
import android.util.ArrayMap;
import android.util.Log;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.zip.CRC32;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.InflaterInputStream;
/**
* Utility class for writing BackupHelpers whose underlying data is a
* fixed set of byte-array blobs. The helper manages diff detection
* and compression on the wire.
*
* @hide
*/
public abstract class BlobBackupHelper implements BackupHelper {
private static final String TAG = "BlobBackupHelper";
private static final boolean DEBUG = false;
private final int mCurrentBlobVersion;
private final String[] mKeys;
public BlobBackupHelper(int currentBlobVersion, String... keys) {
mCurrentBlobVersion = currentBlobVersion;
mKeys = keys;
}
// Client interface
/**
* Generate and return the byte array containing the backup payload describing
* the current data state. During a backup operation this method is called once
* per key that was supplied to the helper's constructor.
*
* @return A byte array containing the data blob that the caller wishes to store,
* or {@code null} if the current state is empty or undefined.
*/
abstract protected byte[] getBackupPayload(String key);
/**
* Given a byte array that was restored from backup, do whatever is appropriate
* to apply that described state in the live system. This method is called once
* per key/value payload that was delivered for restore. Typically data is delivered
* for restore in lexical order by key, not in the order in which the keys
* were supplied in the constructor.
*
* @param payload The byte array that was passed to {@link #getBackupPayload()}
* on the ancestral device.
*/
abstract protected void applyRestoredPayload(String key, byte[] payload);
// Internal implementation
/*
* State on-disk format:
* [Int] : overall blob version number
* [Int=N] : number of keys represented in the state blob
* N* :
* [String] key
* [Long] blob checksum, calculated after compression
*/
@SuppressWarnings("resource")
private ArrayMap readOldState(ParcelFileDescriptor oldStateFd) {
final ArrayMap state = new ArrayMap();
FileInputStream fis = new FileInputStream(oldStateFd.getFileDescriptor());
DataInputStream in = new DataInputStream(fis);
try {
int version = in.readInt();
if (version <= mCurrentBlobVersion) {
final int numKeys = in.readInt();
if (DEBUG) {
Log.i(TAG, " " + numKeys + " keys in state record");
}
for (int i = 0; i < numKeys; i++) {
String key = in.readUTF();
long checksum = in.readLong();
if (DEBUG) {
Log.i(TAG, " key '" + key + "' checksum is " + checksum);
}
state.put(key, checksum);
}
} else {
Log.w(TAG, "Prior state from unrecognized version " + version);
}
} catch (EOFException e) {
// Empty file is expected on first backup, so carry on. If the state
// is truncated we just treat it the same way.
if (DEBUG) {
Log.i(TAG, "Hit EOF reading prior state");
}
state.clear();
} catch (Exception e) {
Log.e(TAG, "Error examining prior backup state " + e.getMessage());
state.clear();
}
return state;
}
/**
* New overall state record
*/
private void writeBackupState(ArrayMap state, ParcelFileDescriptor stateFile) {
try {
FileOutputStream fos = new FileOutputStream(stateFile.getFileDescriptor());
// We explicitly don't close 'out' because we must not close the backing fd.
// The FileOutputStream will not close it implicitly.
@SuppressWarnings("resource")
DataOutputStream out = new DataOutputStream(fos);
out.writeInt(mCurrentBlobVersion);
final int N = (state != null) ? state.size() : 0;
out.writeInt(N);
for (int i = 0; i < N; i++) {
final String key = state.keyAt(i);
final long checksum = state.valueAt(i).longValue();
if (DEBUG) {
Log.i(TAG, " writing key " + key + " checksum = " + checksum);
}
out.writeUTF(key);
out.writeLong(checksum);
}
} catch (IOException e) {
Log.e(TAG, "Unable to write updated state", e);
}
}
// Also versions the deflated blob internally in case we need to revise it
private byte[] deflate(byte[] data) {
byte[] result = null;
if (data != null) {
try {
ByteArrayOutputStream sink = new ByteArrayOutputStream();
DataOutputStream headerOut = new DataOutputStream(sink);
// write the header directly to the sink ahead of the deflated payload
headerOut.writeInt(mCurrentBlobVersion);
DeflaterOutputStream out = new DeflaterOutputStream(sink);
out.write(data);
out.close(); // finishes and commits the compression run
result = sink.toByteArray();
if (DEBUG) {
Log.v(TAG, "Deflated " + data.length + " bytes to " + result.length);
}
} catch (IOException e) {
Log.w(TAG, "Unable to process payload: " + e.getMessage());
}
}
return result;
}
// Returns null if inflation failed
private byte[] inflate(byte[] compressedData) {
byte[] result = null;
if (compressedData != null) {
try {
ByteArrayInputStream source = new ByteArrayInputStream(compressedData);
DataInputStream headerIn = new DataInputStream(source);
int version = headerIn.readInt();
if (version > mCurrentBlobVersion) {
Log.w(TAG, "Saved payload from unrecognized version " + version);
return null;
}
InflaterInputStream in = new InflaterInputStream(source);
ByteArrayOutputStream inflated = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int nRead;
while ((nRead = in.read(buffer)) > 0) {
inflated.write(buffer, 0, nRead);
}
in.close();
inflated.flush();
result = inflated.toByteArray();
if (DEBUG) {
Log.v(TAG, "Inflated " + compressedData.length + " bytes to " + result.length);
}
} catch (IOException e) {
// result is still null here
Log.w(TAG, "Unable to process restored payload: " + e.getMessage());
}
}
return result;
}
private long checksum(byte[] buffer) {
if (buffer != null) {
try {
CRC32 crc = new CRC32();
ByteArrayInputStream bis = new ByteArrayInputStream(buffer);
byte[] buf = new byte[4096];
int nRead = 0;
while ((nRead = bis.read(buf)) >= 0) {
crc.update(buf, 0, nRead);
}
return crc.getValue();
} catch (Exception e) {
// whoops; fall through with an explicitly bogus checksum
}
}
return -1;
}
// BackupHelper interface
@Override
public void performBackup(ParcelFileDescriptor oldStateFd, BackupDataOutput data,
ParcelFileDescriptor newStateFd) {
if (DEBUG) {
Log.i(TAG, "Performing backup for " + this.getClass().getName());
}
final ArrayMap oldState = readOldState(oldStateFd);
final ArrayMap newState = new ArrayMap();
try {
for (String key : mKeys) {
final byte[] payload = deflate(getBackupPayload(key));
final long checksum = checksum(payload);
if (DEBUG) {
Log.i(TAG, "Key " + key + " backup checksum is " + checksum);
}
newState.put(key, checksum);
Long oldChecksum = oldState.get(key);
if (oldChecksum == null || checksum != oldChecksum.longValue()) {
if (DEBUG) {
Log.i(TAG, "Checksum has changed from " + oldChecksum + " to " + checksum
+ " for key " + key + ", writing");
}
if (payload != null) {
data.writeEntityHeader(key, payload.length);
data.writeEntityData(payload, payload.length);
} else {
// state's changed but there's no current payload => delete
data.writeEntityHeader(key, -1);
}
} else {
if (DEBUG) {
Log.i(TAG, "No change under key " + key + " => not writing");
}
}
}
} catch (Exception e) {
Log.w(TAG, "Unable to record notification state: " + e.getMessage());
newState.clear();
} finally {
// Always rewrite the state even if nothing changed
writeBackupState(newState, newStateFd);
}
}
@Override
public void restoreEntity(BackupDataInputStream data) {
final String key = data.getKey();
try {
// known key?
int which;
for (which = 0; which < mKeys.length; which++) {
if (key.equals(mKeys[which])) {
break;
}
}
if (which >= mKeys.length) {
Log.e(TAG, "Unrecognized key " + key + ", ignoring");
return;
}
byte[] compressed = new byte[data.size()];
data.read(compressed);
byte[] payload = inflate(compressed);
applyRestoredPayload(key, payload);
} catch (Exception e) {
Log.e(TAG, "Exception restoring entity " + key + " : " + e.getMessage());
}
}
@Override
public void writeNewStateDescription(ParcelFileDescriptor newState) {
// Just ensure that we do a full backup the first time after a restore
if (DEBUG) {
Log.i(TAG, "Writing state description after restore");
}
writeBackupState(null, newState);
}
}