Make Android Clipboard Keep Track of Last Modified Time

And expose it the C++ code.

To do in a later changelist: add lightweight code to store the hash and update time in prefs so that Chrome can have a better chance of using the current clipboard content on startup.  (If the clipboard content hasn't changed since the last time Chrome ran, the last modified date is likely correct.)

BUG=682446

Review-Url: https://codereview.chromium.org/2766623003
Cr-Commit-Position: refs/heads/master@{#461234}
diff --git a/base/android/java/src/org/chromium/base/metrics/RecordUserAction.java b/base/android/java/src/org/chromium/base/metrics/RecordUserAction.java
index 06004d69..5334746 100644
--- a/base/android/java/src/org/chromium/base/metrics/RecordUserAction.java
+++ b/base/android/java/src/org/chromium/base/metrics/RecordUserAction.java
@@ -5,6 +5,7 @@
 package org.chromium.base.metrics;
 
 import org.chromium.base.ThreadUtils;
+import org.chromium.base.VisibleForTesting;
 import org.chromium.base.annotations.JNINamespace;
 
 /**
@@ -18,7 +19,23 @@
  */
 @JNINamespace("base::android")
 public class RecordUserAction {
+    private static Throwable sDisabledBy;
+
+    /**
+     * Tests may not have native initialized, so they may need to disable metrics. The value should
+     * be reset after the test done, to avoid carrying over state to unrelated tests.
+     */
+    @VisibleForTesting
+    public static void setDisabledForTests(boolean disabled) {
+        if (disabled && sDisabledBy != null) {
+            throw new IllegalStateException("UserActions are already disabled.", sDisabledBy);
+        }
+        sDisabledBy = disabled ? new Throwable() : null;
+    }
+
     public static void record(final String action) {
+        if (sDisabledBy != null) return;
+
         if (ThreadUtils.runningOnUiThread()) {
             nativeRecordUserAction(action);
             return;
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/DisableHistogramsRule.java b/chrome/android/junit/src/org/chromium/chrome/browser/DisableHistogramsRule.java
index 506da77..0859ae5 100644
--- a/chrome/android/junit/src/org/chromium/chrome/browser/DisableHistogramsRule.java
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/DisableHistogramsRule.java
@@ -22,4 +22,4 @@
     protected void after() {
         RecordHistogram.setDisabledForTests(false);
     }
-}
\ No newline at end of file
+}
diff --git a/tools/metrics/actions/actions.xml b/tools/metrics/actions/actions.xml
index 70e2e726..63d9443 100644
--- a/tools/metrics/actions/actions.xml
+++ b/tools/metrics/actions/actions.xml
@@ -10103,10 +10103,14 @@
 <action name="MobileOmniboxClipboardChanged">
   <owner>jif@chromium.org</owner>
   <description>
-    Emitted when Chrome detects that the clipboard contains a new URL. This
-    occurs either when Chrome enters the foreground and notices that the content
-    of the clipboard changed, or when the clipboard changes while Chrome is in
-    the foreground.
+    Emitted when Chrome detects that the clipboard contains a new URL.
+
+    On iOS: this occurs either when Chrome enters the foreground and notices
+    that the content of the clipboard changed, or when the clipboard changes
+    while Chrome is in the foreground.
+
+    On Android: this occurs when Chrome starts up or when the clipboard changes
+    while Chrome is running (in the foreground or not).
   </description>
 </action>
 
diff --git a/tools/metrics/histograms/histograms.xml b/tools/metrics/histograms/histograms.xml
index e572aa68..a416caa 100644
--- a/tools/metrics/histograms/histograms.xml
+++ b/tools/metrics/histograms/histograms.xml
@@ -7466,6 +7466,15 @@
   </summary>
 </histogram>
 
+<histogram name="Clipboard.ConstructedHasher" enum="BooleanSuccess">
+  <owner>mpearson@chromium.org</owner>
+  <summary>
+    Whether Android's Clipboard.java successfully constructed a hasher to hash
+    clipboard entries.  Recorded on construction of the class, which happens
+    only on startup.
+  </summary>
+</histogram>
+
 <histogram name="Clipboard.IncognitoUseCase" enum="ClipboardAction">
   <obsolete>
     Deprecated as of 4/2013, experiment confirmed correctness of our patch.
diff --git a/ui/android/BUILD.gn b/ui/android/BUILD.gn
index b401083..97e17e1 100644
--- a/ui/android/BUILD.gn
+++ b/ui/android/BUILD.gn
@@ -249,12 +249,14 @@
 
 junit_binary("ui_junit_tests") {
   java_files = [
+    "junit/src/org/chromium/ui/base/ClipboardTest.java",
     "junit/src/org/chromium/ui/base/SelectFileDialogTest.java",
     "junit/src/org/chromium/ui/text/SpanApplierTest.java",
   ]
   deps = [
     ":ui_java",
     "//base:base_java",
+    "//base:base_java_test_support",
   ]
 }
 
diff --git a/ui/android/java/src/org/chromium/ui/base/Clipboard.java b/ui/android/java/src/org/chromium/ui/base/Clipboard.java
index 4554352a..5755f18 100644
--- a/ui/android/java/src/org/chromium/ui/base/Clipboard.java
+++ b/ui/android/java/src/org/chromium/ui/base/Clipboard.java
@@ -9,25 +9,45 @@
 import android.content.Context;
 
 import org.chromium.base.ContextUtils;
+import org.chromium.base.Log;
 import org.chromium.base.annotations.CalledByNative;
 import org.chromium.base.annotations.JNINamespace;
 import org.chromium.base.annotations.SuppressFBWarnings;
+import org.chromium.base.metrics.RecordHistogram;
+import org.chromium.base.metrics.RecordUserAction;
 import org.chromium.ui.R;
 import org.chromium.ui.widget.Toast;
 
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+
 /**
  * Simple proxy that provides C++ code with an access pathway to the Android clipboard.
  */
 @JNINamespace("ui")
-public class Clipboard {
+public class Clipboard implements ClipboardManager.OnPrimaryClipChangedListener {
     private static Clipboard sInstance;
 
+    private static final String TAG = "Clipboard";
+
     // Necessary for coercing clipboard contents to text if they require
     // access to network resources, etceteras (e.g., URI in clipboard)
     private final Context mContext;
 
     private final ClipboardManager mClipboardManager;
 
+    // A message hasher that's used to hash clipboard contents so we can tell
+    // when a clipboard changes without storing the full contents.
+    private MessageDigest mMd5Hasher;
+    // The hash of the current clipboard.
+    // TODO(mpearson): unsuppress this warning once saving and restoring
+    // the hash from prefs is added.
+    @SuppressFBWarnings("URF_UNREAD_FIELD")
+    private byte[] mClipboardMd5;
+    // The time when the clipboard was last updated.  Set to 0 if unknown.
+    private long mClipboardChangeTime;
+
     /**
      * Get the singleton Clipboard instance (creating it if needed).
      */
@@ -44,6 +64,20 @@
         mClipboardManager =
                 (ClipboardManager) ContextUtils.getApplicationContext().getSystemService(
                         Context.CLIPBOARD_SERVICE);
+        mClipboardManager.addPrimaryClipChangedListener(this);
+        try {
+            mMd5Hasher = MessageDigest.getInstance("MD5");
+            mClipboardMd5 = weakMd5Hash();
+        } catch (NoSuchAlgorithmException e) {
+            Log.e(TAG,
+                    "Unable to construct MD5 MessageDigest: %s; assume "
+                            + "clipboard last update time is start of epoch.",
+                    e);
+            mMd5Hasher = null;
+            mClipboardMd5 = new byte[] {};
+        }
+        RecordHistogram.recordBooleanHistogram("Clipboard.ConstructedHasher", mMd5Hasher != null);
+        mClipboardChangeTime = 0;
     }
 
     /**
@@ -95,6 +129,21 @@
     }
 
     /**
+     * Gets the time the clipboard content last changed.
+     *
+     * This is calculated according to the device's clock.  E.g., it continues
+     * increasing when the device is suspended.  Likewise, it can be in the
+     * future if the user's clock updated after this information was recorded.
+     *
+     * @return a Java long recording the last changed time in milliseconds since
+     * epoch, or 0 if the time could not be determined.
+     */
+    @CalledByNative
+    public long getClipboardContentChangeTimeInMillis() {
+        return mClipboardChangeTime;
+    }
+
+    /**
      * Emulates the behavior of the now-deprecated
      * {@link android.text.ClipboardManager#setText(CharSequence)}, setting the
      * clipboard's current primary clip to a plain-text clip that consists of
@@ -139,4 +188,37 @@
             Toast.makeText(mContext, text, Toast.LENGTH_SHORT).show();
         }
     }
+
+    /**
+     * Updates mClipboardMd5 and mClipboardChangeTime when the clipboard updates.
+     *
+     * Implements OnPrimaryClipChangedListener to listen for clipboard updates.
+     */
+    @Override
+    public void onPrimaryClipChanged() {
+        if (mMd5Hasher == null) return;
+        RecordUserAction.record("MobileOmniboxClipboardChanged");
+        mClipboardMd5 = weakMd5Hash();
+        // Always update the clipboard change time even if the clipboard
+        // content hasn't changed.  This is because if the user put something
+        // in the clipboard recently (even if it was not necessary because it
+        // was already there), that content should be considered recent.
+        mClipboardChangeTime = System.currentTimeMillis();
+    }
+
+    /**
+     * Returns a weak hash of getCoercedText().
+     *
+     * @return a Java byte[] with the weak hash.
+     */
+    private byte[] weakMd5Hash() {
+        if (getCoercedText() == null) {
+            return new byte[] {};
+        }
+        // Compute a hash consisting of the first 4 bytes of the MD5 hash of
+        // getCoercedText().  This value is used to detect clipboard content
+        // change. Keeping only 4 bytes is a privacy requirement to introduce
+        // collision and allow deniability of having copied a given string.
+        return Arrays.copyOfRange(mMd5Hasher.digest(getCoercedText().getBytes()), 0, 4);
+    }
 }
diff --git a/ui/android/junit/src/org/chromium/ui/base/ClipboardTest.java b/ui/android/junit/src/org/chromium/ui/base/ClipboardTest.java
new file mode 100644
index 0000000..a4bd966
--- /dev/null
+++ b/ui/android/junit/src/org/chromium/ui/base/ClipboardTest.java
@@ -0,0 +1,50 @@
+// Copyright 2017 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.ui.base;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+import org.chromium.base.ContextUtils;
+import org.chromium.base.metrics.RecordHistogram;
+import org.chromium.base.metrics.RecordUserAction;
+import org.chromium.testing.local.LocalRobolectricTestRunner;
+
+/**
+ * Tests logic in the Clipboard class.
+ */
+@RunWith(LocalRobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public class ClipboardTest {
+    @BeforeClass
+    public static void beforeClass() {
+        RecordHistogram.setDisabledForTests(true);
+        RecordUserAction.setDisabledForTests(true);
+    }
+
+    @AfterClass
+    public static void afterClass() {
+        RecordHistogram.setDisabledForTests(false);
+        RecordUserAction.setDisabledForTests(false);
+    }
+
+    @Test
+    public void testGetClipboardContentChangeTimeInMillis() {
+        ContextUtils.initApplicationContext(RuntimeEnvironment.application);
+        // Upon launch, the clipboard should be at start of epoch, i.e., ancient.
+        Clipboard clipboard = Clipboard.getInstance();
+        assertEquals(0, clipboard.getClipboardContentChangeTimeInMillis());
+        // After updating the clipboard, it should have a new time.
+        clipboard.onPrimaryClipChanged();
+        assertTrue(clipboard.getClipboardContentChangeTimeInMillis() > 0);
+    }
+}
diff --git a/ui/base/clipboard/clipboard.cc b/ui/base/clipboard/clipboard.cc
index 9ab641c..6c98121 100644
--- a/ui/base/clipboard/clipboard.cc
+++ b/ui/base/clipboard/clipboard.cc
@@ -86,6 +86,10 @@
     clipboard_map->erase(it);
 }
 
+base::Time Clipboard::GetClipboardLastModifiedTime() const {
+  return base::Time();
+}
+
 void Clipboard::DispatchObject(ObjectType type, const ObjectMapParams& params) {
   // Ignore writes with empty parameters.
   for (const auto& param : params) {
diff --git a/ui/base/clipboard/clipboard.h b/ui/base/clipboard/clipboard.h
index 8ccb873c..5d27f2c 100644
--- a/ui/base/clipboard/clipboard.h
+++ b/ui/base/clipboard/clipboard.h
@@ -21,6 +21,7 @@
 #include "base/synchronization/lock.h"
 #include "base/threading/platform_thread.h"
 #include "base/threading/thread_checker.h"
+#include "base/time/time.h"
 #include "build/build_config.h"
 #include "ui/base/clipboard/clipboard_types.h"
 #include "ui/base/ui_base_export.h"
@@ -204,6 +205,10 @@
   virtual void ReadData(const FormatType& format,
                         std::string* result) const = 0;
 
+  // Returns an estimate of the time the clipboard was last updated.  If the
+  // time is unknown, returns Time::Time().
+  virtual base::Time GetClipboardLastModifiedTime() const;
+
   // Gets the FormatType corresponding to an arbitrary format string,
   // registering it with the system if needed. Due to Windows/Linux
   // limitiations, |format_string| must never be controlled by the user.
diff --git a/ui/base/clipboard/clipboard_android.cc b/ui/base/clipboard/clipboard_android.cc
index 2da7c540..8f35282 100644
--- a/ui/base/clipboard/clipboard_android.cc
+++ b/ui/base/clipboard/clipboard_android.cc
@@ -4,6 +4,9 @@
 
 #include "ui/base/clipboard/clipboard_android.h"
 
+#include <algorithm>
+
+#include "base/android/context_utils.h"
 #include "base/android/jni_string.h"
 #include "base/lazy_instance.h"
 #include "base/stl_util.h"
@@ -47,6 +50,7 @@
  public:
   ClipboardMap();
   std::string Get(const std::string& format);
+  int64_t GetLastClipboardChangeTimeInMillis();
   bool HasFormat(const std::string& format);
   void Set(const std::string& format, const std::string& data);
   void CommitToAndroidClipboard();
@@ -57,6 +61,8 @@
   std::map<std::string, std::string> map_;
   base::Lock lock_;
 
+  int64_t last_clipboard_change_time_ms_;
+
   // Java class and methods for the Android ClipboardManager.
   ScopedJavaGlobalRef<jobject> clipboard_manager_;
 };
@@ -74,6 +80,12 @@
   return it == map_.end() ? std::string() : it->second;
 }
 
+int64_t ClipboardMap::GetLastClipboardChangeTimeInMillis() {
+  base::AutoLock lock(lock_);
+  UpdateFromAndroidClipboard();
+  return last_clipboard_change_time_ms_;
+}
+
 bool ClipboardMap::HasFormat(const std::string& format) {
   base::AutoLock lock(lock_);
   UpdateFromAndroidClipboard();
@@ -158,6 +170,9 @@
 
   AddMapEntry(env, &android_clipboard_state, kPlainTextFormat, jtext);
   AddMapEntry(env, &android_clipboard_state, kHTMLFormat, jhtml);
+  last_clipboard_change_time_ms_ =
+      Java_Clipboard_getClipboardContentChangeTimeInMillis(env,
+                                                           clipboard_manager_);
 
   if (!MapIsSubset(android_clipboard_state, map_))
     android_clipboard_state.swap(map_);
@@ -399,6 +414,12 @@
   *result = g_map.Get().Get(format.ToString());
 }
 
+base::Time ClipboardAndroid::GetClipboardLastModifiedTime() const {
+  DCHECK(CalledOnValidThread());
+  return base::Time::FromJavaTime(
+      g_map.Get().GetLastClipboardChangeTimeInMillis());
+}
+
 // Main entry point used to write several values in the clipboard.
 void ClipboardAndroid::WriteObjects(ClipboardType type,
                                     const ObjectMap& objects) {
diff --git a/ui/base/clipboard/clipboard_android.h b/ui/base/clipboard/clipboard_android.h
index b86d275..a41550a2 100644
--- a/ui/base/clipboard/clipboard_android.h
+++ b/ui/base/clipboard/clipboard_android.h
@@ -45,6 +45,7 @@
                       base::string16* result) const override;
   void ReadBookmark(base::string16* title, std::string* url) const override;
   void ReadData(const FormatType& format, std::string* result) const override;
+  base::Time GetClipboardLastModifiedTime() const override;
   void WriteObjects(ClipboardType type, const ObjectMap& objects) override;
   void WriteText(const char* text_data, size_t text_len) override;
   void WriteHTML(const char* markup_data,