1/*
2 * Copyright (C) 2015 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package android.app.backup;
18
19import android.os.ParcelFileDescriptor;
20import android.util.ArrayMap;
21import android.util.Log;
22
23import java.io.ByteArrayInputStream;
24import java.io.ByteArrayOutputStream;
25import java.io.DataInputStream;
26import java.io.DataOutputStream;
27import java.io.EOFException;
28import java.io.FileInputStream;
29import java.io.FileOutputStream;
30import java.io.IOException;
31import java.util.zip.CRC32;
32import java.util.zip.DeflaterOutputStream;
33import java.util.zip.InflaterInputStream;
34
35/**
36 * Utility class for writing BackupHelpers whose underlying data is a
37 * fixed set of byte-array blobs.  The helper manages diff detection
38 * and compression on the wire.
39 *
40 * @hide
41 */
42public abstract class BlobBackupHelper implements BackupHelper {
43    private static final String TAG = "BlobBackupHelper";
44    private static final boolean DEBUG = false;
45
46    private final int mCurrentBlobVersion;
47    private final String[] mKeys;
48
49    public BlobBackupHelper(int currentBlobVersion, String... keys) {
50        mCurrentBlobVersion = currentBlobVersion;
51        mKeys = keys;
52    }
53
54    // Client interface
55
56    /**
57     * Generate and return the byte array containing the backup payload describing
58     * the current data state.  During a backup operation this method is called once
59     * per key that was supplied to the helper's constructor.
60     *
61     * @return A byte array containing the data blob that the caller wishes to store,
62     *     or {@code null} if the current state is empty or undefined.
63     */
64    abstract protected byte[] getBackupPayload(String key);
65
66    /**
67     * Given a byte array that was restored from backup, do whatever is appropriate
68     * to apply that described state in the live system.  This method is called once
69     * per key/value payload that was delivered for restore.  Typically data is delivered
70     * for restore in lexical order by key, <i>not</i> in the order in which the keys
71     * were supplied in the constructor.
72     *
73     * @param payload The byte array that was passed to {@link #getBackupPayload()}
74     *     on the ancestral device.
75     */
76    abstract protected void applyRestoredPayload(String key, byte[] payload);
77
78
79    // Internal implementation
80
81    /*
82     * State on-disk format:
83     * [Int]    : overall blob version number
84     * [Int=N] : number of keys represented in the state blob
85     * N* :
86     *     [String] key
87     *     [Long]   blob checksum, calculated after compression
88     */
89    @SuppressWarnings("resource")
90    private ArrayMap<String, Long> readOldState(ParcelFileDescriptor oldStateFd) {
91        final ArrayMap<String, Long> state = new ArrayMap<String, Long>();
92
93        FileInputStream fis = new FileInputStream(oldStateFd.getFileDescriptor());
94        DataInputStream in = new DataInputStream(fis);
95
96        try {
97            int version = in.readInt();
98            if (version <= mCurrentBlobVersion) {
99                final int numKeys = in.readInt();
100                if (DEBUG) {
101                    Log.i(TAG, "  " + numKeys + " keys in state record");
102                }
103                for (int i = 0; i < numKeys; i++) {
104                    String key = in.readUTF();
105                    long checksum = in.readLong();
106                    if (DEBUG) {
107                        Log.i(TAG, "  key '" + key + "' checksum is " + checksum);
108                    }
109                    state.put(key, checksum);
110                }
111            } else {
112                Log.w(TAG, "Prior state from unrecognized version " + version);
113            }
114        } catch (EOFException e) {
115            // Empty file is expected on first backup,  so carry on. If the state
116            // is truncated we just treat it the same way.
117            if (DEBUG) {
118                Log.i(TAG, "Hit EOF reading prior state");
119            }
120            state.clear();
121        } catch (Exception e) {
122            Log.e(TAG, "Error examining prior backup state " + e.getMessage());
123            state.clear();
124        }
125
126        return state;
127    }
128
129    /**
130     * New overall state record
131     */
132    private void writeBackupState(ArrayMap<String, Long> state, ParcelFileDescriptor stateFile) {
133        try {
134            FileOutputStream fos = new FileOutputStream(stateFile.getFileDescriptor());
135
136            // We explicitly don't close 'out' because we must not close the backing fd.
137            // The FileOutputStream will not close it implicitly.
138            @SuppressWarnings("resource")
139            DataOutputStream out = new DataOutputStream(fos);
140
141            out.writeInt(mCurrentBlobVersion);
142
143            final int N = (state != null) ? state.size() : 0;
144            out.writeInt(N);
145            for (int i = 0; i < N; i++) {
146                final String key = state.keyAt(i);
147                final long checksum = state.valueAt(i).longValue();
148                if (DEBUG) {
149                    Log.i(TAG, "  writing key " + key + " checksum = " + checksum);
150                }
151                out.writeUTF(key);
152                out.writeLong(checksum);
153            }
154        } catch (IOException e) {
155            Log.e(TAG, "Unable to write updated state", e);
156        }
157    }
158
159    // Also versions the deflated blob internally in case we need to revise it
160    private byte[] deflate(byte[] data) {
161        byte[] result = null;
162        if (data != null) {
163            try {
164                ByteArrayOutputStream sink = new ByteArrayOutputStream();
165                DataOutputStream headerOut = new DataOutputStream(sink);
166
167                // write the header directly to the sink ahead of the deflated payload
168                headerOut.writeInt(mCurrentBlobVersion);
169
170                DeflaterOutputStream out = new DeflaterOutputStream(sink);
171                out.write(data);
172                out.close();  // finishes and commits the compression run
173                result = sink.toByteArray();
174                if (DEBUG) {
175                    Log.v(TAG, "Deflated " + data.length + " bytes to " + result.length);
176                }
177            } catch (IOException e) {
178                Log.w(TAG, "Unable to process payload: " + e.getMessage());
179            }
180        }
181        return result;
182    }
183
184    // Returns null if inflation failed
185    private byte[] inflate(byte[] compressedData) {
186        byte[] result = null;
187        if (compressedData != null) {
188            try {
189                ByteArrayInputStream source = new ByteArrayInputStream(compressedData);
190                DataInputStream headerIn = new DataInputStream(source);
191                int version = headerIn.readInt();
192                if (version > mCurrentBlobVersion) {
193                    Log.w(TAG, "Saved payload from unrecognized version " + version);
194                    return null;
195                }
196
197                InflaterInputStream in = new InflaterInputStream(source);
198                ByteArrayOutputStream inflated = new ByteArrayOutputStream();
199                byte[] buffer = new byte[4096];
200                int nRead;
201                while ((nRead = in.read(buffer)) > 0) {
202                    inflated.write(buffer, 0, nRead);
203                }
204                in.close();
205                inflated.flush();
206                result = inflated.toByteArray();
207                if (DEBUG) {
208                    Log.v(TAG, "Inflated " + compressedData.length + " bytes to " + result.length);
209                }
210            } catch (IOException e) {
211                // result is still null here
212                Log.w(TAG, "Unable to process restored payload: " + e.getMessage());
213            }
214        }
215        return result;
216    }
217
218    private long checksum(byte[] buffer) {
219        if (buffer != null) {
220            try {
221                CRC32 crc = new CRC32();
222                ByteArrayInputStream bis = new ByteArrayInputStream(buffer);
223                byte[] buf = new byte[4096];
224                int nRead = 0;
225                while ((nRead = bis.read(buf)) >= 0) {
226                    crc.update(buf, 0, nRead);
227                }
228                return crc.getValue();
229            } catch (Exception e) {
230                // whoops; fall through with an explicitly bogus checksum
231            }
232        }
233        return -1;
234    }
235
236    // BackupHelper interface
237
238    @Override
239    public void performBackup(ParcelFileDescriptor oldStateFd, BackupDataOutput data,
240            ParcelFileDescriptor newStateFd) {
241        if (DEBUG) {
242            Log.i(TAG, "Performing backup for " + this.getClass().getName());
243        }
244
245        final ArrayMap<String, Long> oldState = readOldState(oldStateFd);
246        final ArrayMap<String, Long> newState = new ArrayMap<String, Long>();
247
248        try {
249            for (String key : mKeys) {
250                final byte[] payload = deflate(getBackupPayload(key));
251                final long checksum = checksum(payload);
252                if (DEBUG) {
253                    Log.i(TAG, "Key " + key + " backup checksum is " + checksum);
254                }
255                newState.put(key, checksum);
256
257                Long oldChecksum = oldState.get(key);
258                if (oldChecksum == null || checksum != oldChecksum.longValue()) {
259                    if (DEBUG) {
260                        Log.i(TAG, "Checksum has changed from " + oldChecksum + " to " + checksum
261                                + " for key " + key + ", writing");
262                    }
263                    if (payload != null) {
264                        data.writeEntityHeader(key, payload.length);
265                        data.writeEntityData(payload, payload.length);
266                    } else {
267                        // state's changed but there's no current payload => delete
268                        data.writeEntityHeader(key, -1);
269                    }
270                } else {
271                    if (DEBUG) {
272                        Log.i(TAG, "No change under key " + key + " => not writing");
273                    }
274                }
275            }
276        } catch (Exception e) {
277            Log.w(TAG,  "Unable to record notification state: " + e.getMessage());
278            newState.clear();
279        } finally {
280            // Always rewrite the state even if nothing changed
281            writeBackupState(newState, newStateFd);
282        }
283    }
284
285    @Override
286    public void restoreEntity(BackupDataInputStream data) {
287        final String key = data.getKey();
288        try {
289            // known key?
290            int which;
291            for (which = 0; which < mKeys.length; which++) {
292                if (key.equals(mKeys[which])) {
293                    break;
294                }
295            }
296            if (which >= mKeys.length) {
297                Log.e(TAG, "Unrecognized key " + key + ", ignoring");
298                return;
299            }
300
301            byte[] compressed = new byte[data.size()];
302            data.read(compressed);
303            byte[] payload = inflate(compressed);
304            applyRestoredPayload(key, payload);
305        } catch (Exception e) {
306            Log.e(TAG, "Exception restoring entity " + key + " : " + e.getMessage());
307        }
308    }
309
310    @Override
311    public void writeNewStateDescription(ParcelFileDescriptor newState) {
312        // Just ensure that we do a full backup the first time after a restore
313        if (DEBUG) {
314            Log.i(TAG, "Writing state description after restore");
315        }
316        writeBackupState(null, newState);
317    }
318}
319