Merge "Improve UX of grouping buttons." into main
diff --git a/apps/ShareTest/AndroidManifest.xml b/apps/ShareTest/AndroidManifest.xml
index 7e1cb3c..08b3402 100644
--- a/apps/ShareTest/AndroidManifest.xml
+++ b/apps/ShareTest/AndroidManifest.xml
@@ -35,6 +35,13 @@
             android:name=".ImageContentProvider"
             android:grantUriPermissions="true" />
 
+        <provider
+            android:authorities="com.android.sharetest.additionalcontent"
+            android:name=".AdditionalContentProvider"
+            android:exported="false"
+            android:enabled="true"
+            android:grantUriPermissions="true" />
+
         <receiver android:name=".ChosenComponentBroadcastReceiver" />
     </application>
 </manifest>
diff --git a/apps/ShareTest/res/layout/activity_main.xml b/apps/ShareTest/res/layout/activity_main.xml
index 3077ea9..5716bbd 100644
--- a/apps/ShareTest/res/layout/activity_main.xml
+++ b/apps/ShareTest/res/layout/activity_main.xml
@@ -49,11 +49,19 @@
                 />
             </RadioGroup>
 
-            <TextView
+            <CheckBox
+                android:id="@+id/shareousel"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
-                style="@style/title"
-                android:text="Represent Media As"
+                android:text="Enable Shareousel"
+                />
+
+            <TextView
+                android:id="@+id/media_type_header"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                style="@style/subtitle"
+                android:text="Media Type"
                 />
 
             <Spinner
@@ -190,6 +198,19 @@
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 style="@style/title"
+                android:text="Metadata"
+                />
+
+            <EditText
+                android:id="@+id/metadata"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                />
+
+            <TextView
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                style="@style/title"
                 android:text="Advanced Options"
             />
 
diff --git a/apps/ShareTest/src/com/android/sharetest/AdditionalContentProvider.kt b/apps/ShareTest/src/com/android/sharetest/AdditionalContentProvider.kt
new file mode 100644
index 0000000..b0a79a6
--- /dev/null
+++ b/apps/ShareTest/src/com/android/sharetest/AdditionalContentProvider.kt
@@ -0,0 +1,99 @@
+package com.android.sharetest
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.Intent
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import android.os.Bundle
+import android.os.CancellationSignal
+import android.service.chooser.AdditionalContentContract
+import kotlin.random.Random
+
+class AdditionalContentProvider : ContentProvider() {
+    override fun onCreate(): Boolean {
+        return true
+    }
+
+    override fun query(
+        uri: Uri,
+        projection: Array<String>?,
+        queryArgs: Bundle?,
+        cancellationSignal: CancellationSignal?
+    ): Cursor? {
+        val context = context ?: return null
+        val cursor = MatrixCursor(arrayOf(AdditionalContentContract.Columns.URI))
+        val chooserIntent =
+            queryArgs?.getParcelable(Intent.EXTRA_INTENT, Intent::class.java) ?: return cursor
+        // Images are img1 ... img8
+        var uris = Array(ImageContentProvider.IMAGE_COUNT) { idx ->
+            ImageContentProvider.makeItemUri(idx + 1, "image/jpeg")
+        }
+        val callingPackage = getCallingPackage()
+        for (u in uris) {
+            cursor.addRow(arrayOf(u.toString()))
+            context.grantUriPermission(callingPackage, u, Intent.FLAG_GRANT_READ_URI_PERMISSION)
+        }
+        val startPos = chooserIntent.getIntExtra(CURSOR_START_POSITION, -1)
+        if (startPos >= 0) {
+            var cursorExtras = cursor.extras
+            cursorExtras = if (cursorExtras == null) {
+                Bundle()
+            } else {
+                Bundle(cursorExtras)
+            }
+            cursorExtras.putInt(AdditionalContentContract.CursorExtraKeys.POSITION, startPos)
+            cursor.extras = cursorExtras
+        }
+        return cursor
+    }
+
+    override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
+        val context = context ?: return null
+        val result = Bundle()
+        val customActionFactory = CustomActionFactory(context)
+
+        // Make a random number of custom actions each time they change something.
+        result.putParcelableArray(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS,
+            customActionFactory.getCustomActions(Random.nextInt(5)))
+        return result
+    }
+
+    override fun query(
+        uri: Uri,
+        projection: Array<String>?,
+        selection: String?,
+        selectionArgs: Array<String>?,
+        sortOrder: String?
+    ): Cursor? {
+        return null
+    }
+
+    override fun getType(uri: Uri): String? {
+        return null
+    }
+
+    override fun insert(uri: Uri, values: ContentValues?): Uri? {
+        return null
+    }
+
+    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int {
+        return 0
+    }
+
+    override fun update(
+        uri: Uri,
+        values: ContentValues?,
+        selection: String?,
+        selectionArgs: Array<String>?
+    ): Int {
+        return 0
+    }
+
+    companion object {
+        val ADDITIONAL_CONTENT_URI = Uri.parse("content://com.android.sharetest.additionalcontent")
+        val CURSOR_START_POSITION = "com.android.sharetest.CURSOR_START_POS"
+    }
+}
+
diff --git a/apps/ShareTest/src/com/android/sharetest/CustomActionFactory.kt b/apps/ShareTest/src/com/android/sharetest/CustomActionFactory.kt
new file mode 100644
index 0000000..78b839c
--- /dev/null
+++ b/apps/ShareTest/src/com/android/sharetest/CustomActionFactory.kt
@@ -0,0 +1,31 @@
+package com.android.sharetest
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.graphics.drawable.Icon
+import android.service.chooser.ChooserAction
+
+class CustomActionFactory(private val context: Context) {
+    fun getCustomActions(count: Int): Array<ChooserAction> {
+        val actions = Array(count) { idx ->
+            val customAction = PendingIntent.getBroadcast(
+                context,
+                idx,
+                Intent(BROADCAST_ACTION),
+                PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
+            )
+            ChooserAction.Builder(
+                Icon.createWithResource(context, R.drawable.testicon),
+                "Action ${idx + 1}",
+                customAction
+            ).build()
+        }
+
+        return actions
+    }
+
+    companion object {
+        const val BROADCAST_ACTION = "broadcast-action"
+    }
+}
diff --git a/apps/ShareTest/src/com/android/sharetest/ImageContentProvider.kt b/apps/ShareTest/src/com/android/sharetest/ImageContentProvider.kt
index 586b4d3..6118560 100644
--- a/apps/ShareTest/src/com/android/sharetest/ImageContentProvider.kt
+++ b/apps/ShareTest/src/com/android/sharetest/ImageContentProvider.kt
@@ -73,6 +73,7 @@
         if (shouldFailOpen()) {
             return null
         }
+
         return uri.lastPathSegment?.let{ context?.assets?.openFd(it) }
     }
 
@@ -89,6 +90,14 @@
     }
 
     companion object {
+        fun makeItemUri(idx: Int, mimeType: String): Uri =
+            Uri.parse("${URI_PREFIX}img$idx.jpg")
+                    .buildUpon()
+                    .appendQueryParameter(PARAM_TYPE, mimeType)
+                    .build()
+
+        const val IMAGE_COUNT = 8
+
         const val URI_PREFIX = "content://com.android.sharetest.provider/"
         const val PARAM_TYPE = "type"
         val ICON_URI: Uri = Uri.parse("${URI_PREFIX}letter_a.png")
diff --git a/apps/ShareTest/src/com/android/sharetest/ShareTestActivity.kt b/apps/ShareTest/src/com/android/sharetest/ShareTestActivity.kt
index 11edd16..5e85962 100644
--- a/apps/ShareTest/src/com/android/sharetest/ShareTestActivity.kt
+++ b/apps/ShareTest/src/com/android/sharetest/ShareTestActivity.kt
@@ -26,7 +26,6 @@
 import android.graphics.Color
 import android.graphics.Typeface
 import android.graphics.drawable.Icon
-import android.net.Uri
 import android.os.Bundle
 import android.service.chooser.ChooserAction
 import android.text.Spannable
@@ -41,14 +40,14 @@
 import android.widget.ArrayAdapter
 import android.widget.Button
 import android.widget.CheckBox
+import android.widget.EditText
 import android.widget.RadioButton
 import android.widget.RadioGroup
 import android.widget.Spinner
 import android.widget.Toast
 import androidx.annotation.RequiresApi
+import kotlin.random.Random
 
-private const val BROADCAST_ACTION = "broadcast-action"
-private const val IMAGE_COUNT = 8
 private const val TYPE_IMAGE = "Image"
 private const val TYPE_VIDEO = "Video"
 private const val TYPE_PDF = "PDF Doc"
@@ -63,8 +62,12 @@
     private lateinit var mediaSelection: RadioGroup
     private lateinit var textSelection: RadioGroup
     private lateinit var mediaTypeSelection: Spinner
+    private lateinit var mediaTypeHeader: View
     private lateinit var richText: CheckBox
     private lateinit var albumCheck: CheckBox
+    private lateinit var metadata: EditText
+    private lateinit var shareouselCheck: CheckBox
+    private val customActionFactory = CustomActionFactory(this)
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
@@ -79,17 +82,20 @@
 
         registerReceiver(
             customActionReceiver,
-            IntentFilter(BROADCAST_ACTION),
+            IntentFilter(CustomActionFactory.BROADCAST_ACTION),
             Context.RECEIVER_EXPORTED
         )
 
         richText = requireViewById(R.id.use_rich_text)
         albumCheck = requireViewById(R.id.album_text)
+        shareouselCheck = requireViewById(R.id.shareousel)
         mediaTypeSelection = requireViewById(R.id.media_type_selection)
+        mediaTypeHeader = requireViewById(R.id.media_type_header)
         mediaSelection = requireViewById<RadioGroup>(R.id.media_selection).apply {
             setOnCheckedChangeListener { _, id -> updateMediaTypesList(id) }
             check(R.id.no_media)
         }
+        metadata = requireViewById<EditText>(R.id.metadata)
 
         textSelection = requireViewById<RadioGroup>(R.id.text_selection).apply {
             check(R.id.short_text)
@@ -152,7 +158,7 @@
         ).apply {
             setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
         }
-        mediaTypeSelection.isEnabled = false
+        setMediaTypeVisibility(false)
     }
 
     private fun setSingleMediaTypeOptions() {
@@ -163,7 +169,7 @@
         ).apply {
             setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
         }
-        mediaTypeSelection.isEnabled = true
+        setMediaTypeVisibility(true)
     }
 
     private fun setAllMediaTypeOptions() {
@@ -182,7 +188,14 @@
         ).apply {
             setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
         }
