ui: Improve router args parsing

1. Parse repeated search params in URL as arrays:
E.g.
?foo=bar&foo=baz -> {foo: ['bar', 'baz']}

2. Parse params with no value as boolean true
E.g.
?foo&bar -> {foo: true, bar: true}
diff --git a/ui/src/base/route_parser.ts b/ui/src/base/route_parser.ts
new file mode 100644
index 0000000..4fb78db
--- /dev/null
+++ b/ui/src/base/route_parser.ts
@@ -0,0 +1,80 @@
+// Copyright (C) 2025 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 type ParsedUrlParams = {
+  [key: string]: string | boolean | (string | boolean)[];
+};
+
+/**
+ * Parses URL search parameters with special handling for repeated arguments
+ * and valueless arguments.
+ * - Repeated search arguments are treated as an array of strings.
+ * - Arguments without a value (e.g. "?foo") are treated as a boolean `true`.
+ *
+ * Example:
+ * `?foo=bar&baz&baz=qux&a=1&a=2` will be parsed as:
+ * `{foo: 'bar', baz: ['', 'qux'], a: ['1', '2']}`
+ * A valueless parameter is only treated as true if it is not repeated.
+ * e.g. `?foo` is parsed as `{foo: true}`.
+ */
+export function parseUrlSearchParams(params: URLSearchParams): ParsedUrlParams {
+  const result: ParsedUrlParams = {};
+  const keys = new Set<string>();
+  for (const key of params.keys()) {
+    keys.add(key);
+  }
+
+  const convert = (s: string): string | boolean => {
+    if (s === '') return true;
+    if (s === 'true') return true;
+    if (s === 'false') return false;
+    return s;
+  };
+
+  for (const key of keys) {
+    const values = params.getAll(key);
+    if (values.length > 1) {
+      result[key] = values.map(convert);
+    } else {
+      result[key] = convert(values[0]);
+    }
+  }
+  return result;
+}
+
+export function buildUrlSearchParams(params: ParsedUrlParams): string {
+  const parts: string[] = [];
+  for (const key of Object.keys(params).sort()) {
+    const value = params[key];
+    if (value === true) {
+      parts.push(encodeURIComponent(key));
+    } else if (value === false) {
+      parts.push(`${encodeURIComponent(key)}=false`);
+    } else if (typeof value === 'string') {
+      parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
+    } else if (Array.isArray(value)) {
+      for (const item of value) {
+        const encodedKey = encodeURIComponent(key);
+        if (item === true || item === '') {
+          parts.push(encodedKey);
+        } else if (item === false) {
+          parts.push(`${encodedKey}=false`);
+        } else {
+          parts.push(`${encodedKey}=${encodeURIComponent(item as string)}`);
+        }
+      }
+    }
+  }
+  return parts.join('&');
+}
diff --git a/ui/src/base/route_parser_unittest.ts b/ui/src/base/route_parser_unittest.ts
new file mode 100644
index 0000000..5a3b40b
--- /dev/null
+++ b/ui/src/base/route_parser_unittest.ts
@@ -0,0 +1,155 @@
+// Copyright (C) 2025 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.
+
+import {buildUrlSearchParams, parseUrlSearchParams} from './route_parser';
+
+describe('parseUrlSearchParams', () => {
+  it('handles empty params', () => {
+    const params = new URLSearchParams('');
+    expect(parseUrlSearchParams(params)).toEqual({});
+  });
+
+  it('handles single value params', () => {
+    const params = new URLSearchParams('foo=bar&baz=qux');
+    expect(parseUrlSearchParams(params)).toEqual({
+      foo: 'bar',
+      baz: 'qux',
+    });
+  });
+
+  it('handles repeated params', () => {
+    const params = new URLSearchParams('foo=bar&foo=baz');
+    expect(parseUrlSearchParams(params)).toEqual({
+      foo: ['bar', 'baz'],
+    });
+  });
+
+  it('handles valueless params as true', () => {
+    const params = new URLSearchParams('foo&bar=baz');
+    expect(parseUrlSearchParams(params)).toEqual({
+      foo: true,
+      bar: 'baz',
+    });
+  });
+
+  it('handles true string as boolean', () => {
+    const params = new URLSearchParams('foo=true&bar=baz');
+    expect(parseUrlSearchParams(params)).toEqual({
+      foo: true,
+      bar: 'baz',
+    });
+  });
+
+  it('handles false string as boolean', () => {
+    const params = new URLSearchParams('foo=false&bar=baz');
+    expect(parseUrlSearchParams(params)).toEqual({
+      foo: false,
+      bar: 'baz',
+    });
+  });
+
+  it('handles mixed params', () => {
+    const params = new URLSearchParams('a=1&b&c=2&c=3&d=true&e=false');
+    expect(parseUrlSearchParams(params)).toEqual({
+      a: '1',
+      b: true,
+      c: ['2', '3'],
+      d: true,
+      e: false,
+    });
+  });
+
+  it('handles valueless repeated params', () => {
+    const params = new URLSearchParams('foo&foo');
+    expect(parseUrlSearchParams(params)).toEqual({
+      foo: [true, true],
+    });
+  });
+
+  it('handles mixed valueless and valued repeated params', () => {
+    const params = new URLSearchParams('foo&foo=bar');
+    expect(parseUrlSearchParams(params)).toEqual({
+      foo: [true, 'bar'],
+    });
+  });
+
+  it('handles mixed booleans in repeated params', () => {
+    const params = new URLSearchParams('foo=true&foo=bar&foo=false');
+    expect(parseUrlSearchParams(params)).toEqual({
+      foo: [true, 'bar', false],
+    });
+  });
+});
+
+describe('buildUrlSearchParams', () => {
+  it('handles empty params', () => {
+    expect(buildUrlSearchParams({})).toEqual('');
+  });
+
+  it('handles single value params', () => {
+    expect(buildUrlSearchParams({foo: 'bar', baz: 'qux'})).toEqual(
+      'baz=qux&foo=bar',
+    );
+  });
+
+  it('handles repeated params', () => {
+    expect(buildUrlSearchParams({foo: ['bar', 'baz']})).toEqual(
+      'foo=bar&foo=baz',
+    );
+  });
+
+  it('handles valueless params as true', () => {
+    expect(buildUrlSearchParams({foo: true, bar: 'baz'})).toEqual(
+      'bar=baz&foo',
+    );
+  });
+
+  it('handles boolean false', () => {
+    expect(buildUrlSearchParams({foo: false, bar: 'baz'})).toEqual(
+      'bar=baz&foo=false',
+    );
+  });
+
+  it('handles mixed params', () => {
+    expect(
+      buildUrlSearchParams({
+        a: '1',
+        b: true,
+        c: ['2', '3'],
+        d: true,
+        e: false,
+      }),
+    ).toEqual('a=1&b&c=2&c=3&d&e=false');
+  });
+
+  it('handles valueless repeated params', () => {
+    expect(buildUrlSearchParams({foo: [true, true]})).toEqual('foo&foo');
+  });
+
+  it('handles mixed valueless and valued repeated params', () => {
+    expect(buildUrlSearchParams({foo: [true, 'bar']})).toEqual('foo&foo=bar');
+  });
+
+  it('handles mixed booleans in repeated params', () => {
+    expect(buildUrlSearchParams({foo: [true, 'bar', false]})).toEqual(
+      'foo&foo=bar&foo=false',
+    );
+  });
+
+  it('handles special characters', () => {
+    expect(buildUrlSearchParams({a: ' ', b: '&', c: ['=', '?']})).toEqual(
+      'a=%20&b=%26&c=%3D&c=%3F',
+    );
+  });
+});
diff --git a/ui/src/core/app_impl.ts b/ui/src/core/app_impl.ts
index 4fed138..28043e3 100644
--- a/ui/src/core/app_impl.ts
+++ b/ui/src/core/app_impl.ts
@@ -209,7 +209,7 @@
 
 export class AppImpl implements App {
   readonly pluginId: string;
-  readonly initialPluginRouteArgs: RouteArgs;
+  readonly initialPluginRouteArgs: {[key: string]: RouteArg | undefined};
   private readonly appCtx: AppContext;
   private readonly pageMgrProxy: PageManagerImpl;
 
diff --git a/ui/src/core/router.ts b/ui/src/core/router.ts
index 9bd6451..9e763a3 100644
--- a/ui/src/core/router.ts
+++ b/ui/src/core/router.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import m from 'mithril';
+import {buildUrlSearchParams, parseUrlSearchParams} from '../base/route_parser';
 import {assertTrue} from '../base/logging';
 import {RouteArgs, ROUTE_SCHEMA} from '../public/route_schema';
 
@@ -107,7 +107,7 @@
       newRoute.args.local_cache_key = oldRoute.args.local_cache_key;
     }
 
