// Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package org.chromium.chrome.browser; import android.graphics.Color; import android.os.Build; import android.os.Handler; import android.util.Log; import android.util.Pair; import org.chromium.base.StreamUtil; import org.chromium.base.VisibleForTesting; import org.chromium.chrome.browser.tab.Tab; import org.chromium.content.browser.crypto.CipherFactory; import org.chromium.content_public.browser.WebContents; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.EOFException; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.nio.channels.FileChannel.MapMode; import javax.crypto.Cipher; import javax.crypto.CipherInputStream; import javax.crypto.CipherOutputStream; /** * Object that contains the state of a tab, including its navigation history. */ public class TabState { private static final String TAG = "TabState"; public static final String SAVED_TAB_STATE_FILE_PREFIX = "tab"; public static final String SAVED_TAB_STATE_FILE_PREFIX_INCOGNITO = "cryptonito"; /** * Version number of the format used to save the WebContents navigation history, as returned by * nativeGetContentsStateAsByteBuffer(). Version labels: * 0 - Chrome m18 * 1 - Chrome m25 * 2 - Chrome m26+ */ public static final int CONTENTS_STATE_CURRENT_VERSION = 2; /** Special value for mTimestampMillis. */ private static final long TIMESTAMP_NOT_SET = -1; /** Checks if the TabState header is loaded properly. */ private static final long KEY_CHECKER = 0; /** Overrides the Chrome channel/package name to test a variant channel-specific behaviour. */ private static String sChannelNameOverrideForTest; /** Contains the state for a WebContents. */ public static class WebContentsState { private final ByteBuffer mBuffer; private int mVersion; public WebContentsState(ByteBuffer buffer) { mBuffer = buffer; } public ByteBuffer buffer() { return mBuffer; } public int version() { return mVersion; } public void setVersion(int version) { mVersion = version; } /** * Creates a WebContents from the buffer. * @param isHidden Whether or not the tab initially starts hidden. * @return Pointer A WebContents object. */ public WebContents restoreContentsFromByteBuffer(boolean isHidden) { return nativeRestoreContentsFromByteBuffer(mBuffer, mVersion, isHidden); } /** * Creates a WebContents for the ContentsState and adds it as an historical tab, then * deletes the WebContents. */ public void createHistoricalTab() { nativeCreateHistoricalTab(mBuffer, mVersion); } } /** Deletes the native-side portion of the buffer. */ public static class WebContentsStateNative extends WebContentsState { private final Handler mHandler; public WebContentsStateNative(ByteBuffer buffer) { super(buffer); this.mHandler = new Handler(); } @Override protected void finalize() { assert mHandler != null; mHandler.post(new Runnable() { @Override public void run() { nativeFreeWebContentsStateBuffer(buffer()); } }); } } /** Navigation history of the WebContents. */ public WebContentsState contentsState; public int parentId = Tab.INVALID_TAB_ID; public long syncId; public long timestampMillis = TIMESTAMP_NOT_SET; public String openerAppId; public boolean shouldPreserve; /** The tab's theme color. */ public int themeColor; /** Whether this TabState was created from a file containing info about an incognito Tab. */ protected boolean mIsIncognito; /** Whether the theme color was set for this tab. */ private boolean mHasThemeColor; /** @return Whether a Stable channel build of Chrome is being used. */ private static boolean isStableChannelBuild() { if ("stable".equals(sChannelNameOverrideForTest)) return true; return ChromeVersionInfo.isStableBuild(); } /** * Restore a TabState file for a particular Tab. Checks if the Tab exists as a regular tab * before searching for an encrypted version. * @param stateFolder Folder containing the TabState files. * @param id ID of the Tab to restore. * @return TabState that has been restored, or null if it failed. */ public static TabState restoreTabState(File stateFolder, int id) { // First try finding an unencrypted file. boolean encrypted = false; File file = getTabStateFile(stateFolder, id, encrypted); // If that fails, try finding the encrypted version. if (!file.exists()) { encrypted = true; file = getTabStateFile(stateFolder, id, encrypted); } // If they both failed, there's nothing to read. if (!file.exists()) return null; // If one of them passed, open the file input stream and read the state contents. return restoreTabState(file, encrypted); } /** * Restores a particular TabState file from storage. * @param tabFile Location of the TabState file. * @param isIncognito Whether the Tab is incognito or not. * @return TabState that has been restored, or null if it failed. */ public static TabState restoreTabState(File tabFile, boolean isIncognito) { FileInputStream stream = null; TabState tabState = null; try { stream = new FileInputStream(tabFile); tabState = TabState.readState(stream, isIncognito); } catch (FileNotFoundException exception) { Log.e(TAG, "Failed to restore tab state for tab: " + tabFile); } catch (IOException exception) { Log.e(TAG, "Failed to restore tab state.", exception); } finally { StreamUtil.closeQuietly(stream); } return tabState; } /** * Restores a particular TabState file from storage. * @param input Location of the TabState file. * @param encrypted Whether the file is encrypted or not. * @return TabState that has been restored, or null if it failed. */ private static TabState readState(FileInputStream input, boolean encrypted) throws IOException { DataInputStream stream = null; if (encrypted) { Cipher cipher = CipherFactory.getInstance().getCipher(Cipher.DECRYPT_MODE); if (cipher != null) { stream = new DataInputStream(new CipherInputStream(input, cipher)); } } if (stream == null) { stream = new DataInputStream(input); } try { if (encrypted && stream.readLong() != KEY_CHECKER) { // Got the wrong key, skip the file return null; } TabState tabState = new TabState(); tabState.timestampMillis = stream.readLong(); int size = stream.readInt(); if (encrypted) { // If it's encrypted, we have to read the stream normally to apply the cipher. byte[] state = new byte[size]; stream.readFully(state); tabState.contentsState = new WebContentsState(ByteBuffer.allocateDirect(size)); tabState.contentsState.buffer().put(state); } else { // If not, we can mmap the file directly, saving time and copies into the java heap. FileChannel channel = input.getChannel(); tabState.contentsState = new WebContentsState( channel.map(MapMode.READ_ONLY, channel.position(), size)); // Skip ahead to avoid re-reading data that mmap'd. long skipped = input.skip(size); if (skipped != size) { Log.e(TAG, "Only skipped " + skipped + " bytes when " + size + " should've " + "been skipped. Tab restore may fail."); } } tabState.parentId = stream.readInt(); try { tabState.openerAppId = stream.readUTF(); if ("".equals(tabState.openerAppId)) tabState.openerAppId = null; } catch (EOFException eof) { // Could happen if reading a version of a TabState that does not include the app id. Log.w(TAG, "Failed to read opener app id state from tab state"); } try { tabState.contentsState.setVersion(stream.readInt()); } catch (EOFException eof) { // On the stable channel, the first release is version 18. For all other channels, // chrome 25 is the first release. tabState.contentsState.setVersion(isStableChannelBuild() ? 0 : 1); // Could happen if reading a version of a TabState that does not include the // version id. Log.w(TAG, "Failed to read saved state version id from tab state. Assuming " + "version " + tabState.contentsState.version()); } try { tabState.syncId = stream.readLong(); } catch (EOFException eof) { tabState.syncId = 0; // Could happen if reading a version of TabState without syncId. Log.w(TAG, "Failed to read syncId from tab state. Assuming syncId is: 0"); } try { tabState.shouldPreserve = stream.readBoolean(); } catch (EOFException eof) { // Could happen if reading a version of TabState without this flag set. tabState.shouldPreserve = false; Log.w(TAG, "Failed to read shouldPreserve flag from tab state. " + "Assuming shouldPreserve is false"); } tabState.mIsIncognito = encrypted; try { tabState.themeColor = stream.readInt(); tabState.mHasThemeColor = true; } catch (EOFException eof) { // Could happen if reading a version of TabState without a theme color. tabState.themeColor = Color.WHITE; tabState.mHasThemeColor = false; Log.w(TAG, "Failed to read theme color from tab state. " + "Assuming theme color is white"); } return tabState; } finally { stream.close(); } } /** * Writes the TabState to disk. This method may be called on either the UI or background thread. * @param file File to write the tab's state to. * @param state State object obtained from from {@link Tab#getState()}. * @param encrypted Whether or not the TabState should be encrypted. */ public static void saveState(File file, TabState state, boolean encrypted) { if (state == null || state.contentsState == null) { return; } // Create the byte array from contentsState before opening the FileOutputStream, in case // contentsState.buffer is an instance of MappedByteBuffer that is mapped to // the tab state file. byte[] contentsStateBytes = new byte[state.contentsState.buffer().limit()]; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { state.contentsState.buffer().rewind(); state.contentsState.buffer().get(contentsStateBytes); } else { // For JellyBean and below a bug in MappedByteBufferAdapter causes rewind to not be // propagated to the underlying ByteBuffer, and results in an underflow exception. See: // http://b.android.com/53637. for (int i = 0; i < state.contentsState.buffer().limit(); i++) { contentsStateBytes[i] = state.contentsState.buffer().get(i); } } DataOutputStream dataOutputStream = null; FileOutputStream fileOutputStream = null; try { fileOutputStream = new FileOutputStream(file); if (encrypted) { Cipher cipher = CipherFactory.getInstance().getCipher(Cipher.ENCRYPT_MODE); if (cipher != null) { dataOutputStream = new DataOutputStream(new CipherOutputStream( fileOutputStream, cipher)); } else { // If cipher is null, getRandomBytes failed, which means encryption is // meaningless. Therefore, do not save anything. This will cause users // to lose Incognito state in certain cases. That is annoying, but is // better than failing to provide the guarantee of Incognito Mode. return; } } else { dataOutputStream = new DataOutputStream(fileOutputStream); } if (encrypted) { dataOutputStream.writeLong(KEY_CHECKER); } dataOutputStream.writeLong(state.timestampMillis); dataOutputStream.writeInt(contentsStateBytes.length); dataOutputStream.write(contentsStateBytes); dataOutputStream.writeInt(state.parentId); dataOutputStream.writeUTF(state.openerAppId != null ? state.openerAppId : ""); dataOutputStream.writeInt(state.contentsState.version()); dataOutputStream.writeLong(state.syncId); dataOutputStream.writeBoolean(state.shouldPreserve); dataOutputStream.writeInt(state.themeColor); } catch (FileNotFoundException e) { Log.w(TAG, "FileNotFoundException while attempting to save TabState."); } catch (IOException e) { Log.w(TAG, "IOException while attempting to save TabState."); } finally { StreamUtil.closeQuietly(dataOutputStream); StreamUtil.closeQuietly(fileOutputStream); } } /** * Returns a File corresponding to the given TabState. * @param directory Directory containing the TabState files. * @param tabId ID of the TabState to delete. * @param encrypted Whether the TabState is encrypted. * @return File corresponding to the given TabState. */ public static File getTabStateFile(File directory, int tabId, boolean encrypted) { return new File(directory, getTabStateFilename(tabId, encrypted)); } /** * Deletes the TabState corresponding to the given Tab. * @param directory Directory containing the TabState files. * @param tabId ID of the TabState to delete. * @param encrypted Whether the TabState is encrypted. */ public static void deleteTabState(File directory, int tabId, boolean encrypted) { File file = getTabStateFile(directory, tabId, encrypted); if (file.exists() && !file.delete()) Log.e(TAG, "Failed to delete TabState: " + file); } /** @return Title currently being displayed in the saved state's current entry. */ public String getDisplayTitleFromState() { return nativeGetDisplayTitleFromByteBuffer(contentsState.buffer(), contentsState.version()); } /** @return URL currently being displayed in the saved state's current entry. */ public String getVirtualUrlFromState() { return nativeGetVirtualUrlFromByteBuffer(contentsState.buffer(), contentsState.version()); } /** @return Whether an incognito TabState was loaded by {@link #readState}. */ public boolean isIncognito() { return mIsIncognito; } /** @return The theme color of the tab or Color.WHITE if not set. */ public int getThemeColor() { return themeColor; } /** @return True if the tab has a theme color set. */ public boolean hasThemeColor() { return mHasThemeColor; } /** * Creates a WebContentsState for a tab that will be loaded lazily. * @param url URL that is pending. * @param referrerUrl URL for the referrer. * @param referrerPolicy Policy for the referrer. * @param isIncognito Whether or not the state is meant to be incognito (e.g. encrypted). * @return ByteBuffer that represents a state representing a single pending URL. */ public static ByteBuffer createSingleNavigationStateAsByteBuffer( String url, String referrerUrl, int referrerPolicy, boolean isIncognito) { return nativeCreateSingleNavigationStateAsByteBuffer( url, referrerUrl, referrerPolicy, isIncognito); } /** * Returns the WebContents' state as a ByteBuffer. * @param tab Tab to pickle. * @return ByteBuffer containing the state of the WebContents. */ public static ByteBuffer getContentsStateAsByteBuffer(Tab tab) { return nativeGetContentsStateAsByteBuffer(tab); } /** * Generates the name of the state file that should represent the Tab specified by {@code id} * and {@code encrypted}. * @param id The id of the {@link Tab} to save. * @param encrypted Whether or not the tab is incognito and should be encrypted. * @return The name of the file the Tab state should be saved to. */ public static String getTabStateFilename(int id, boolean encrypted) { return (encrypted ? SAVED_TAB_STATE_FILE_PREFIX_INCOGNITO : SAVED_TAB_STATE_FILE_PREFIX) + id; } /** * Parse the tab id and whether the tab is incognito from the tab state filename. * @param name The given filename for the tab state file. * @return A {@link Pair} with tab id and incognito state read from the filename. */ public static Pair<Integer, Boolean> parseInfoFromFilename(String name) { try { if (name.startsWith(SAVED_TAB_STATE_FILE_PREFIX_INCOGNITO)) { int id = Integer.parseInt( name.substring(SAVED_TAB_STATE_FILE_PREFIX_INCOGNITO.length())); return Pair.create(id, true); } else if (name.startsWith(SAVED_TAB_STATE_FILE_PREFIX)) { int id = Integer.parseInt( name.substring(SAVED_TAB_STATE_FILE_PREFIX.length())); return Pair.create(id, false); } } catch (NumberFormatException ex) { // Expected for files not related to tab state. } return null; } /** * Overrides the channel name for testing. * @param name Channel to use. */ @VisibleForTesting public static void setChannelNameOverrideForTest(String name) { sChannelNameOverrideForTest = name; } private static native WebContents nativeRestoreContentsFromByteBuffer( ByteBuffer buffer, int savedStateVersion, boolean initiallyHidden); private static native ByteBuffer nativeGetContentsStateAsByteBuffer(Tab tab); private static native ByteBuffer nativeCreateSingleNavigationStateAsByteBuffer( String url, String referrerUrl, int referrerPolicy, boolean isIncognito); private static native String nativeGetDisplayTitleFromByteBuffer( ByteBuffer state, int savedStateVersion); private static native String nativeGetVirtualUrlFromByteBuffer( ByteBuffer state, int savedStateVersion); private static native void nativeFreeWebContentsStateBuffer(ByteBuffer buffer); private static native void nativeCreateHistoricalTab(ByteBuffer state, int savedStateVersion); }