-        mediaTypeSelection.isEnabled = true
+        setMediaTypeVisibility(true)
+    }
+
+    private fun setMediaTypeVisibility(visible: Boolean) {
+        val visibility = if (visible) View.VISIBLE else View.GONE
+        mediaTypeHeader.visibility = visibility
+        mediaTypeSelection.visibility = visibility
+        shareouselCheck.visibility = visibility
     }
 
     private fun share(view: View) {
@@ -192,20 +205,22 @@
         val mimeTypes = getSelectedContentTypes()
 
         val imageUris = ArrayList(
-            (1..IMAGE_COUNT).map{ idx ->
-                makeItemUri(idx, mimeTypes[idx % mimeTypes.size])
-            }.shuffled())
+            (1..ImageContentProvider.IMAGE_COUNT).map{ idx ->
+                ImageContentProvider.makeItemUri(idx, mimeTypes[idx % mimeTypes.size])
+            })
+
+        val imageIndex = Random.nextInt(ImageContentProvider.IMAGE_COUNT)
 
         when (mediaSelection.checkedRadioButtonId) {
             R.id.one_image -> share.apply {
-                putExtra(Intent.EXTRA_STREAM, imageUris[0])
+                putExtra(Intent.EXTRA_STREAM, imageUris[imageIndex])
                 clipData = ClipData("", arrayOf("image/jpg"), ClipData.Item(imageUris[0]))
                 type = if (mimeTypes.size == 1) mimeTypes[0] else "*/*"
             }
             R.id.many_images -> share.apply {
                 action = Intent.ACTION_SEND_MULTIPLE
                 clipData = ClipData("", arrayOf("image/jpg"), ClipData.Item(imageUris[0])).apply {
-                    for (i in 1 until IMAGE_COUNT) {
+                    for (i in 1 until ImageContentProvider.IMAGE_COUNT) {
                         addItem(ClipData.Item(imageUris[i]))
                     }
                 }
@@ -253,7 +268,7 @@
             val pendingIntent = PendingIntent.getBroadcast(
                 this,
                 1,
-                Intent(BROADCAST_ACTION),
+                Intent(CustomActionFactory.BROADCAST_ACTION),
                 PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
             )
             val modifyShareAction = ChooserAction.Builder(
@@ -267,13 +282,25 @@
 
         when (requireViewById<RadioGroup>(R.id.action_selection).checkedRadioButtonId) {
             R.id.one_action -> chooserIntent.putExtra(
-                Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, getCustomActions(1)
+                Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, customActionFactory.getCustomActions(1)
             )
             R.id.five_actions -> chooserIntent.putExtra(
-                Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, getCustomActions(5)
+                Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, customActionFactory.getCustomActions(5)
             )
         }
 
+        if (metadata.text.isNotEmpty()) {
+            chooserIntent.putExtra(Intent.EXTRA_METADATA_TEXT, metadata.text)
+        }
+        if (shareouselCheck.isChecked) {
+            chooserIntent.putExtra(Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI,
+                AdditionalContentProvider.ADDITIONAL_CONTENT_URI)
+            chooserIntent.putExtra(Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, 0)
+            chooserIntent.clipData?.addItem(
+                ClipData.Item(AdditionalContentProvider.ADDITIONAL_CONTENT_URI))
+            chooserIntent.putExtra(AdditionalContentProvider.CURSOR_START_POSITION, 0)
+        }
+
         startActivity(chooserIntent)
     }
 
@@ -290,12 +317,6 @@
             }
         } ?: arrayOf("image/jpeg")
 
-    private fun makeItemUri(idx: Int, mimeType: String): Uri =
-        Uri.parse("${ImageContentProvider.URI_PREFIX}img$idx.jpg")
-            .buildUpon()
-            .appendQueryParameter(ImageContentProvider.PARAM_TYPE, mimeType)
-            .build()
-
     private fun setIntentText(intent: Intent, text: CharSequence) {
         if (TextUtils.isEmpty(intent.type)) {
             intent.type = "text/plain"
@@ -349,26 +370,6 @@
                 if (richText.isChecked) it else it.toString()
             }
 
-    private fun getCustomActions(count: Int): Array<ChooserAction?> {
-        val actions = arrayOfNulls<ChooserAction>(count)
-
-        for (i in 0 until count) {
-            val customAction = PendingIntent.getBroadcast(
-                this,
-                i,
-                Intent(BROADCAST_ACTION),
-                PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
-            )
-            actions[i] = ChooserAction.Builder(
-                Icon.createWithResource(this, R.drawable.testicon),
-                "Action ${i + 1}",
-                customAction
-            ).build()
-        }
-
-        return actions
-    }
-
     override fun onDestroy() {
         super.onDestroy()
         unregisterReceiver(customActionReceiver)
diff --git a/samples/ApiDemos/tests/Android.bp b/samples/ApiDemos/tests/Android.bp
index abb7fd7..b2fbf80 100644
--- a/samples/ApiDemos/tests/Android.bp
+++ b/samples/ApiDemos/tests/Android.bp
@@ -10,7 +10,10 @@
         "android.test.runner.stubs",
         "android.test.base.stubs",
     ],
-    static_libs: ["junit"],
+    static_libs: [
+        "junit",
+        "androidx.test.rules",
+    ],
     // Include all test java files.
     srcs: ["src/**/*.java"],
     // Notice that we don't have to include the src files of ApiDemos because, by
diff --git a/samples/ApiDemos/tests/src/com/example/android/apis/ApiDemosApplicationTests.java b/samples/ApiDemos/tests/src/com/example/android/apis/ApiDemosApplicationTests.java
index 3074ca6..0b63f6c 100644
--- a/samples/ApiDemos/tests/src/com/example/android/apis/ApiDemosApplicationTests.java
+++ b/samples/ApiDemos/tests/src/com/example/android/apis/ApiDemosApplicationTests.java
@@ -17,8 +17,9 @@
 package com.example.android.apis;
 
 import android.test.ApplicationTestCase;
-import android.test.suitebuilder.annotation.MediumTest;
-import android.test.suitebuilder.annotation.SmallTest;
+
+import androidx.test.filters.MediumTest;
+import androidx.test.filters.SmallTest;
 
 /**
  * This is a simple framework for a test of an Application.  See 
diff --git a/samples/ApiDemos/tests/src/com/example/android/apis/app/ForwardingTest.java b/samples/ApiDemos/tests/src/com/example/android/apis/app/ForwardingTest.java
index 340bedb..19677c0 100644
--- a/samples/ApiDemos/tests/src/com/example/android/apis/app/ForwardingTest.java
+++ b/samples/ApiDemos/tests/src/com/example/android/apis/app/ForwardingTest.java
@@ -16,15 +16,16 @@
 
 package com.example.android.apis.app;
 
-import com.example.android.apis.R;
-import com.example.android.apis.view.Focus2ActivityTest;
-
 import android.content.Context;
 import android.content.Intent;
 import android.test.ActivityUnitTestCase;
-import android.test.suitebuilder.annotation.MediumTest;
 import android.widget.Button;
 
+import androidx.test.filters.MediumTest;
+
+import com.example.android.apis.R;
+import com.example.android.apis.view.Focus2ActivityTest;
+
 /**
  * This demonstrates completely isolated "unit test" of an Activity class.
  *
diff --git a/samples/ApiDemos/tests/src/com/example/android/apis/app/LocalServiceTest.java b/samples/ApiDemos/tests/src/com/example/android/apis/app/LocalServiceTest.java
index 78fee41..99d7426 100644
--- a/samples/ApiDemos/tests/src/com/example/android/apis/app/LocalServiceTest.java
+++ b/samples/ApiDemos/tests/src/com/example/android/apis/app/LocalServiceTest.java
@@ -16,16 +16,12 @@
 
 package com.example.android.apis.app;
 
-import android.app.Notification;
-import android.app.NotificationManager;
-import android.content.Context;
 import android.content.Intent;
-import android.os.Handler;
 import android.os.IBinder;
-import android.test.MoreAsserts;
 import android.test.ServiceTestCase;
-import android.test.suitebuilder.annotation.MediumTest;
-import android.test.suitebuilder.annotation.SmallTest;
+
+import androidx.test.filters.MediumTest;
+import androidx.test.filters.SmallTest;
 
 /**
  * This is a simple framework for a test of a Service.  See {@link android.test.ServiceTestCase
diff --git a/samples/ApiDemos/tests/src/com/example/android/apis/os/MorseCodeConverterTest.java b/samples/ApiDemos/tests/src/com/example/android/apis/os/MorseCodeConverterTest.java
index 7cf0395..7e18a1b 100644
--- a/samples/ApiDemos/tests/src/com/example/android/apis/os/MorseCodeConverterTest.java
+++ b/samples/ApiDemos/tests/src/com/example/android/apis/os/MorseCodeConverterTest.java
@@ -16,8 +16,9 @@
 
 package com.example.android.apis.os;
 
+import androidx.test.filters.SmallTest;
+
 import junit.framework.TestCase;
-import android.test.suitebuilder.annotation.SmallTest;
 
 /**
  * An example of a true unit test that tests the utility class {@link MorseCodeConverter}.
diff --git a/samples/ApiDemos/tests/src/com/example/android/apis/view/Focus2ActivityTest.java b/samples/ApiDemos/tests/src/com/example/android/apis/view/Focus2ActivityTest.java
index b555913..7cc595a 100644
--- a/samples/ApiDemos/tests/src/com/example/android/apis/view/Focus2ActivityTest.java
+++ b/samples/ApiDemos/tests/src/com/example/android/apis/view/Focus2ActivityTest.java
@@ -16,13 +16,14 @@
 
 package com.example.android.apis.view;
 
-import com.example.android.apis.R;
-
 import android.test.ActivityInstrumentationTestCase2;
-import android.test.suitebuilder.annotation.MediumTest;
 import android.view.KeyEvent;
 import android.widget.Button;
 
+import androidx.test.filters.MediumTest;
+
+import com.example.android.apis.R;
+
 /**
  * An example of an {@link ActivityInstrumentationTestCase} of a specific activity {@link Focus2}.
  * By virtue of extending {@link ActivityInstrumentationTestCase}, the target activity is automatically
diff --git a/samples/ApiDemos/tests/src/com/example/android/apis/view/Focus2AndroidTest.java b/samples/ApiDemos/tests/src/com/example/android/apis/view/Focus2AndroidTest.java
index b52e4b8..db164fa 100644
--- a/samples/ApiDemos/tests/src/com/example/android/apis/view/Focus2AndroidTest.java
+++ b/samples/ApiDemos/tests/src/com/example/android/apis/view/Focus2AndroidTest.java
@@ -16,17 +16,18 @@
 
 package com.example.android.apis.view;
 
-import com.example.android.apis.R;
-
 import android.content.Context;
 import android.test.AndroidTestCase;
-import android.test.suitebuilder.annotation.SmallTest;
 import android.view.FocusFinder;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.Button;
 
+import androidx.test.filters.SmallTest;
+
+import com.example.android.apis.R;
+
 /**
  * This exercises the same logic as {@link Focus2ActivityTest} but in a lighter
  * weight manner; it doesn't need to launch the activity, and it can test the
diff --git a/tools/winscope/google.tslint.json b/tools/winscope/google.tslint.json
index 68500bd..fd318dc 100644
--- a/tools/winscope/google.tslint.json
+++ b/tools/winscope/google.tslint.json
@@ -2,14 +2,12 @@
 // Enable non-strict mode to allow comments.
 {
   "rules": {
-    // The disabled rules below are google3 custom rules that need to be built
-    // and added to the project (see google3/javascript/typescript/tslint/rules/).
-    // If needed, it should be possible to build and run them in the Winscope npm
-    // environment as well. It is probably simpler and quicker to wait till
-    // Winscope is ported to google3 though.
-
-    //"angular-use-component-harnesses": true,
-    //"angular-output-is-readonly": true,
+    "angular-use-component-harnesses": true,
+    "angular-output-is-readonly": true,
+    "angular-no-entry-components": true,
+    "angular-no-manual-lifecycle-hook-method-calls": true,
+    "angular-no-test-overrides-for-framework-features": true,
+    "angular-non-null-asserted-input-is-required": true,
     "array-type": [true, "array-simple"],
     "arrow-return-shorthand": true,
     "ban": [true,
@@ -39,100 +37,106 @@
       {"name": ["pdescribe", "only"]},
       {"name": ["describeWithDate", "skip"]},
       {"name": ["describeWithDate", "only"]},
+      {"name": ["describeBooleanFlag", "only"]},
+      {"name": ["describeEnabledBooleanFlag", "only"]},
       {"name": "parseInt", "message": "See http://go/tsstyle#type-coercion"},
       {"name": "parseFloat", "message": "See http://go/tsstyle#type-coercion"},
       {"name": "Array", "message": "See http://go/tsstyle#array-constructor"},
       {"name": ["*", "innerText"], "message": "Use .textContent instead. http://go/typescript/patterns#browser-oddities"},
       {"name": ["goog", "setTestOnly"], "message": "See http://go/tsstyle#tests"}
     ],
-    //"ban-as-never": true,
-    //"ban-implicit-undefined-default-parameters": true,
-    //"ban-jsdoc-enum-tag": true,
-    //"ban-malformed-import-paths": true,
-    //"ban-passing-async-function-to-describe": true,
-    //"ban-spy-returning-rejected-promise": true,
-    //"ban-strict-prop-init-comment": true,
+    "ban-as-never": true,
+    "ban-const-enum": true,
+    "ban-implicit-undefined-default-parameters": true,
+    "ban-jsdoc-enum-tag": true,
+    "ban-malformed-import-paths": true,
+    "ban-passing-async-function-to-describe": true,
+    "ban-spy-returning-rejected-promise": true,
+    "ban-strict-prop-init-comment": true,
     // allowedSuppressions is a list of strings with no whitespace which, when
-    // found wrapped in parentheses immediately after the suppresion, will
-    // prevent this rule from triggering.
-    // For example: `// @ts-ignore(go/ts99upgrade) Some explanation.`
+    // found anywhere in the suppression comment, will prevent this rule from
+    // triggering.
+    // For example: `// TODO: go/ts99upgrade - Fix the suppressed error`
     // Prefer using b/ bug links or go/ go links.
     // To check if your suppression string is available in prod, use:
     //   cl-status/#/summary/tricorder.go-worker/[[SUBMITTED_CL_NUM]]
     // Or, for CLs using the suppression, a go/startblock directive of:
     //   cl-status tricorder.go-worker contains cl/[[SUBMITTED_CL_NUM]] in prod
-    //"ban-ts-suppressions": [true, {
-    //  "allowedSuppressions": [
-    //    "b/249999919", // Node 18.x typings update
-    //    "go/tsjs-aatm",
-    //    "go/ts49upgrade",
-    //    "go/jspb-ts-enums-fix",
-    //    "KEEP_ME_LAST_TO_AVOID_NEEDING_TO_ADD_A_COMMA_TO_THE_LAST_ENTRY"
-    //  ]
-    //}],
-    //"ban-tslint-disable": true,
+    "ban-ts-suppressions": [true, {
+      "allowedSuppressions": [
+        "b/317387267",
+        "go/tsjs-aatm",
+        "go/ts54upgrade",
+        "go/closure-fail-never",
+        "go/clutz-ts-ignore",
+        "KEEP_ME_LAST_TO_AVOID_NEEDING_TO_ADD_A_COMMA_TO_THE_LAST_ENTRY"
+      ]
+    }],
+    "ban-tslint-disable": true,
     "ban-types": [true,
       ["Object", "Use {} or 'object' instead. See http://go/ts-style#wrapper-types"],
       ["String", "Use 'string' instead."],
       ["Number", "Use 'number' instead."],
       ["Boolean", "Use 'boolean' instead."],
       // Add tests in google3/javascript/typescript/tslint/test/googleConfig/ban_types.ts.lint
-      ["AnyDuring(?!((ICentral|CelloJs|AngularIvy|Drive|1TF|AllAsUnknown|GoogPromiseThen|Search|DWE|JasmineApril2021|Assisted)Migration)).*",
+      ["AnyDuring(?!((CelloJs|AngularIvy|Drive|1TF|AllAsUnknown|GoogPromiseThen|Search|DWE|Assisted)Migration)).*",
        "AnyDuringMigration is a quick-fix used during TypeScript migrations, and should be removed as soon as possible. See http://go/any_during_migration."]
     ],
     // go/keep-sorted start
-    //"class-as-namespace": true,
+    "class-as-namespace": true,
     "class-name": true,
     "curly": [true, "ignore-same-line"],
-    //"decorator-placement": true,
-    //"discourage-angular-material-subpackage-imports": true,
-    //"enforce-comments-on-exported-symbols": true,
-    //"enforce-name-casing": true,
-    //"file-comment": true,
-    //"fix-trailing-comma-import-export": true,
+    "decorator-placement": true,
+    "discourage-angular-material-subpackage-imports": true,
+    "enforce-comments-on-exported-symbols": true,
+    "file-comment": true,
     "forin": true,
     "interface-name": [true, "never-prefix"],
     "interface-over-type-literal": true,
     "jsdoc-format": true,
-    //"jsdoc-tags": true,
+    "jsdoc-tags": true,
     "label-position": true,
     "member-access": [true, "no-public"],
     "new-parens": true,
     "no-angle-bracket-type-assertion": true,
-    //TODO (b/264508345): enable rule below after removeing 'any' types
-    //"no-any": true,
+    "no-any": true,
     "no-conditional-assignment": [true, "allow-within-parenthesis"],
     "no-construct": true,
     "no-debugger": true,
     "no-default-export": true,
     "no-duplicate-switch-case": true,
-    //"no-inferrable-new-expression": true,
+    "no-inferrable-new-expression": true,
+    "no-inferrable-primitive-types": [true, "ignore-readonly-properties"],
     "no-namespace": [true, "allow-declarations"],
-    //"no-new-decorators": true,
-    //"no-quoted-property-signatures": true,
+    "no-new-decorators": true,
+    "no-quoted-property-signatures": true,
     "no-reference": true,
     "no-require-imports": true,
-    //"no-return-only-generics": true,
+    "no-return-only-generics": true,
     "no-string-throw": true,
-    //"no-undefined-type-alias": true,
-    //"no-unnecessary-escapes": true,
+    "no-undefined-type-alias": true,
+    "no-unnecessary-escapes": true,
     "no-unsafe-finally": true,
     "no-unused-expression": [true, "allow-fast-null-checks"],
     "no-unused-variable": true,
-    //"no-unused-wiz-injections": true,
+    "no-unused-wiz-injections": true,
     "no-var-keyword": true,
     "object-literal-shorthand": true,
+    "one-variable-per-declaration": [true, "ignore-for-loop"],
     "only-arrow-functions": [true, "allow-declarations", "allow-named-functions"],
     "prefer-const": [true, {"destructuring": "all"}],
-    //"prefer-function-declaration": true,
-    //"prefer-type-annotation": true,
+    "prefer-function-declaration": true,
+    "prefer-type-annotation": true,
     "radix": true,
     "semicolon": [true, "always", "strict-bound-class-methods"],
     "static-this": true,
     "switch-default": true,
     "triple-equals": [true, "allow-null-check"],
-    "unnecessary-constructor": true
-    //"well-formed-closure-message": true
+    "unnecessary-constructor": true,
+    "well-formed-closure-message": true
     // go/keep-sorted end
-  }
-}
+  },
+  "rulesDirectory": [
+    "rules"
+  ]
+}
\ No newline at end of file
diff --git a/tools/winscope/prettier.config.js b/tools/winscope/prettier.config.js
index 72fd720..dabbe2b 100644
--- a/tools/winscope/prettier.config.js
+++ b/tools/winscope/prettier.config.js
@@ -1,29 +1,54 @@
-/*
- * Copyright (C) 2022 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.
- */
-
-module.exports = {
-  arrowParens: 'always',
-  bracketSameLine: true,
-  bracketSpacing: false,
-  printWidth: 100,
-  singleQuote: true,
-  semi: true,
+const shared = {
+  printWidth: 80,
   tabWidth: 2,
   useTabs: false,
-  trailingComma: 'es5',
-  //enable this setting in case prettier-plugin-organize-imports starts breaking stuff
-  //organizeImportsSkipDestructiveCodeActions: true,
+  semi: true,
+  singleQuote: true,
+  quoteProps: 'preserve',
+  bracketSpacing: false,
+  trailingComma: 'all',
+  arrowParens: 'always',
+  embeddedLanguageFormatting: 'off',
+  bracketSameLine: true,
+  singleAttributePerLine: false,
+  jsxSingleQuote: false,
+  htmlWhitespaceSensitivity: 'strict',
+};
+
+module.exports = {
+  overrides: [
+    {
+      /** TSX/TS/JS-specific configuration. */
+      files: '*.tsx',
+      options: shared,
+    },
+    {
+      files: '*.ts',
+      options: shared,
+    },
+    {
+      files: '*.js',
+      options: shared,
+    },
+    {
+      /** Sass-specific configuration. */
+      files: '*.scss',
+      options: {
+        singleQuote: true,
+      },
+    },
+    {
+      files: '*.html',
+      options: {
+        printWidth: 100,
+      },
+    },
+    {
+      files: '*.acx.html',
+      options: {
+        parser: 'angular',
+        singleQuote: true,
+      },
+    },
+  ],
 };
diff --git a/tools/winscope/src/app/mediator.ts b/tools/winscope/src/app/mediator.ts
index 3796c62..72a206e 100644
--- a/tools/winscope/src/app/mediator.ts
+++ b/tools/winscope/src/app/mediator.ts
@@ -149,6 +149,9 @@
     });
 
     await event.visit(WinscopeEventType.TRACE_POSITION_UPDATE, async (event) => {
+      if (event.updateTimeline) {
+        this.timelineData.setPosition(event.position);
+      }
       await this.propagateTracePosition(event.position, false);
     });
 
diff --git a/tools/winscope/src/app/mediator_test.ts b/tools/winscope/src/app/mediator_test.ts
index 58a59bf..be21df4 100644
--- a/tools/winscope/src/app/mediator_test.ts
+++ b/tools/winscope/src/app/mediator_test.ts
@@ -14,6 +14,7 @@
  * limitations under the License.
  */
 
+import {assertDefined} from 'common/assert_utils';
 import {FunctionUtils} from 'common/function_utils';
 import {NO_TIMEZONE_OFFSET_FACTORY, TimestampFactory} from 'common/timestamp_factory';
 import {ProgressListener} from 'messaging/progress_listener';
@@ -245,6 +246,26 @@
     );
   });
 