-    const args = m.buildQueryString(newRoute.args);
+    const args = buildUrlSearchParams(newRoute.args);
     let normalizedFragment = `#!${newRoute.page}${newRoute.subpage}`;
     if (args.length) {
       normalizedFragment += `?${args}`;
@@ -162,7 +162,7 @@
 
     let rawArgs = {};
     if (url.search) {
-      rawArgs = Router.parseQueryString(url.search);
+      rawArgs = parseUrlSearchParams(url.searchParams);
     }
 
     const args = safeParseRoute(rawArgs);
@@ -186,14 +186,9 @@
     return {page, subpage, args, fragment};
   }
 
-  private static parseQueryString(query: string) {
-    query = query.replaceAll('+', ' ');
-    return m.parseQueryString(query);
-  }
-
   private static parseSearchParams(url: string): RouteArgs {
-    const query = new URL(url).search;
-    const rawArgs = Router.parseQueryString(query);
+    const searchParams = new URL(url).searchParams;
+    const rawArgs = parseUrlSearchParams(searchParams);
     const args = safeParseRoute(rawArgs);
     return args;
   }
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index 57f4da3..984e0f6 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -415,6 +415,9 @@
   NON_CORE_PLUGINS.forEach((p) => pluginManager.registerPlugin(p));
   const route = Router.parseUrl(window.location.href);
   const overrides = (route.args.enablePlugins ?? '').split(',');