+  it('propagates trace position update and updates timeline data', async () => {
+    await loadFiles();
+    await loadTraceView();
+
+    // notify position
+    resetSpyCalls();
+    const finalTimestampNs = timelineData.getFullTimeRange().to.getValueNs();
+    const timestamp = NO_TIMEZONE_OFFSET_FACTORY.makeRealTimestamp(finalTimestampNs);
+    const position = TracePosition.fromTimestamp(timestamp);
+
+    await mediator.onWinscopeEvent(new TracePositionUpdate(position, true));
+    checkTracePositionUpdateEvents(
+      [viewerStub0, viewerOverlay, timelineComponent, crossToolProtocol],
+      position
+    );
+    expect(assertDefined(timelineData.getCurrentPosition()).timestamp.getValueNs()).toEqual(
+      finalTimestampNs
+    );
+  });
+
   it("initializes viewers' trace position also when loaded traces have no valid timestamps", async () => {
     const dumpFile = await UnitTestUtils.getFixtureFile('traces/dump_WindowManager.pb');
     await mediator.onWinscopeEvent(new AppFilesUploaded([dumpFile]));
diff --git a/tools/winscope/src/common/time_utils.ts b/tools/winscope/src/common/time_utils.ts
index 5350c52..aef660a 100644
--- a/tools/winscope/src/common/time_utils.ts
+++ b/tools/winscope/src/common/time_utils.ts
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-import {Timestamp, TimestampType, TimezoneInfo} from 'common/time';
+import {Timestamp, TimestampType} from 'common/time';
 import {NO_TIMEZONE_OFFSET_FACTORY} from './timestamp_factory';
 
 export class TimeUtils {
@@ -133,10 +133,10 @@
     return ts1;
   }
 
-  static addTimezoneOffset(timezoneInfo: TimezoneInfo, timestampNs: bigint): bigint {
+  static addTimezoneOffset(timezone: string, timestampNs: bigint): bigint {
     const utcDate = new Date(Number(timestampNs / 1000000n));
-    const timezoneDateFormatted = utcDate.toLocaleString(timezoneInfo.locale, {
-      timeZone: timezoneInfo.timezone,
+    const timezoneDateFormatted = utcDate.toLocaleString('en-US', {
+      timeZone: timezone,
     });
     const timezoneDate = new Date(timezoneDateFormatted);
     const hoursDiff = timezoneDate.getHours() - utcDate.getHours();
diff --git a/tools/winscope/src/common/time_utils_test.ts b/tools/winscope/src/common/time_utils_test.ts
index 38ea474..3f34054 100644
--- a/tools/winscope/src/common/time_utils_test.ts
+++ b/tools/winscope/src/common/time_utils_test.ts
@@ -341,19 +341,39 @@
     expect(TimeUtils.format(timestamp)).toEqual('100ms0ns');
   });
 
-  it('addTimezoneOffset', () => {
-    const timestampNs = 1000000000000n;
-    expect(
-      TimeUtils.addTimezoneOffset({timezone: 'Europe/London', locale: 'en-GB'}, timestampNs)
-    ).toEqual(4600000000000n);
-    expect(
-      TimeUtils.addTimezoneOffset({timezone: 'Europe/Zurich', locale: 'en-US'}, timestampNs)
-    ).toEqual(4600000000000n);
-    expect(
-      TimeUtils.addTimezoneOffset({timezone: 'America/Los_Angeles', locale: 'en-US'}, timestampNs)
-    ).toEqual(58600000000000n);
-    expect(
-      TimeUtils.addTimezoneOffset({timezone: 'Asia/Kolkata', locale: 'en-US'}, timestampNs)
-    ).toEqual(20800000000000n);
+  it('addTimezoneOffset for elapsed timestamps', () => {
+    const elapsedTimestampNs = 1000000000000n;
+    expect(TimeUtils.addTimezoneOffset('Europe/London', elapsedTimestampNs)).toEqual(
+      4600000000000n
+    );
+    expect(TimeUtils.addTimezoneOffset('Europe/Zurich', elapsedTimestampNs)).toEqual(
+      4600000000000n
+    );
+    expect(TimeUtils.addTimezoneOffset('America/Los_Angeles', elapsedTimestampNs)).toEqual(
+      58600000000000n
+    );
+    expect(TimeUtils.addTimezoneOffset('Asia/Kolkata', elapsedTimestampNs)).toEqual(
+      20800000000000n
+    );
+  });
+
+  it('addTimezoneOffset for real timestamps', () => {
+    const realTimestampNs = 1706094750112797658n;
+    expect(TimeUtils.addTimezoneOffset('Europe/London', realTimestampNs)).toEqual(
+      1706094750112797658n
+    );
+    expect(TimeUtils.addTimezoneOffset('Europe/Zurich', realTimestampNs)).toEqual(
+      1706098350112797658n
+    );
+    expect(TimeUtils.addTimezoneOffset('America/Los_Angeles', realTimestampNs)).toEqual(
+      1706065950112797658n
+    );
+    expect(TimeUtils.addTimezoneOffset('Asia/Kolkata', realTimestampNs)).toEqual(
+      1706114550112797658n
+    );
+  });
+
+  it('addTimezoneOffset throws for invalid timezone', () => {
+    expect(() => TimeUtils.addTimezoneOffset('Invalid/Timezone', 10n)).toThrow();
   });
 });
diff --git a/tools/winscope/src/common/timestamp_factory.ts b/tools/winscope/src/common/timestamp_factory.ts
index 00872a9..d89fcff 100644
--- a/tools/winscope/src/common/timestamp_factory.ts
+++ b/tools/winscope/src/common/timestamp_factory.ts
@@ -24,7 +24,7 @@
     const valueWithRealtimeOffset = valueNs + (realToElapsedTimeOffsetNs ?? 0n);
     const localNs =
       this.timezoneInfo.timezone !== 'UTC'
-        ? TimeUtils.addTimezoneOffset(this.timezoneInfo, valueWithRealtimeOffset)
+        ? TimeUtils.addTimezoneOffset(this.timezoneInfo.timezone, valueWithRealtimeOffset)
         : valueWithRealtimeOffset;
     return new Timestamp(TimestampType.REAL, localNs, localNs - valueWithRealtimeOffset);
   }
diff --git a/tools/winscope/src/messaging/winscope_event.ts b/tools/winscope/src/messaging/winscope_event.ts
index 86a52a6..2284048 100644
--- a/tools/winscope/src/messaging/winscope_event.ts
+++ b/tools/winscope/src/messaging/winscope_event.ts
@@ -151,20 +151,22 @@
 export class TracePositionUpdate extends WinscopeEvent {
   override readonly type = WinscopeEventType.TRACE_POSITION_UPDATE;
   readonly position: TracePosition;
+  readonly updateTimeline: boolean;
 
-  constructor(position: TracePosition) {
+  constructor(position: TracePosition, updateTimeline = false) {
     super();
     this.position = position;
+    this.updateTimeline = updateTimeline;
   }
 
-  static fromTimestamp(timestamp: Timestamp): TracePositionUpdate {
+  static fromTimestamp(timestamp: Timestamp, updateTimeline = false): TracePositionUpdate {
     const position = TracePosition.fromTimestamp(timestamp);
-    return new TracePositionUpdate(position);
+    return new TracePositionUpdate(position, updateTimeline);
   }
 
-  static fromTraceEntry(entry: TraceEntry<object>): TracePositionUpdate {
+  static fromTraceEntry(entry: TraceEntry<object>, updateTimeline = false): TracePositionUpdate {
     const position = TracePosition.fromTraceEntry(entry);
-    return new TracePositionUpdate(position);
+    return new TracePositionUpdate(position, updateTimeline);
   }
 }
 
diff --git a/tools/winscope/src/trace/transition.ts b/tools/winscope/src/trace/transition.ts
index f35f552..1e2ebec 100644
--- a/tools/winscope/src/trace/transition.ts
+++ b/tools/winscope/src/trace/transition.ts
@@ -19,8 +19,8 @@
 export interface Transition {
   id: number;
   type: string;
-  sendTime?: string;
-  finishTime?: string;
+  sendTime?: PropertyTreeNode;
+  dispatchTime?: PropertyTreeNode;
   duration?: string;
   merged: boolean;
   aborted: boolean;
diff --git a/tools/winscope/src/viewers/components/ime_additional_properties_component.ts b/tools/winscope/src/viewers/components/ime_additional_properties_component.ts
index 0fc78e0..1eafa7e 100644
--- a/tools/winscope/src/viewers/components/ime_additional_properties_component.ts
+++ b/tools/winscope/src/viewers/components/ime_additional_properties_component.ts
@@ -22,6 +22,7 @@
 import {ImeAdditionalProperties} from 'viewers/common/ime_additional_properties';
 import {ImeContainerProperties, InputMethodSurfaceProperties} from 'viewers/common/ime_utils';
 import {ViewerEvents} from 'viewers/common/viewer_events';
+import {selectedElementStyle} from './styles/selected_element.styles';
 
 @Component({
   selector: 'ime-additional-properties',
@@ -320,10 +321,10 @@
       }
 
       .selected {
-        background-color: #87acec;
         color: black;
       }
     `,
+    selectedElementStyle,
   ],
 })
 export class ImeAdditionalPropertiesComponent {
diff --git a/tools/winscope/src/viewers/components/styles/current_element.styles.ts b/tools/winscope/src/viewers/components/styles/current_element.styles.ts
new file mode 100644
index 0000000..ac96b93
--- /dev/null
+++ b/tools/winscope/src/viewers/components/styles/current_element.styles.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+export const currentElementStyle = `
+    .current {
+        color: white;
+        background-color: #365179;
+    }
+`;
diff --git a/tools/winscope/src/viewers/components/styles/node.styles.ts b/tools/winscope/src/viewers/components/styles/node.styles.ts
index 0777e9c..c9bd563 100644
--- a/tools/winscope/src/viewers/components/styles/node.styles.ts
+++ b/tools/winscope/src/viewers/components/styles/node.styles.ts
@@ -13,7 +13,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-export const nodeStyles = `
+
+import {selectedElementStyle} from './selected_element.styles';
+
+export const nodeStyles =
+  `
     .node {
         position: relative;
         display: inline-flex;
@@ -52,11 +56,7 @@
         padding: 3px;
         color: white;
     }
-
-    .selected {
-        background-color: #87ACEC;
-    }
-`;
+` + selectedElementStyle;
 
 // FIXME: child-hover selector is not working.
 export const treeNodeDataViewStyles = `
diff --git a/tools/winscope/src/viewers/components/styles/selected_element.styles.ts b/tools/winscope/src/viewers/components/styles/selected_element.styles.ts
new file mode 100644
index 0000000..9b2d70e
--- /dev/null
+++ b/tools/winscope/src/viewers/components/styles/selected_element.styles.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+export const selectedElementStyle = `
+    .selected {
+        background-color: #87ACEC;
+    }
+`;
diff --git a/tools/winscope/src/viewers/components/styles/timestamp_button.styles.ts b/tools/winscope/src/viewers/components/styles/timestamp_button.styles.ts
new file mode 100644
index 0000000..d3694c2
--- /dev/null
+++ b/tools/winscope/src/viewers/components/styles/timestamp_button.styles.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+export const timeButtonStyle = `
+    .time button {
+        padding: 0px;
+        line-height: normal;
+        text-align: left;
+        white-space: normal;
+    }
+`;
diff --git a/tools/winscope/src/viewers/viewer_protolog/events.ts b/tools/winscope/src/viewers/viewer_protolog/events.ts
index 4cbe3a7..8f3f8b4 100644
--- a/tools/winscope/src/viewers/viewer_protolog/events.ts
+++ b/tools/winscope/src/viewers/viewer_protolog/events.ts
@@ -18,6 +18,7 @@
   static TagsFilterChanged = 'ViewerProtoLogEvent_TagsFilterChanged';
   static SourceFilesFilterChanged = 'ViewerProtoLogEvent_SourceFilesFilterChanged';
   static SearchStringFilterChanged = 'ViewerProtoLogEvent_SearchStringFilterChanged';
+  static TimestampSelected = 'ViewerProtoLogEvent_TimestampSelected';
 }
 
 export {Events};
diff --git a/tools/winscope/src/viewers/viewer_protolog/presenter.ts b/tools/winscope/src/viewers/viewer_protolog/presenter.ts
index 8f3f6b7..f6ee918 100644
--- a/tools/winscope/src/viewers/viewer_protolog/presenter.ts
+++ b/tools/winscope/src/viewers/viewer_protolog/presenter.ts
@@ -123,7 +123,7 @@
       return {
         originalIndex: index,
         text: assertDefined(messageNode.getChildByName('text')).formattedValue(),
-        time: assertDefined(messageNode.getChildByName('timestamp')).formattedValue(),
+        time: assertDefined(messageNode.getChildByName('timestamp')),
         tag: assertDefined(messageNode.getChildByName('tag')).formattedValue(),
         level: assertDefined(messageNode.getChildByName('level')).formattedValue(),
         at: assertDefined(messageNode.getChildByName('at')).formattedValue(),
diff --git a/tools/winscope/src/viewers/viewer_protolog/presenter_test.ts b/tools/winscope/src/viewers/viewer_protolog/presenter_test.ts
index 4f583e8..b422a42 100644
--- a/tools/winscope/src/viewers/viewer_protolog/presenter_test.ts
+++ b/tools/winscope/src/viewers/viewer_protolog/presenter_test.ts
@@ -45,33 +45,6 @@
     const elapsedTime20 = NO_TIMEZONE_OFFSET_FACTORY.makeElapsedTimestamp(20n);
     const elapsedTime30 = NO_TIMEZONE_OFFSET_FACTORY.makeElapsedTimestamp(30n);
 
-    inputMessages = [
-      {
-        originalIndex: 0,
-        text: 'text0',
-        time: '10ns',
-        tag: 'tag0',
-        level: 'level0',
-        at: 'sourcefile0',
-      },
-      {
-        originalIndex: 1,
-        text: 'text1',
-        time: '20ns',
-        tag: 'tag1',
-        level: 'level1',
-        at: 'sourcefile1',
-      },
-      {
-        originalIndex: 2,
-        text: 'text2',
-        time: '30ns',
-        tag: 'tag2',
-        level: 'level2',
-        at: 'sourcefile2',
-      },
-    ];
-
     const entries = [
       new PropertyTreeBuilder()
         .setRootId('ProtologTrace')
@@ -110,6 +83,33 @@
         .build(),
     ];
 
+    inputMessages = [
+      {
+        originalIndex: 0,
+        text: 'text0',
+        time: assertDefined(entries[0].getChildByName('timestamp')),
+        tag: 'tag0',
+        level: 'level0',
+        at: 'sourcefile0',
+      },
+      {
+        originalIndex: 1,
+        text: 'text1',
+        time: assertDefined(entries[1].getChildByName('timestamp')),
+        tag: 'tag1',
+        level: 'level1',
+        at: 'sourcefile1',
+      },
+      {
+        originalIndex: 2,
+        text: 'text2',
+        time: assertDefined(entries[2].getChildByName('timestamp')),
+        tag: 'tag2',
+        level: 'level2',
+        at: 'sourcefile2',
+      },
+    ];
+
     trace = new TraceBuilder<PropertyTreeNode>()
       .setEntries(entries)
       .setTimestamps([time10, time11, time12])
diff --git a/tools/winscope/src/viewers/viewer_protolog/scroll_strategy/protolog_scroll_strategy.ts b/tools/winscope/src/viewers/viewer_protolog/scroll_strategy/protolog_scroll_strategy.ts
index fa91c3f..d4afe7d 100644
--- a/tools/winscope/src/viewers/viewer_protolog/scroll_strategy/protolog_scroll_strategy.ts
+++ b/tools/winscope/src/viewers/viewer_protolog/scroll_strategy/protolog_scroll_strategy.ts
@@ -25,7 +25,10 @@
 
   protected override predictScrollItemHeight(message: UiDataMessage): number {
     const textHeight = this.subItemHeight(message.text, this.textCharsPerRow);
-    const timestampHeight = this.subItemHeight(message.time, this.timestampCharsPerRow);
+    const timestampHeight = this.subItemHeight(
+      message.time.formattedValue(),
+      this.timestampCharsPerRow
+    );
     const sourceFileHeight = this.subItemHeight(message.at, this.sourceFileCharsPerRow);
     return Math.max(textHeight, timestampHeight, sourceFileHeight);
   }
diff --git a/tools/winscope/src/viewers/viewer_protolog/ui_data.ts b/tools/winscope/src/viewers/viewer_protolog/ui_data.ts
index 0977b62..2032fc2 100644
--- a/tools/winscope/src/viewers/viewer_protolog/ui_data.ts
+++ b/tools/winscope/src/viewers/viewer_protolog/ui_data.ts
@@ -14,10 +14,12 @@
  * limitations under the License.
  */
 
+import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
+
 export interface UiDataMessage {
   readonly originalIndex: number;
   readonly text: string;
-  readonly time: string;
+  readonly time: PropertyTreeNode;
   readonly tag: string;
   readonly level: string;
   readonly at: string;
diff --git a/tools/winscope/src/viewers/viewer_protolog/viewer_protolog.ts b/tools/winscope/src/viewers/viewer_protolog/viewer_protolog.ts
index 882129e..925a8bd 100644
--- a/tools/winscope/src/viewers/viewer_protolog/viewer_protolog.ts
+++ b/tools/winscope/src/viewers/viewer_protolog/viewer_protolog.ts
@@ -14,9 +14,13 @@
  * limitations under the License.
  */
 
-import {WinscopeEvent} from 'messaging/winscope_event';
+import {FunctionUtils} from 'common/function_utils';
+import {Timestamp} from 'common/time';
+import {TracePositionUpdate, WinscopeEvent} from 'messaging/winscope_event';
+import {EmitEvent} from 'messaging/winscope_event_emitter';
 import {Traces} from 'trace/traces';
 import {TraceType} from 'trace/trace_type';
+import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
 import {View, Viewer, ViewType} from 'viewers/viewer';
 import {Events} from './events';
 import {Presenter} from './presenter';
@@ -28,6 +32,7 @@
   private readonly htmlElement: HTMLElement;
   private readonly presenter: Presenter;
   private readonly view: View;
+  private emitAppEvent: EmitEvent = FunctionUtils.DO_NOTHING_ASYNC;
 
   constructor(traces: Traces) {
     this.htmlElement = document.createElement('viewer-protolog');
@@ -48,6 +53,9 @@
     this.htmlElement.addEventListener(Events.SearchStringFilterChanged, (event) => {
       this.presenter.onSearchStringFilterChanged((event as CustomEvent).detail);
     });
+    this.htmlElement.addEventListener(Events.TimestampSelected, (event) => {
+      this.propagateTimestamp((event as CustomEvent).detail);
+    });
 
     this.view = new View(
       ViewType.TAB,
@@ -62,8 +70,13 @@
     await this.presenter.onAppEvent(event);
   }
 
-  setEmitEvent() {
-    // do nothing
+  setEmitEvent(callback: EmitEvent) {
+    this.emitAppEvent = callback;
+  }
+
+  async propagateTimestamp(timestampNode: PropertyTreeNode) {
+    const timestamp: Timestamp = timestampNode.getValue();
+    await this.emitAppEvent(TracePositionUpdate.fromTimestamp(timestamp, true));
   }
 
   getViews(): View[] {
diff --git a/tools/winscope/src/viewers/viewer_protolog/viewer_protolog_component.ts b/tools/winscope/src/viewers/viewer_protolog/viewer_protolog_component.ts
index 6c76d4d..3acbca8 100644
--- a/tools/winscope/src/viewers/viewer_protolog/viewer_protolog_component.ts
+++ b/tools/winscope/src/viewers/viewer_protolog/viewer_protolog_component.ts
@@ -16,6 +16,9 @@
 import {CdkVirtualScrollViewport} from '@angular/cdk/scrolling';
 import {Component, ElementRef, Inject, Input, ViewChild} from '@angular/core';
 import {MatSelectChange} from '@angular/material/select';
+import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
+import {currentElementStyle} from 'viewers/components/styles/current_element.styles';
+import {timeButtonStyle} from 'viewers/components/styles/timestamp_button.styles';
 import {Events} from './events';
 import {UiData} from './ui_data';
 
@@ -76,9 +79,14 @@
           *cdkVirtualFor="let message of uiData.messages; let i = index"
           class="message"
           [attr.item-id]="i"
-          [class.current-message]="isCurrentMessage(i)">
+          [class.current]="isCurrentMessage(i)">
           <div class="time">
-            <span class="mat-body-1">{{ message.time }}</span>
+            <button
+              mat-button
+              [color]="isCurrentMessage(i) ? 'secondary' : 'primary'"
+              (click)="onTimestampClicked(message.time)">
+              {{ message.time.formattedValue() }}
+            </button>
           </div>
           <div class="log-level">
             <span class="mat-body-1">{{ message.level }}</span>
@@ -122,11 +130,6 @@
         overflow-wrap: anywhere;
       }
 
-      .message.current-message {
-        background-color: #365179;
-        color: white;
-      }
-
       .time {
         flex: 2;
       }
@@ -175,12 +178,15 @@
         font-size: 12px;
       }
     `,
+    currentElementStyle,
+    timeButtonStyle,
   ],
 })
 export class ViewerProtologComponent {
   uiData: UiData = UiData.EMPTY;
 
   private searchString = '';
+  private lastClicked = '';
 
   @ViewChild(CdkVirtualScrollViewport) scrollComponent?: CdkVirtualScrollViewport;
 
@@ -189,7 +195,12 @@
   @Input()
   set inputData(data: UiData) {
     this.uiData = data;
-    if (this.uiData.currentMessageIndex !== undefined && this.scrollComponent) {
+    if (
+      this.uiData.currentMessageIndex !== undefined &&
+      this.scrollComponent &&
+      this.lastClicked !==
+        this.uiData.messages[this.uiData.currentMessageIndex].time.formattedValue()
+    ) {
       this.scrollComponent.scrollToIndex(this.uiData.currentMessageIndex);
     }
   }
@@ -216,6 +227,11 @@
     }
   }
 
+  onTimestampClicked(timestamp: PropertyTreeNode) {
+    this.lastClicked = timestamp.formattedValue();
+    this.emitEvent(Events.TimestampSelected, timestamp);
+  }
+
   isCurrentMessage(index: number): boolean {
     return index === this.uiData.currentMessageIndex;
   }
diff --git a/tools/winscope/src/viewers/viewer_protolog/viewer_protolog_component_test.ts b/tools/winscope/src/viewers/viewer_protolog/viewer_protolog_component_test.ts
index 297b86b..1f8ae22 100644
--- a/tools/winscope/src/viewers/viewer_protolog/viewer_protolog_component_test.ts
+++ b/tools/winscope/src/viewers/viewer_protolog/viewer_protolog_component_test.ts
@@ -22,6 +22,9 @@
 import {MatSelectModule} from '@angular/material/select';
 import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
 import {assertDefined} from 'common/assert_utils';
+import {NO_TIMEZONE_OFFSET_FACTORY} from 'common/timestamp_factory';
+import {PropertyTreeBuilder} from 'test/unit/property_tree_builder';
+import {TIMESTAMP_FORMATTER} from 'trace/tree_node/formatters';
 import {executeScrollComponentTests} from 'viewers/common/scroll_component_test_utils';
 import {Events} from './events';
 import {ProtologScrollDirective} from './scroll_strategy/protolog_scroll_directive';
@@ -96,6 +99,21 @@
       goToCurrentTimeButton.click();
       expect(spy).toHaveBeenCalledWith(150);
     });
+
+    it('propagates timestamp on click', () => {
+      component.inputData = makeUiData();
+      fixture.detectChanges();
+      let timestamp = '';
+      htmlElement.addEventListener(Events.TimestampSelected, (event) => {
+        timestamp = (event as CustomEvent).detail.formattedValue();
+      });
+      const logTimestampButton = assertDefined(
+        htmlElement.querySelector('.time button')
+      ) as HTMLButtonElement;
+      logTimestampButton.click();
+
+      expect(timestamp).toEqual('10ns');
+    });
   });
 
   describe('Scroll component', () => {
@@ -123,6 +141,13 @@
     const allTags = ['WindowManager', 'INVALID'];
     const allSourceFiles = ['test_source_file.java', 'other_test_source_file.java'];
 
+    const time = new PropertyTreeBuilder()
+      .setRootId('ProtologMessage')
+      .setName('timestamp')
+      .setValue(NO_TIMEZONE_OFFSET_FACTORY.makeElapsedTimestamp(10n))
+      .setFormatter(TIMESTAMP_FORMATTER)
+      .build();
+
     const messages = [];
     const shortMessage = 'test information about message';
     const longMessage = shortMessage.repeat(10) + 'keep';
@@ -130,7 +155,7 @@
       const uiDataMessage: UiDataMessage = {
         originalIndex: i,
         text: i % 2 === 0 ? shortMessage : longMessage,
-        time: '2022-11-21T18:05:09.777144978',
+        time,
         tag: i % 2 === 0 ? allTags[0] : allTags[1],
         level: i % 2 === 0 ? allLogLevels[0] : allLogLevels[1],
         at: i % 2 === 0 ? allSourceFiles[0] : allSourceFiles[1],
diff --git a/tools/winscope/src/viewers/viewer_transactions/events.ts b/tools/winscope/src/viewers/viewer_transactions/events.ts
index 7af5bab..8a1302d 100644
--- a/tools/winscope/src/viewers/viewer_transactions/events.ts
+++ b/tools/winscope/src/viewers/viewer_transactions/events.ts
@@ -23,6 +23,7 @@
   static WhatFilterChanged = 'ViewerTransactionsEvent_WhatFilterChanged';
   static EntryClicked = 'ViewerTransactionsEvent_EntryClicked';
   static TransactionIdFilterChanged = 'ViewerTransactionsEvent_TransactionIdFilterChanged';
+  static TimestampSelected = 'ViewerTransactionsEvent_TimestampSelected';
 }
 
 export {Events};
diff --git a/tools/winscope/src/viewers/viewer_transactions/presenter.ts b/tools/winscope/src/viewers/viewer_transactions/presenter.ts
index d8b39c8..1bb09d8 100644
--- a/tools/winscope/src/viewers/viewer_transactions/presenter.ts
+++ b/tools/winscope/src/viewers/viewer_transactions/presenter.ts
@@ -17,13 +17,14 @@
 import {ArrayUtils} from 'common/array_utils';
 import {assertDefined} from 'common/assert_utils';
 import {PersistentStoreProxy} from 'common/persistent_store_proxy';
-import {TimeUtils} from 'common/time_utils';
 import {WinscopeEvent, WinscopeEventType} from 'messaging/winscope_event';
 import {Trace, TraceEntry} from 'trace/trace';
 import {Traces} from 'trace/traces';
 import {TraceEntryFinder} from 'trace/trace_entry_finder';
 import {TraceType} from 'trace/trace_type';
+import {TIMESTAMP_FORMATTER} from 'trace/tree_node/formatters';
 import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
+import {DEFAULT_PROPERTY_TREE_NODE_FACTORY} from 'trace/tree_node/property_tree_node_factory';
 import {Filter} from 'viewers/common/operations/filter';
 import {UiPropertyTreeNode} from 'viewers/common/ui_property_tree_node';
 import {UiTreeFormatter} from 'viewers/common/ui_tree_formatter';
@@ -348,7 +349,13 @@
       const entry = this.trace.getEntry(originalIndex);
       const entryNode = entryProtos[originalIndex];
       const vsyncId = Number(assertDefined(entryNode.getChildByName('vsyncId')).getValue());
-      const entryTimestamp = TimeUtils.format(entry.getTimestamp());
+
+      const entryTimestamp = DEFAULT_PROPERTY_TREE_NODE_FACTORY.makeCalculatedProperty(
+        'TransactionsTraceEntry',
+        'timestamp',
+        entry.getTimestamp()
+      );
+      entryTimestamp.setFormatter(TIMESTAMP_FORMATTER);
 
       for (const transactionState of assertDefined(
         entryNode.getChildByName('transactions')
diff --git a/tools/winscope/src/viewers/viewer_transactions/presenter_test.ts b/tools/winscope/src/viewers/viewer_transactions/presenter_test.ts
index ac06b3a..253ccae 100644
--- a/tools/winscope/src/viewers/viewer_transactions/presenter_test.ts
+++ b/tools/winscope/src/viewers/viewer_transactions/presenter_test.ts
@@ -320,12 +320,14 @@
 
   it('formats real time', async () => {
     await setUpTestEnvironment(TimestampType.REAL);
-    expect(assertDefined(outputUiData).entries[0].time).toEqual('2022-08-03T06:19:01.051480997');
+    expect(assertDefined(outputUiData).entries[0].time.formattedValue()).toEqual(
+      '2022-08-03T06:19:01.051480997'
+    );
   });
 
   it('formats elapsed time', async () => {
     await setUpTestEnvironment(TimestampType.ELAPSED);
-    expect(assertDefined(outputUiData).entries[0].time).toEqual('2s450ms981445ns');
+    expect(assertDefined(outputUiData).entries[0].time.formattedValue()).toEqual('2s450ms981445ns');
   });
 
   const setUpTestEnvironment = async (timestampType: TimestampType) => {
diff --git a/tools/winscope/src/viewers/viewer_transactions/scroll_strategy/transactions_scroll_strategy.ts b/tools/winscope/src/viewers/viewer_transactions/scroll_strategy/transactions_scroll_strategy.ts
index 20b1447..2535661 100644
--- a/tools/winscope/src/viewers/viewer_transactions/scroll_strategy/transactions_scroll_strategy.ts
+++ b/tools/winscope/src/viewers/viewer_transactions/scroll_strategy/transactions_scroll_strategy.ts
@@ -24,7 +24,10 @@
 
   protected override predictScrollItemHeight(entry: UiDataEntry): number {
     const whatHeight = this.subItemHeight(entry.what, this.whatCharsPerRow);
-    const timestampHeight = this.subItemHeight(entry.time, this.timestampCharsPerRow);
+    const timestampHeight = this.subItemHeight(
+      entry.time.formattedValue(),
+      this.timestampCharsPerRow
+    );
     return Math.max(whatHeight, timestampHeight);
   }
 }
diff --git a/tools/winscope/src/viewers/viewer_transactions/ui_data.ts b/tools/winscope/src/viewers/viewer_transactions/ui_data.ts
index 9af0c9f..c494224 100644
--- a/tools/winscope/src/viewers/viewer_transactions/ui_data.ts
+++ b/tools/winscope/src/viewers/viewer_transactions/ui_data.ts
@@ -55,7 +55,7 @@
 class UiDataEntry {
   constructor(
     public originalIndexInTraceEntry: number,
-    public time: string,
+    public time: PropertyTreeNode,
     public vsyncId: number,
     public pid: string,
     public uid: string,
diff --git a/tools/winscope/src/viewers/viewer_transactions/viewer_transactions.ts b/tools/winscope/src/viewers/viewer_transactions/viewer_transactions.ts
index 2ff5415..cc52c8f 100644
--- a/tools/winscope/src/viewers/viewer_transactions/viewer_transactions.ts
+++ b/tools/winscope/src/viewers/viewer_transactions/viewer_transactions.ts
@@ -14,9 +14,13 @@
  * limitations under the License.
  */
 
-import {WinscopeEvent} from 'messaging/winscope_event';
+import {FunctionUtils} from 'common/function_utils';
+import {Timestamp} from 'common/time';
+import {TracePositionUpdate, WinscopeEvent} from 'messaging/winscope_event';
+import {EmitEvent} from 'messaging/winscope_event_emitter';
 import {Traces} from 'trace/traces';
 import {TraceType} from 'trace/trace_type';
+import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
 import {ViewerEvents} from 'viewers/common/viewer_events';
 import {View, Viewer, ViewType} from 'viewers/viewer';
 import {Events} from './events';
@@ -29,6 +33,7 @@
   private readonly htmlElement: HTMLElement;
   private readonly presenter: Presenter;
   private readonly view: View;
+  private emitAppEvent: EmitEvent = FunctionUtils.DO_NOTHING_ASYNC;
 
   constructor(traces: Traces, storage: Storage) {
     this.htmlElement = document.createElement('viewer-transactions');
@@ -68,13 +73,12 @@
     this.htmlElement.addEventListener(Events.EntryClicked, (event) => {
       this.presenter.onEntryClicked((event as CustomEvent).detail);
     });
+    this.htmlElement.addEventListener(Events.TimestampSelected, (event) => {
+      this.propagateTimestamp((event as CustomEvent).detail);
+    });
 
-    this.htmlElement.addEventListener(
-      ViewerEvents.PropertiesUserOptionsChange,
-      async (event) =>
-        await this.presenter.onPropertiesUserOptionsChange(
-          (event as CustomEvent).detail.userOptions
-        )
+    this.htmlElement.addEventListener(ViewerEvents.PropertiesUserOptionsChange, (event) =>
+      this.presenter.onPropertiesUserOptionsChange((event as CustomEvent).detail.userOptions)
     );
 
     this.view = new View(
@@ -90,8 +94,13 @@
     await this.presenter.onAppEvent(event);
   }
 
-  setEmitEvent() {
-    // do nothing
+  setEmitEvent(callback: EmitEvent) {
+    this.emitAppEvent = callback;
+  }
+
+  async propagateTimestamp(timestampNode: PropertyTreeNode) {
+    const timestamp: Timestamp = timestampNode.getValue();
+    await this.emitAppEvent(TracePositionUpdate.fromTimestamp(timestamp, true));
   }
 
   getViews(): View[] {
diff --git a/tools/winscope/src/viewers/viewer_transactions/viewer_transactions_component.ts b/tools/winscope/src/viewers/viewer_transactions/viewer_transactions_component.ts
index eeda175..fb72c90 100644
--- a/tools/winscope/src/viewers/viewer_transactions/viewer_transactions_component.ts
+++ b/tools/winscope/src/viewers/viewer_transactions/viewer_transactions_component.ts
@@ -16,7 +16,11 @@
 import {CdkVirtualScrollViewport} from '@angular/cdk/scrolling';
 import {Component, ElementRef, Inject, Input, ViewChild} from '@angular/core';
 import {MatSelectChange} from '@angular/material/select';
+import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
 import {ViewerEvents} from 'viewers/common/viewer_events';
+import {currentElementStyle} from 'viewers/components/styles/current_element.styles';
+import {selectedElementStyle} from 'viewers/components/styles/selected_element.styles';
+import {timeButtonStyle} from 'viewers/components/styles/timestamp_button.styles';
 import {Events} from './events';
 import {UiData} from './ui_data';
 
@@ -104,11 +108,16 @@
             *cdkVirtualFor="let entry of uiData.entries; let i = index"
             class="entry"
             [attr.item-id]="i"
-            [class.current-entry]="isCurrentEntry(i)"
-            [class.selected-entry]="isSelectedEntry(i)"
+            [class.current]="isCurrentEntry(i)"
+            [class.selected]="isSelectedEntry(i)"
             (click)="onEntryClicked(i)">
             <div class="time">
-              <span class="mat-body-1">{{ entry.time }}</span>
+              <button
+                mat-button
+                [color]="isCurrentEntry(i) ? 'secondary' : 'primary'"
+                (click)="onTimestampClicked(entry.time)">
+                {{ entry.time.formattedValue() }}
+              </button>
             </div>
             <div class="id">
               <span class="mat-body-1">{{ entry.transactionId }}</span>
@@ -227,16 +236,6 @@
         margin-right: 16px;
       }
 
-      .entry.current-entry {
-        color: white;
-        background-color: #365179;
-      }
-
-      .entry.selected-entry {
-        color: white;
-        background-color: #98aecd;
-      }
-
       .go-to-current-time {
         flex: none;
         margin-top: 4px;
@@ -251,11 +250,15 @@
         max-height: 75vh;
       }
     `,
+    selectedElementStyle,
+    currentElementStyle,
+    timeButtonStyle,
   ],
 })
 class ViewerTransactionsComponent {
   objectKeys = Object.keys;
   uiData: UiData = UiData.EMPTY;
+  private lastClicked = '';
 
   @ViewChild(CdkVirtualScrollViewport) scrollComponent?: CdkVirtualScrollViewport;
 
@@ -264,7 +267,11 @@
   @Input()
   set inputData(data: UiData) {
     this.uiData = data;
-    if (this.uiData.scrollToIndex !== undefined && this.scrollComponent) {
+    if (
+      this.uiData.scrollToIndex !== undefined &&
+      this.scrollComponent &&
+      this.lastClicked !== this.uiData.entries[this.uiData.scrollToIndex].time.formattedValue()
+    ) {
       this.scrollComponent.scrollToIndex(this.uiData.scrollToIndex);
     }
   }
@@ -315,6 +322,11 @@
     }
   }
 
+  onTimestampClicked(timestamp: PropertyTreeNode) {
+    this.lastClicked = timestamp.formattedValue();
+    this.emitEvent(Events.TimestampSelected, timestamp);
+  }
+
   isCurrentEntry(index: number): boolean {
     return index === this.uiData.currentEntryIndex;
   }
diff --git a/tools/winscope/src/viewers/viewer_transactions/viewer_transactions_component_test.ts b/tools/winscope/src/viewers/viewer_transactions/viewer_transactions_component_test.ts
index f8e3cd3..258d294 100644
--- a/tools/winscope/src/viewers/viewer_transactions/viewer_transactions_component_test.ts
+++ b/tools/winscope/src/viewers/viewer_transactions/viewer_transactions_component_test.ts
@@ -18,9 +18,12 @@
 import {ComponentFixture, ComponentFixtureAutoDetect, TestBed} from '@angular/core/testing';
 import {MatDividerModule} from '@angular/material/divider';
 import {assertDefined} from 'common/assert_utils';
+import {NO_TIMEZONE_OFFSET_FACTORY} from 'common/timestamp_factory';
 import {PropertyTreeBuilder} from 'test/unit/property_tree_builder';
+import {TIMESTAMP_FORMATTER} from 'trace/tree_node/formatters';
 import {executeScrollComponentTests} from 'viewers/common/scroll_component_test_utils';
 import {UiPropertyTreeNode} from 'viewers/common/ui_property_tree_node';
+import {Events} from './events';
 import {TransactionsScrollDirective} from './scroll_strategy/transactions_scroll_directive';
 import {UiData, UiDataEntry} from './ui_data';
 import {ViewerTransactionsComponent} from './viewer_transactions_component';
@@ -62,7 +65,7 @@
       expect(htmlElement.querySelector('.scroll')).toBeTruthy();
 
       const entry = assertDefined(htmlElement.querySelector('.scroll .entry'));
-      expect(entry.innerHTML).toContain('TIME_VALUE');
+      expect(entry.innerHTML).toContain('1ns');
       expect(entry.innerHTML).toContain('-111');
       expect(entry.innerHTML).toContain('PID_VALUE');
       expect(entry.innerHTML).toContain('UID_VALUE');
@@ -84,6 +87,21 @@
       expect(spy).toHaveBeenCalledWith(1);
     });
 
+    it('propagates timestamp on click', () => {
+      component.inputData = makeUiData();
+      fixture.detectChanges();
+      let timestamp = '';
+      htmlElement.addEventListener(Events.TimestampSelected, (event) => {
+        timestamp = (event as CustomEvent).detail.formattedValue();
+      });
+      const logTimestampButton = assertDefined(
+        htmlElement.querySelector('.time button')
+      ) as HTMLButtonElement;
+      logTimestampButton.click();
+
+      expect(timestamp).toEqual('1ns');
+    });
+
     function makeUiData(): UiData {
       const propertiesTree = new PropertyTreeBuilder()
         .setRootId('Transactions')
@@ -91,9 +109,16 @@
         .setValue(null)
         .build();
 
+      const time = new PropertyTreeBuilder()
+        .setRootId(propertiesTree.id)
+        .setName('timestamp')
+        .setValue(NO_TIMEZONE_OFFSET_FACTORY.makeElapsedTimestamp(1n))
+        .setFormatter(TIMESTAMP_FORMATTER)
+        .build();
+
       const entry = new UiDataEntry(
         0,
-        'TIME_VALUE',
+        time,
         -111,
         'PID_VALUE',
         'UID_VALUE',
@@ -106,7 +131,7 @@
 
       const entry2 = new UiDataEntry(
         1,
-        'TIME_VALUE',
+        time,
         -222,
         'PID_VALUE_2',
         'UID_VALUE_2',
@@ -144,6 +169,14 @@
         .setName('tree')
         .setValue(null)
         .build();
+
+      const time = new PropertyTreeBuilder()
+        .setRootId(propertiesTree.id)
+        .setName('timestamp')
+        .setValue(NO_TIMEZONE_OFFSET_FACTORY.makeElapsedTimestamp(1n))
+        .setFormatter(TIMESTAMP_FORMATTER)
+        .build();
+
       const uiData = new UiData(
         [],
         [],
@@ -164,7 +197,7 @@
       for (let i = 0; i < 200; i++) {
         const entry = new UiDataEntry(
           0,
-          'TIME_VALUE',
+          time,
           -111,
           'PID_VALUE',
           'UID_VALUE',
diff --git a/tools/winscope/src/viewers/viewer_transitions/events.ts b/tools/winscope/src/viewers/viewer_transitions/events.ts
index 95f9665..aa19964 100644
--- a/tools/winscope/src/viewers/viewer_transitions/events.ts
+++ b/tools/winscope/src/viewers/viewer_transitions/events.ts
@@ -16,6 +16,7 @@
 
 class Events {
   static TransitionSelected = 'ViewerTransitionsEvent_TransitionSelected';
+  static TimestampSelected = 'ViewerTransitionsEvent_TimestampSelected';
 }
 
 export {Events};
diff --git a/tools/winscope/src/viewers/viewer_transitions/presenter.ts b/tools/winscope/src/viewers/viewer_transitions/presenter.ts
index b8a165b..b9a08ca 100644
--- a/tools/winscope/src/viewers/viewer_transitions/presenter.ts
+++ b/tools/winscope/src/viewers/viewer_transitions/presenter.ts
@@ -112,12 +112,13 @@
   private makeTransitions(entries: PropertyTreeNode[]): Transition[] {
     return entries.map((transitionNode) => {
       const wmDataNode = assertDefined(transitionNode.getChildByName('wmData'));
+      const shellDataNode = assertDefined(transitionNode.getChildByName('shellData'));
 
       const transition: Transition = {
         id: assertDefined(transitionNode.getChildByName('id')).getValue(),
         type: wmDataNode.getChildByName('type')?.formattedValue() ?? 'NONE',
-        sendTime: wmDataNode.getChildByName('sendTimeNs')?.formattedValue(),
-        finishTime: wmDataNode.getChildByName('finishTimeNs')?.formattedValue(),
+        sendTime: wmDataNode.getChildByName('sendTimeNs'),
+        dispatchTime: shellDataNode.getChildByName('dispatchTimeNs'),
         duration: transitionNode.getChildByName('duration')?.formattedValue(),
         merged: assertDefined(transitionNode.getChildByName('merged')).getValue(),
         aborted: assertDefined(transitionNode.getChildByName('aborted')).getValue(),
diff --git a/tools/winscope/src/viewers/viewer_transitions/viewer_transitions.ts b/tools/winscope/src/viewers/viewer_transitions/viewer_transitions.ts
index 495e825..f5e53a1 100644
--- a/tools/winscope/src/viewers/viewer_transitions/viewer_transitions.ts
+++ b/tools/winscope/src/viewers/viewer_transitions/viewer_transitions.ts
@@ -13,9 +13,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {WinscopeEvent} from 'messaging/winscope_event';
+import {FunctionUtils} from 'common/function_utils';
+import {Timestamp} from 'common/time';
+import {TracePositionUpdate, WinscopeEvent} from 'messaging/winscope_event';
+import {EmitEvent} from 'messaging/winscope_event_emitter';
 import {Traces} from 'trace/traces';
 import {TraceType} from 'trace/trace_type';
+import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
 import {View, Viewer, ViewType} from 'viewers/viewer';
 import {Events} from './events';
 import {Presenter} from './presenter';
@@ -27,6 +31,7 @@
   private readonly htmlElement: HTMLElement;
   private readonly presenter: Presenter;
   private readonly view: View;
+  private emitAppEvent: EmitEvent = FunctionUtils.DO_NOTHING_ASYNC;
 
   constructor(traces: Traces) {
     this.htmlElement = document.createElement('viewer-transitions');
@@ -39,6 +44,10 @@
       this.presenter.onTransitionSelected((event as CustomEvent).detail);
     });
 
+    this.htmlElement.addEventListener(Events.TimestampSelected, (event) => {
+      this.propagateTimestamp((event as CustomEvent).detail);
+    });
+
     this.view = new View(
       ViewType.TAB,
       this.getDependencies(),
@@ -52,8 +61,13 @@
     await this.presenter.onAppEvent(event);
   }
 
-  setEmitEvent() {
-    // do nothing
+  setEmitEvent(callback: EmitEvent) {
+    this.emitAppEvent = callback;
+  }
+
+  async propagateTimestamp(timestampNode: PropertyTreeNode) {
+    const timestamp: Timestamp = timestampNode.getValue();
+    await this.emitAppEvent(TracePositionUpdate.fromTimestamp(timestamp, true));
   }
 
   getViews(): View[] {
diff --git a/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component.ts b/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component.ts
index 8e8a066..ca78276 100644
--- a/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component.ts
+++ b/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component.ts
@@ -17,6 +17,8 @@
 import {Component, ElementRef, Inject, Input} from '@angular/core';
 import {Transition} from 'trace/transition';
 import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
+import {selectedElementStyle} from 'viewers/components/styles/selected_element.styles';
+import {timeButtonStyle} from 'viewers/components/styles/timestamp_button.styles';
 import {Events} from './events';
 import {UiData} from './ui_data';
 
@@ -29,6 +31,7 @@
           <div class="id mat-body-2">Id</div>
           <div class="type mat-body-2">Type</div>
           <div class="send-time mat-body-2">Send Time</div>
+          <div class="dispatch-time mat-body-2">Dispatch Time</div>
           <div class="duration mat-body-2">Duration</div>
           <div class="status mat-body-2">Status</div>
         </div>
@@ -36,7 +39,7 @@
           <div
             *cdkVirtualFor="let transition of uiData.entries; let i = index"
             class="entry table-row"
-            [class.current]="isCurrentTransition(transition)"
+            [class.selected]="isSelectedTransition(transition)"
             (click)="onTransitionClicked(transition)">
             <div class="id">
               <span class="mat-body-1">{{ transition.id }}</span>
@@ -44,10 +47,26 @@
             <div class="type">
               <span class="mat-body-1">{{ transition.type }}</span>
             </div>
-            <div class="send-time">
-              <span *ngIf="transition.sendTime" class="mat-body-1">{{ transition.sendTime }}</span>
+            <div class="send-time time">
+              <button
+                mat-button
+                color="primary"
+                *ngIf="transition.sendTime"
+                (click)="onTimestampClicked(transition.sendTime)">
+                {{ transition.sendTime.formattedValue() }}
+              </button>
               <span *ngIf="!transition.sendTime" class="mat-body-1"> n/a </span>
             </div>
+            <div class="dispatch-time time">
+              <button
+                mat-button
+                color="primary"
+                *ngIf="transition.dispatchTime"
+                (click)="onTimestampClicked(transition.dispatchTime)">
+                {{ transition.dispatchTime.formattedValue() }}
+              </button>
+              <span *ngIf="!transition.dispatchTime" class="mat-body-1"> n/a </span>
+            </div>
             <div class="duration">
               <span *ngIf="transition.duration" class="mat-body-1">{{ transition.duration }}</span>
               <span *ngIf="!transition.duration" class="mat-body-1"> n/a </span>
@@ -133,11 +152,6 @@
         border-bottom: solid 1px rgba(0, 0, 0, 0.5);
       }
 
-      .scroll .entry.current {
-        color: white;
-        background-color: #365179;
-      }
-
       .table-row > div {
         padding: 16px;
       }
@@ -150,6 +164,10 @@
         flex: 2;
       }
 
+      .table-row .dispatch-time {
+        flex: 4;
+      }
+
       .table-row .send-time {
         flex: 4;
       }
@@ -169,7 +187,7 @@
         gap: 5px;
       }
 
-      .current .status mat-icon {
+      .selected .status mat-icon {
         color: white !important;
       }
 
@@ -186,13 +204,9 @@
         flex-grow: 1;
         padding: 0.5rem;
       }
-
-      .selected-transition {
-        padding: 1rem;
-        border-bottom: solid 1px rgba(0, 0, 0, 0.12);
-        flex-grow: 1;
-      }
     `,
+    selectedElementStyle,
+    timeButtonStyle,
   ],
 })
 export class ViewerTransitionsComponent {
@@ -207,7 +221,7 @@
     this.emitEvent(Events.TransitionSelected, transition.propertiesTree);
   }
 
-  isCurrentTransition(transition: Transition): boolean {
+  isSelectedTransition(transition: Transition): boolean {
     return (
       transition.id ===
         this.uiData.selectedTransition
@@ -222,6 +236,10 @@
     );
   }
 
+  onTimestampClicked(timestamp: PropertyTreeNode) {
+    this.emitEvent(Events.TimestampSelected, timestamp);
+  }
+
   emitEvent(event: string, propertiesTree: PropertyTreeNode) {
     const customEvent = new CustomEvent(event, {
       bubbles: true,
diff --git a/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component_test.ts b/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component_test.ts
index 835ba06..f8cc62f 100644
--- a/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component_test.ts
+++ b/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component_test.ts
@@ -20,6 +20,7 @@
 import {MatDividerModule} from '@angular/material/divider';
 import {assertDefined} from 'common/assert_utils';
 import {TimestampType} from 'common/time';
+import {NO_TIMEZONE_OFFSET_FACTORY} from 'common/timestamp_factory';
 import {TracePositionUpdate} from 'messaging/winscope_event';
 import {PropertyTreeBuilder} from 'test/unit/property_tree_builder';
 import {UnitTestUtils} from 'test/unit/utils';
@@ -29,6 +30,7 @@
 import {TracePosition} from 'trace/trace_position';
 import {TraceType} from 'trace/trace_type';
 import {Transition} from 'trace/transition';
+import {TIMESTAMP_FORMATTER} from 'trace/tree_node/formatters';
 import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
 import {TreeComponent} from 'viewers/components/tree_component';
 import {TreeNodeComponent} from 'viewers/components/tree_node_component';
@@ -154,6 +156,19 @@
     const textContentWithoutWhitespaces = treeView.textContent?.replace(/(\s|\t|\n)*/g, '');
     expect(textContentWithoutWhitespaces).toContain(`id:${selectedTransitionId}`);
   });
+
+  it('propagates timestamp on click', () => {
+    let timestamp = '';
+    htmlElement.addEventListener(Events.TimestampSelected, (event) => {
+      timestamp = (event as CustomEvent).detail.formattedValue();
+    });
+    const logTimestampButton = assertDefined(
+      htmlElement.querySelector('.time button')
+    ) as HTMLButtonElement;
+    logTimestampButton.click();
+
+    expect(timestamp).toEqual('20ns');
+  });
 });
 
 function makeUiData(): UiData {
@@ -181,11 +196,18 @@
     .setChildren([{name: 'id', value: id}])
     .build();
 
+  const sendTimeNode = new PropertyTreeBuilder()
+    .setRootId(transitionTree.id)
+    .setName('sendTimeNs')
+    .setValue(NO_TIMEZONE_OFFSET_FACTORY.makeElapsedTimestamp(BigInt(sendTimeNanos)))
+    .setFormatter(TIMESTAMP_FORMATTER)
+    .build();
+
   return {
     id,
     type: 'TO_FRONT',
-    sendTime: sendTimeNanos.toString() + 'ns',
-    finishTime: finishTimeNanos.toString() + 'ns',
+    sendTime: sendTimeNode,
+    dispatchTime: undefined,
     duration: (finishTimeNanos - sendTimeNanos).toString() + 'ns',
     merged: false,
     aborted: false,
diff --git a/vndk/tools/header-checker/utils/create_reference_dumps.py b/vndk/tools/header-checker/utils/create_reference_dumps.py
index 40ceba7..901970e 100755
--- a/vndk/tools/header-checker/utils/create_reference_dumps.py
+++ b/vndk/tools/header-checker/utils/create_reference_dumps.py
@@ -13,7 +13,7 @@
 PRODUCTS_DEFAULT = ['aosp_arm', 'aosp_arm64', 'aosp_x86', 'aosp_x86_64']
 
 PREBUILTS_ABI_DUMPS_DIR = os.path.join(AOSP_DIR, 'prebuilts', 'abi-dumps')
-PREBUILTS_ABI_DUMPS_SUBDIRS = ('ndk', 'platform')
+PREBUILTS_ABI_DUMPS_SUBDIRS = ('ndk', 'platform', 'vndk')
 NON_AOSP_TAGS = {'VENDOR', 'PRODUCT'}
 
 SOONG_DIR = os.path.join(AOSP_DIR, 'out', 'soong', '.intermediates')
@@ -28,8 +28,9 @@
 
 
 class GetVersionedRefDumpDirStem:
-    def __init__(self, chosen_platform_version,
+    def __init__(self, board_api_level, chosen_platform_version,
                  binder_bitness):
+        self.board_api_level = board_api_level
         self.chosen_platform_version = chosen_platform_version
         self.binder_bitness = binder_bitness
 
@@ -37,8 +38,9 @@
         if subdir not in PREBUILTS_ABI_DUMPS_SUBDIRS:
             raise ValueError(f'"{subdir}" is not a valid dump directory under '
                              f'{PREBUILTS_ABI_DUMPS_DIR}.')
-        return os.path.join(PREBUILTS_ABI_DUMPS_DIR, subdir,
-                            self.chosen_platform_version,
+        version_stem = (self.board_api_level if subdir == 'vndk' else
+                        self.chosen_platform_version)
+        return os.path.join(PREBUILTS_ABI_DUMPS_DIR, subdir, version_stem,
                             self.binder_bitness, arch_str)
 
 
@@ -56,8 +58,10 @@
         return ''
     if tag == 'NDK':
         return 'ndk'
-    if tag in ('PLATFORM', 'LLNDK'):
+    if tag == 'PLATFORM':
         return 'platform'
+    if tag == 'LLNDK':
+        return 'vndk'
     raise ValueError(tag + ' is not a known tag.')
 
 
@@ -94,10 +98,10 @@
     for product in args.products:
         build_target = BuildTarget(product, args.release, args.build_variant)
         (
-            platform_vndk_version, binder_32_bit,
-            platform_version_codename, platform_sdk_version
-        ) = build_vars = get_build_vars(
-            ['PLATFORM_VNDK_VERSION', 'BINDER32BIT',
+            platform_vndk_version, release_board_api_level, binder_32_bit,
+            platform_version_codename, platform_sdk_version,
+        ) = get_build_vars(
+            ['PLATFORM_VNDK_VERSION', 'RELEASE_BOARD_API_LEVEL', 'BINDER32BIT',
              'PLATFORM_VERSION_CODENAME', 'PLATFORM_SDK_VERSION'],
             build_target
         )
@@ -121,7 +125,7 @@
             exclude_tags = ()
         else:
             get_ref_dump_dir_stem = GetVersionedRefDumpDirStem(
-                chosen_platform_version,
+                release_board_api_level, chosen_platform_version,
                 binder_bitness)
             exclude_tags = NON_AOSP_TAGS