+
+  console.log(route.args);
+
   pluginManager.activatePlugins(overrides);
 }
 
diff --git a/ui/src/plugins/dev.perfetto.AutoPinAndExpandTracks/index.ts b/ui/src/plugins/dev.perfetto.AutoPinAndExpandTracks/index.ts
index f9dbd17..2fe4e52 100644
--- a/ui/src/plugins/dev.perfetto.AutoPinAndExpandTracks/index.ts
+++ b/ui/src/plugins/dev.perfetto.AutoPinAndExpandTracks/index.ts
@@ -34,10 +34,15 @@
 function getParamValues(param: RouteArg | undefined): string[] {
   if (typeof param === 'boolean') return [];
   if (param === undefined) return [];
-
-  const trimmed = param.trim();
-  if (trimmed === '') return [];
-  return [trimmed];
+  if (typeof param === 'string') {
+    const trimmed = param.trim();
+    if (trimmed === '') return [];
+    return [trimmed];
+  }
+  return param
+    .filter((p) => typeof p === 'string')
+    .map((p) => p.trim())
+    .filter((p) => p !== '');
 }
 
 /**
diff --git a/ui/src/public/route_schema.ts b/ui/src/public/route_schema.ts
index fac02dc..177d187 100644
--- a/ui/src/public/route_schema.ts
+++ b/ui/src/public/route_schema.ts
@@ -16,7 +16,8 @@
 
 // Args are parsed by the Router into strings or booleans only.
 // E.g. ?hideSidebar=true&url=a?b?c -> {hideSidebar: true, url: 'a?b?c'}
-const argSchema = z.union([z.string(), z.boolean()]);
+const stringOrBool = z.union([z.string(), z.boolean()]);
+const argSchema = z.union([stringOrBool, z.array(stringOrBool)]);
 export type RouteArg = z.infer<typeof argSchema>;
 
 // We use .catch(undefined) on every field below to make sure that passing an