Merge changes from topic "CachedPreferencesProto"

* changes:
  CachedPreferences: change the cached format to CachedPreferencesProto
  CachedPreferences: add support for proto based preferences
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 9fec1fa..b17e5fc 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -61,7 +61,7 @@
       @Override
       protected void configure() {
         persist(BYID_AND_REV_NAME, CachedAccountDetails.Key.class, CachedAccountDetails.class)
-            .version(1)
+            .version(2)
             .keySerializer(CachedAccountDetails.Key.Serializer.INSTANCE)
             .valueSerializer(CachedAccountDetails.Serializer.INSTANCE)
             .loader(Loader.class);
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index d591809..2b0ba3f 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -196,7 +196,7 @@
    */
   public CachedPreferences asCachedPreferences() {
     checkLoaded();
-    return CachedPreferences.fromConfig(preferences.getRaw());
+    return CachedPreferences.fromLegacyConfig(preferences.getRaw());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/account/CachedAccountDetails.java b/java/com/google/gerrit/server/account/CachedAccountDetails.java
index 2ab6174..a8e409d 100644
--- a/java/com/google/gerrit/server/account/CachedAccountDetails.java
+++ b/java/com/google/gerrit/server/account/CachedAccountDetails.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.config.CachedPreferences;
 import java.time.Instant;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Details of an account that are cached persistently in {@link AccountCache}. */
@@ -131,7 +132,11 @@
         serialized.addProjectWatchProto(proto);
       }
 
-      serialized.setUserPreferences(cachedAccountDetails.preferences().config());
+      Optional<Cache.CachedPreferencesProto> cachedPreferencesProto =
+          cachedAccountDetails.preferences().nonEmptyConfig();
+      if (cachedPreferencesProto.isPresent()) {
+        serialized.setUserPreferences(cachedPreferencesProto.get());
+      }
       return Protos.toByteArray(serialized.build());
     }
 
@@ -166,7 +171,7 @@
       return CachedAccountDetails.create(
           account,
           projectWatches.build(),
-          CachedPreferences.fromString(proto.getUserPreferences()));
+          CachedPreferences.fromCachedPreferencesProto(proto.getUserPreferences()));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java b/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java
index f0343b5..1da396e 100644
--- a/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java
@@ -214,7 +214,7 @@
     try (Timer0.Context ignored = readSingleLatency.start()) {
       cfg = new AccountConfig(accountId, allUsersName, allUsersRepository).load();
       defaultPreferences =
-          CachedPreferences.fromConfig(
+          CachedPreferences.fromLegacyConfig(
               VersionedDefaultPreferences.get(allUsersRepository, allUsersName));
     }
 
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
index cf57634..a12002f 100644
--- a/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
@@ -301,7 +301,7 @@
                   updateExternalIdNotes(
                       repo, accountConfig.getExternalIdsRev(), accountId, accountDelta);
                   CachedPreferences defaultPreferences =
-                      CachedPreferences.fromConfig(
+                      CachedPreferences.fromLegacyConfig(
                           VersionedDefaultPreferences.get(repo, allUsersName));
 
                   return new UpdatedAccount(message, accountConfig, defaultPreferences, true);
@@ -323,7 +323,7 @@
     return repo -> {
       AccountConfig accountConfig = read(repo, updateArguments.accountId);
       CachedPreferences defaultPreferences =
-          CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
+          CachedPreferences.fromLegacyConfig(VersionedDefaultPreferences.get(repo, allUsersName));
       Optional<AccountState> accountState =
           AccountsNoteDbImpl.getFromAccountConfig(externalIds, accountConfig, defaultPreferences);
       if (!accountState.isPresent()) {
@@ -343,7 +343,7 @@
 
       accountConfig.setAccountDelta(delta);
       CachedPreferences cachedDefaultPreferences =
-          CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
+          CachedPreferences.fromLegacyConfig(VersionedDefaultPreferences.get(repo, allUsersName));
       return new UpdatedAccount(
           updateArguments.message, accountConfig, cachedDefaultPreferences, false);
     };
diff --git a/java/com/google/gerrit/server/config/CachedPreferences.java b/java/com/google/gerrit/server/config/CachedPreferences.java
index 388f58a..169d9ec 100644
--- a/java/com/google/gerrit/server/config/CachedPreferences.java
+++ b/java/com/google/gerrit/server/config/CachedPreferences.java
@@ -15,82 +15,154 @@
 package com.google.gerrit.server.config;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Function;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.proto.Entities.UserPreferences;
+import com.google.gerrit.server.cache.proto.Cache.CachedPreferencesProto;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 
 /**
  * Container class for preferences serialized as Git-style config files. Keeps the values as {@link
- * String}s as they are immutable and thread-safe.
+ * CachedPreferencesProto}s as they are immutable and thread-safe.
+ *
+ * <p>The config string wrapped by this class might represent different structures. See {@link
+ * CachedPreferencesProto} for more details.
  */
 @AutoValue
 public abstract class CachedPreferences {
+  public static final CachedPreferences EMPTY =
+      fromCachedPreferencesProto(CachedPreferencesProto.getDefaultInstance());
 
-  public static CachedPreferences EMPTY = fromString("");
+  protected abstract CachedPreferencesProto config();
 
-  public abstract String config();
-
-  /** Returns a cache-able representation of the config. */
-  public static CachedPreferences fromConfig(Config cfg) {
-    return new AutoValue_CachedPreferences(cfg.toText());
+  public Optional<CachedPreferencesProto> nonEmptyConfig() {
+    return config().equals(EMPTY.config()) ? Optional.empty() : Optional.of(config());
+  }
+  /** Returns a cache-able representation of the preferences proto. */
+  public static CachedPreferences fromUserPreferencesProto(UserPreferences proto) {
+    return fromCachedPreferencesProto(
+        CachedPreferencesProto.newBuilder().setUserPreferences(proto).build());
   }
 
-  /**
-   * Returns a cache-able representation of the config. To be used only when constructing a {@link
-   * CachedPreferences} from a serialized, cached value.
-   */
-  public static CachedPreferences fromString(String cfg) {
-    return new AutoValue_CachedPreferences(cfg);
+  /** Returns a cache-able representation of the git config. */
+  public static CachedPreferences fromLegacyConfig(Config cfg) {
+    return fromCachedPreferencesProto(
+        CachedPreferencesProto.newBuilder().setLegacyGitConfig(cfg.toText()).build());
+  }
+
+  /** Returns a cache-able representation of the preferences proto. */
+  public static CachedPreferences fromCachedPreferencesProto(
+      @Nullable CachedPreferencesProto proto) {
+    if (proto != null) {
+      return new AutoValue_CachedPreferences(proto);
+    }
+    return EMPTY;
   }
 
   public static GeneralPreferencesInfo general(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
-    try {
-      return PreferencesParserUtil.parseGeneralPreferences(
-          userPreferences.asConfig(), configOrNull(defaultPreferences), null);
-    } catch (ConfigInvalidException e) {
-      return GeneralPreferencesInfo.defaults();
-    }
-  }
-
-  public static EditPreferencesInfo edit(
-      Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
-    try {
-      return PreferencesParserUtil.parseEditPreferences(
-          userPreferences.asConfig(), configOrNull(defaultPreferences), null);
-    } catch (ConfigInvalidException e) {
-      return EditPreferencesInfo.defaults();
-    }
+    return getPreferences(
+        defaultPreferences,
+        userPreferences,
+        PreferencesParserUtil::parseGeneralPreferences,
+        p ->
+            UserPreferencesConverter.GeneralPreferencesInfoConverter.fromProto(
+                p.getGeneralPreferencesInfo()),
+        GeneralPreferencesInfo.defaults());
   }
 
   public static DiffPreferencesInfo diff(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
-    try {
-      return PreferencesParserUtil.parseDiffPreferences(
-          userPreferences.asConfig(), configOrNull(defaultPreferences), null);
-    } catch (ConfigInvalidException e) {
-      return DiffPreferencesInfo.defaults();
-    }
+    return getPreferences(
+        defaultPreferences,
+        userPreferences,
+        PreferencesParserUtil::parseDiffPreferences,
+        p ->
+            UserPreferencesConverter.DiffPreferencesInfoConverter.fromProto(
+                p.getDiffPreferencesInfo()),
+        DiffPreferencesInfo.defaults());
+  }
+
+  public static EditPreferencesInfo edit(
+      Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
+    return getPreferences(
+        defaultPreferences,
+        userPreferences,
+        PreferencesParserUtil::parseEditPreferences,
+        p ->
+            UserPreferencesConverter.EditPreferencesInfoConverter.fromProto(
+                p.getEditPreferencesInfo()),
+        EditPreferencesInfo.defaults());
   }
 
   public Config asConfig() {
-    Config cfg = new Config();
     try {
-      cfg.fromText(config());
+      switch (config().getPreferencesCase()) {
+        case LEGACY_GIT_CONFIG:
+          // continue below
+        case PREFERENCES_NOT_SET:
+          Config cfg = new Config();
+          cfg.fromText(config().getLegacyGitConfig());
+          return cfg;
+        case USER_PREFERENCES:
+          break;
+      }
     } catch (ConfigInvalidException e) {
-      // Programmer error: We have parsed this config before and are unable to parse it now.
       throw new StorageException(e);
     }
-    return cfg;
+    throw new StorageException(
+        String.format(
+            "Cannot parse the given config as a CachedPreferencesProto proto. Got [%s]", config()));
+  }
+
+  public UserPreferences asUserPreferencesProto() {
+    if (config().hasUserPreferences()) {
+      return config().getUserPreferences();
+    }
+    throw new StorageException(
+        String.format(
+            "Cannot parse the given config as a UserPreferences proto. Got [%s]", config()));
   }
 
   @Nullable
   private static Config configOrNull(Optional<CachedPreferences> cachedPreferences) {
     return cachedPreferences.map(CachedPreferences::asConfig).orElse(null);
   }
+
+  @FunctionalInterface
+  private interface ComputePreferencesFn<PreferencesT> {
+    PreferencesT apply(Config cfg, @Nullable Config defaultCfg, @Nullable PreferencesT input)
+        throws ConfigInvalidException;
+  }
+
+  private static <PreferencesT> PreferencesT getPreferences(
+      Optional<CachedPreferences> defaultPreferences,
+      CachedPreferences userPreferences,
+      ComputePreferencesFn<PreferencesT> computePreferencesFn,
+      Function<UserPreferences, PreferencesT> fromUserPreferencesFn,
+      PreferencesT javaDefaults) {
+    try {
+      CachedPreferencesProto userPreferencesProto = userPreferences.config();
+      switch (userPreferencesProto.getPreferencesCase()) {
+        case USER_PREFERENCES:
+          PreferencesT pref =
+              fromUserPreferencesFn.apply(userPreferencesProto.getUserPreferences());
+          return computePreferencesFn.apply(new Config(), configOrNull(defaultPreferences), pref);
+        case LEGACY_GIT_CONFIG:
+          return computePreferencesFn.apply(
+              userPreferences.asConfig(), configOrNull(defaultPreferences), null);
+        case PREFERENCES_NOT_SET:
+          throw new ConfigInvalidException("Invalid config " + userPreferences);
+      }
+    } catch (ConfigInvalidException e) {
+      return javaDefaults;
+    }
+    return javaDefaults;
+  }
 }
diff --git a/java/com/google/gerrit/server/config/DefaultPreferencesCache.java b/java/com/google/gerrit/server/config/DefaultPreferencesCache.java
index 39adb48..28b9507 100644
--- a/java/com/google/gerrit/server/config/DefaultPreferencesCache.java
+++ b/java/com/google/gerrit/server/config/DefaultPreferencesCache.java
@@ -22,7 +22,7 @@
    * Static member to be returned when there is no default config. This prevents re-instantiating
    * many {@link CachedPreferences} in this case.
    */
-  CachedPreferences EMPTY = CachedPreferences.fromString("");
+  CachedPreferences EMPTY = CachedPreferences.EMPTY;
 
   /** Returns a cached instance of {@link CachedPreferences}. */
   CachedPreferences get();
diff --git a/java/com/google/gerrit/server/config/DefaultPreferencesCacheImpl.java b/java/com/google/gerrit/server/config/DefaultPreferencesCacheImpl.java
index f8156a7..854a5c9 100644
--- a/java/com/google/gerrit/server/config/DefaultPreferencesCacheImpl.java
+++ b/java/com/google/gerrit/server/config/DefaultPreferencesCacheImpl.java
@@ -99,7 +99,7 @@
       try (Repository allUsersRepo = repositoryManager.openRepository(allUsersName)) {
         VersionedDefaultPreferences versionedDefaultPreferences = new VersionedDefaultPreferences();
         versionedDefaultPreferences.load(allUsersName, allUsersRepo, key);
-        return CachedPreferences.fromConfig(versionedDefaultPreferences.getConfig());
+        return CachedPreferences.fromLegacyConfig(versionedDefaultPreferences.getConfig());
       }
     }
   }
diff --git a/java/com/google/gerrit/server/config/UserPreferencesConverter.java b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
new file mode 100644
index 0000000..bb611ad
--- /dev/null
+++ b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
@@ -0,0 +1,343 @@
+// Copyright (C) 2023 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.
+
+package com.google.gerrit.server.config;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.EditPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.MenuItem;
+import com.google.gerrit.proto.Entities.UserPreferences;
+import com.google.protobuf.Message;
+import com.google.protobuf.ProtocolMessageEnum;
+import java.util.function.Function;
+
+/**
+ * Converters for user preferences data classes
+ *
+ * <p>Upstream, we use java representations of the preference classes. Internally, we store proto
+ * equivalents in Spanner.
+ */
+final class UserPreferencesConverter {
+  static final class GeneralPreferencesInfoConverter {
+    public static UserPreferences.GeneralPreferencesInfo toProto(GeneralPreferencesInfo info) {
+      UserPreferences.GeneralPreferencesInfo.Builder builder =
+          UserPreferences.GeneralPreferencesInfo.newBuilder();
+      builder = setIfNotNull(builder, builder::setChangesPerPage, info.changesPerPage);
+      builder = setIfNotNull(builder, builder::setDownloadScheme, info.downloadScheme);
+      builder =
+          setEnumIfNotNull(
+              builder,
+              builder::setTheme,
+              UserPreferences.GeneralPreferencesInfo.Theme::valueOf,
+              info.theme);
+      builder =
+          setEnumIfNotNull(
+              builder,
+              builder::setDateFormat,
+              UserPreferences.GeneralPreferencesInfo.DateFormat::valueOf,
+              info.dateFormat);
+      builder =
+          setEnumIfNotNull(
+              builder,
+              builder::setTimeFormat,
+              UserPreferences.GeneralPreferencesInfo.TimeFormat::valueOf,
+              info.timeFormat);
+      builder = setIfNotNull(builder, builder::setExpandInlineDiffs, info.expandInlineDiffs);
+      builder =
+          setIfNotNull(
+              builder, builder::setRelativeDateInChangeTable, info.relativeDateInChangeTable);
+      builder =
+          setEnumIfNotNull(
+              builder,
+              builder::setDiffView,
+              UserPreferences.GeneralPreferencesInfo.DiffView::valueOf,
+              info.diffView);
+      builder = setIfNotNull(builder, builder::setSizeBarInChangeTable, info.sizeBarInChangeTable);
+      builder =
+          setIfNotNull(builder, builder::setLegacycidInChangeTable, info.legacycidInChangeTable);
+      builder =
+          setIfNotNull(builder, builder::setMuteCommonPathPrefixes, info.muteCommonPathPrefixes);
+      builder = setIfNotNull(builder, builder::setSignedOffBy, info.signedOffBy);
+      builder =
+          setEnumIfNotNull(
+              builder,
+              builder::setEmailStrategy,
+              UserPreferences.GeneralPreferencesInfo.EmailStrategy::valueOf,
+              info.emailStrategy);
+      builder =
+          setEnumIfNotNull(
+              builder,
+              builder::setEmailFormat,
+              UserPreferences.GeneralPreferencesInfo.EmailFormat::valueOf,
+              info.emailFormat);
+      builder =
+          setEnumIfNotNull(
+              builder,
+              builder::setDefaultBaseForMerges,
+              UserPreferences.GeneralPreferencesInfo.DefaultBase::valueOf,
+              info.defaultBaseForMerges);
+      builder =
+          setIfNotNull(builder, builder::setPublishCommentsOnPush, info.publishCommentsOnPush);
+      builder =
+          setIfNotNull(
+              builder, builder::setDisableKeyboardShortcuts, info.disableKeyboardShortcuts);
+      builder =
+          setIfNotNull(
+              builder, builder::setDisableTokenHighlighting, info.disableTokenHighlighting);
+      builder =
+          setIfNotNull(builder, builder::setWorkInProgressByDefault, info.workInProgressByDefault);
+      if (info.my != null) {
+        builder =
+            builder.addAllMyMenuItems(
+                info.my.stream().map(i -> menuItemToProto(i)).collect(toImmutableList()));
+      }
+      if (info.changeTable != null) {
+        builder = builder.addAllChangeTable(info.changeTable);
+      }
+      builder =
+          setIfNotNull(
+              builder, builder::setAllowBrowserNotifications, info.allowBrowserNotifications);
+      return builder.build();
+    }
+
+    public static GeneralPreferencesInfo fromProto(UserPreferences.GeneralPreferencesInfo proto) {
+      GeneralPreferencesInfo res = new GeneralPreferencesInfo();
+      res.changesPerPage = proto.hasChangesPerPage() ? proto.getChangesPerPage() : null;
+      res.downloadScheme = proto.hasDownloadScheme() ? proto.getDownloadScheme() : null;
+      res.theme =
+          proto.hasTheme() ? GeneralPreferencesInfo.Theme.valueOf(proto.getTheme().name()) : null;
+      res.dateFormat =
+          proto.hasDateFormat()
+              ? GeneralPreferencesInfo.DateFormat.valueOf(proto.getDateFormat().name())
+              : null;
+      res.timeFormat =
+          proto.hasTimeFormat()
+              ? GeneralPreferencesInfo.TimeFormat.valueOf(proto.getTimeFormat().name())
+              : null;
+      res.expandInlineDiffs = proto.hasExpandInlineDiffs() ? proto.getExpandInlineDiffs() : null;
+      res.relativeDateInChangeTable =
+          proto.hasRelativeDateInChangeTable() ? proto.getRelativeDateInChangeTable() : null;
+      res.diffView =
+          proto.hasDiffView()
+              ? GeneralPreferencesInfo.DiffView.valueOf(proto.getDiffView().name())
+              : null;
+      res.sizeBarInChangeTable =
+          proto.hasSizeBarInChangeTable() ? proto.getSizeBarInChangeTable() : null;
+      res.legacycidInChangeTable =
+          proto.hasLegacycidInChangeTable() ? proto.getLegacycidInChangeTable() : null;
+      res.muteCommonPathPrefixes =
+          proto.hasMuteCommonPathPrefixes() ? proto.getMuteCommonPathPrefixes() : null;
+      res.signedOffBy = proto.hasSignedOffBy() ? proto.getSignedOffBy() : null;
+      res.emailStrategy =
+          proto.hasEmailStrategy()
+              ? GeneralPreferencesInfo.EmailStrategy.valueOf(proto.getEmailStrategy().name())
+              : null;
+      res.emailFormat =
+          proto.hasEmailFormat()
+              ? GeneralPreferencesInfo.EmailFormat.valueOf(proto.getEmailFormat().name())
+              : null;
+      res.defaultBaseForMerges =
+          proto.hasDefaultBaseForMerges()
+              ? GeneralPreferencesInfo.DefaultBase.valueOf(proto.getDefaultBaseForMerges().name())
+              : null;
+      res.publishCommentsOnPush =
+          proto.hasPublishCommentsOnPush() ? proto.getPublishCommentsOnPush() : null;
+      res.disableKeyboardShortcuts =
+          proto.hasDisableKeyboardShortcuts() ? proto.getDisableKeyboardShortcuts() : null;
+      res.disableTokenHighlighting =
+          proto.hasDisableTokenHighlighting() ? proto.getDisableTokenHighlighting() : null;
+      res.workInProgressByDefault =
+          proto.hasWorkInProgressByDefault() ? proto.getWorkInProgressByDefault() : null;
+      res.my =
+          proto.getMyMenuItemsCount() != 0
+              ? proto.getMyMenuItemsList().stream()
+                  .map(p -> menuItemFromProto(p))
+                  .collect(toImmutableList())
+              : null;
+      res.changeTable = proto.getChangeTableCount() != 0 ? proto.getChangeTableList() : null;
+      res.allowBrowserNotifications =
+          proto.hasAllowBrowserNotifications() ? proto.getAllowBrowserNotifications() : null;
+      return res;
+    }
+
+    private static UserPreferences.GeneralPreferencesInfo.MenuItem menuItemToProto(
+        MenuItem javaItem) {
+      UserPreferences.GeneralPreferencesInfo.MenuItem.Builder builder =
+          UserPreferences.GeneralPreferencesInfo.MenuItem.newBuilder();
+      builder = setIfNotNull(builder, builder::setName, javaItem.name);
+      builder = setIfNotNull(builder, builder::setUrl, javaItem.url);
+      builder = setIfNotNull(builder, builder::setTarget, javaItem.target);
+      builder = setIfNotNull(builder, builder::setId, javaItem.id);
+      return builder.build();
+    }
+
+    private static MenuItem menuItemFromProto(
+        UserPreferences.GeneralPreferencesInfo.MenuItem proto) {
+      return new MenuItem(
+          proto.hasName() ? proto.getName() : null,
+          proto.hasUrl() ? proto.getUrl() : null,
+          proto.hasTarget() ? proto.getTarget() : null,
+          proto.hasId() ? proto.getId() : null);
+    }
+
+    private GeneralPreferencesInfoConverter() {}
+  }
+
+  static final class DiffPreferencesInfoConverter {
+    public static UserPreferences.DiffPreferencesInfo toProto(DiffPreferencesInfo info) {
+      UserPreferences.DiffPreferencesInfo.Builder builder =
+          UserPreferences.DiffPreferencesInfo.newBuilder();
+      builder = setIfNotNull(builder, builder::setContext, info.context);
+      builder = setIfNotNull(builder, builder::setTabSize, info.tabSize);
+      builder = setIfNotNull(builder, builder::setFontSize, info.fontSize);
+      builder = setIfNotNull(builder, builder::setLineLength, info.lineLength);
+      builder = setIfNotNull(builder, builder::setCursorBlinkRate, info.cursorBlinkRate);
+      builder = setIfNotNull(builder, builder::setExpandAllComments, info.expandAllComments);
+      builder = setIfNotNull(builder, builder::setIntralineDifference, info.intralineDifference);
+      builder = setIfNotNull(builder, builder::setManualReview, info.manualReview);
+      builder = setIfNotNull(builder, builder::setShowLineEndings, info.showLineEndings);
+      builder = setIfNotNull(builder, builder::setShowTabs, info.showTabs);
+      builder = setIfNotNull(builder, builder::setShowWhitespaceErrors, info.showWhitespaceErrors);
+      builder = setIfNotNull(builder, builder::setSyntaxHighlighting, info.syntaxHighlighting);
+      builder = setIfNotNull(builder, builder::setHideTopMenu, info.hideTopMenu);
+      builder =
+          setIfNotNull(builder, builder::setAutoHideDiffTableHeader, info.autoHideDiffTableHeader);
+      builder = setIfNotNull(builder, builder::setHideLineNumbers, info.hideLineNumbers);
+      builder = setIfNotNull(builder, builder::setRenderEntireFile, info.renderEntireFile);
+      builder = setIfNotNull(builder, builder::setHideEmptyPane, info.hideEmptyPane);
+      builder = setIfNotNull(builder, builder::setMatchBrackets, info.matchBrackets);
+      builder = setIfNotNull(builder, builder::setLineWrapping, info.lineWrapping);
+      builder =
+          setEnumIfNotNull(
+              builder,
+              builder::setIgnoreWhitespace,
+              UserPreferences.DiffPreferencesInfo.Whitespace::valueOf,
+              info.ignoreWhitespace);
+      builder = setIfNotNull(builder, builder::setRetainHeader, info.retainHeader);
+      builder = setIfNotNull(builder, builder::setSkipDeleted, info.skipDeleted);
+      builder = setIfNotNull(builder, builder::setSkipUnchanged, info.skipUnchanged);
+      builder = setIfNotNull(builder, builder::setSkipUncommented, info.skipUncommented);
+      return builder.build();
+    }
+
+    public static DiffPreferencesInfo fromProto(UserPreferences.DiffPreferencesInfo proto) {
+      DiffPreferencesInfo res = new DiffPreferencesInfo();
+      res.context = proto.hasContext() ? proto.getContext() : null;
+      res.tabSize = proto.hasTabSize() ? proto.getTabSize() : null;
+      res.fontSize = proto.hasFontSize() ? proto.getFontSize() : null;
+      res.lineLength = proto.hasLineLength() ? proto.getLineLength() : null;
+      res.cursorBlinkRate = proto.hasCursorBlinkRate() ? proto.getCursorBlinkRate() : null;
+      res.expandAllComments = proto.hasExpandAllComments() ? proto.getExpandAllComments() : null;
+      res.intralineDifference =
+          proto.hasIntralineDifference() ? proto.getIntralineDifference() : null;
+      res.manualReview = proto.hasManualReview() ? proto.getManualReview() : null;
+      res.showLineEndings = proto.hasShowLineEndings() ? proto.getShowLineEndings() : null;
+      res.showTabs = proto.hasShowTabs() ? proto.getShowTabs() : null;
+      res.showWhitespaceErrors =
+          proto.hasShowWhitespaceErrors() ? proto.getShowWhitespaceErrors() : null;
+      res.syntaxHighlighting = proto.hasSyntaxHighlighting() ? proto.getSyntaxHighlighting() : null;
+      res.hideTopMenu = proto.hasHideTopMenu() ? proto.getHideTopMenu() : null;
+      res.autoHideDiffTableHeader =
+          proto.hasAutoHideDiffTableHeader() ? proto.getAutoHideDiffTableHeader() : null;
+      res.hideLineNumbers = proto.hasHideLineNumbers() ? proto.getHideLineNumbers() : null;
+      res.renderEntireFile = proto.hasRenderEntireFile() ? proto.getRenderEntireFile() : null;
+      res.hideEmptyPane = proto.hasHideEmptyPane() ? proto.getHideEmptyPane() : null;
+      res.matchBrackets = proto.hasMatchBrackets() ? proto.getMatchBrackets() : null;
+      res.lineWrapping = proto.hasLineWrapping() ? proto.getLineWrapping() : null;
+      res.ignoreWhitespace =
+          proto.hasIgnoreWhitespace()
+              ? DiffPreferencesInfo.Whitespace.valueOf(proto.getIgnoreWhitespace().name())
+              : null;
+      res.retainHeader = proto.hasRetainHeader() ? proto.getRetainHeader() : null;
+      res.skipDeleted = proto.hasSkipDeleted() ? proto.getSkipDeleted() : null;
+      res.skipUnchanged = proto.hasSkipUnchanged() ? proto.getSkipUnchanged() : null;
+      res.skipUncommented = proto.hasSkipUncommented() ? proto.getSkipUncommented() : null;
+      return res;
+    }
+
+    private DiffPreferencesInfoConverter() {}
+  }
+
+  static final class EditPreferencesInfoConverter {
+    public static UserPreferences.EditPreferencesInfo toProto(EditPreferencesInfo info) {
+      UserPreferences.EditPreferencesInfo.Builder builder =
+          UserPreferences.EditPreferencesInfo.newBuilder();
+      builder = setIfNotNull(builder, builder::setTabSize, info.tabSize);
+      builder = setIfNotNull(builder, builder::setLineLength, info.lineLength);
+      builder = setIfNotNull(builder, builder::setIndentUnit, info.indentUnit);
+      builder = setIfNotNull(builder, builder::setCursorBlinkRate, info.cursorBlinkRate);
+      builder = setIfNotNull(builder, builder::setHideTopMenu, info.hideTopMenu);
+      builder = setIfNotNull(builder, builder::setShowTabs, info.showTabs);
+      builder = setIfNotNull(builder, builder::setShowWhitespaceErrors, info.showWhitespaceErrors);
+      builder = setIfNotNull(builder, builder::setSyntaxHighlighting, info.syntaxHighlighting);
+      builder = setIfNotNull(builder, builder::setHideLineNumbers, info.hideLineNumbers);
+      builder = setIfNotNull(builder, builder::setMatchBrackets, info.matchBrackets);
+      builder = setIfNotNull(builder, builder::setLineWrapping, info.lineWrapping);
+      builder = setIfNotNull(builder, builder::setIndentWithTabs, info.indentWithTabs);
+      builder = setIfNotNull(builder, builder::setAutoCloseBrackets, info.autoCloseBrackets);
+      builder = setIfNotNull(builder, builder::setShowBase, info.showBase);
+      return builder.build();
+    }
+
+    public static EditPreferencesInfo fromProto(UserPreferences.EditPreferencesInfo proto) {
+      EditPreferencesInfo res = new EditPreferencesInfo();
+      res.tabSize = proto.hasTabSize() ? proto.getTabSize() : null;
+      res.lineLength = proto.hasLineLength() ? proto.getLineLength() : null;
+      res.indentUnit = proto.hasIndentUnit() ? proto.getIndentUnit() : null;
+      res.cursorBlinkRate = proto.hasCursorBlinkRate() ? proto.getCursorBlinkRate() : null;
+      res.hideTopMenu = proto.hasHideTopMenu() ? proto.getHideTopMenu() : null;
+      res.showTabs = proto.hasShowTabs() ? proto.getShowTabs() : null;
+      res.showWhitespaceErrors =
+          proto.hasShowWhitespaceErrors() ? proto.getShowWhitespaceErrors() : null;
+      res.syntaxHighlighting = proto.hasSyntaxHighlighting() ? proto.getSyntaxHighlighting() : null;
+      res.hideLineNumbers = proto.hasHideLineNumbers() ? proto.getHideLineNumbers() : null;
+      res.matchBrackets = proto.hasMatchBrackets() ? proto.getMatchBrackets() : null;
+      res.lineWrapping = proto.hasLineWrapping() ? proto.getLineWrapping() : null;
+      res.indentWithTabs = proto.hasIndentWithTabs() ? proto.getIndentWithTabs() : null;
+      res.autoCloseBrackets = proto.hasAutoCloseBrackets() ? proto.getAutoCloseBrackets() : null;
+      res.showBase = proto.hasShowBase() ? proto.getShowBase() : null;
+      return res;
+    }
+
+    private EditPreferencesInfoConverter() {}
+  }
+
+  private static <ValueT, BuilderT extends Message.Builder> BuilderT setIfNotNull(
+      BuilderT builder, Function<ValueT, BuilderT> protoFieldSetterFn, ValueT javaField) {
+    if (javaField != null) {
+      return protoFieldSetterFn.apply(javaField);
+    }
+    return builder;
+  }
+
+  private static <
+          JavaEnumT extends Enum<?>,
+          ProtoEnumT extends ProtocolMessageEnum,
+          BuilderT extends Message.Builder>
+      BuilderT setEnumIfNotNull(
+          BuilderT builder,
+          Function<ProtoEnumT, BuilderT> protoFieldSetterFn,
+          Function<String, ProtoEnumT> protoEnumFromNameFn,
+          JavaEnumT javaEnum) {
+    if (javaEnum != null) {
+      return protoFieldSetterFn.apply(protoEnumFromNameFn.apply(javaEnum.name()));
+    }
+    return builder;
+  }
+
+  private UserPreferencesConverter() {}
+}
diff --git a/javatests/com/google/gerrit/server/account/AccountCacheTest.java b/javatests/com/google/gerrit/server/account/AccountCacheTest.java
index c1eff15..6628362 100644
--- a/javatests/com/google/gerrit/server/account/AccountCacheTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountCacheTest.java
@@ -21,9 +21,11 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.NotifyConfig;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.proto.Entities;
 import com.google.gerrit.server.cache.proto.Cache;
 import com.google.gerrit.server.config.CachedPreferences;
 import java.time.Instant;
+import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
 /**
@@ -49,7 +51,7 @@
             .setPreferredEmail("foo@bar.tld")
             .build();
     CachedAccountDetails original =
-        CachedAccountDetails.create(account, ImmutableMap.of(), CachedPreferences.fromString(""));
+        CachedAccountDetails.create(account, ImmutableMap.of(), CachedPreferences.EMPTY);
     byte[] serialized = SERIALIZER.serialize(original);
     Cache.AccountDetailsProto expected =
         Cache.AccountDetailsProto.newBuilder()
@@ -71,7 +73,7 @@
   @Test
   public void account_roundTripNullFields() throws Exception {
     CachedAccountDetails original =
-        CachedAccountDetails.create(ACCOUNT, ImmutableMap.of(), CachedPreferences.fromString(""));
+        CachedAccountDetails.create(ACCOUNT, ImmutableMap.of(), CachedPreferences.EMPTY);
     byte[] serialized = SERIALIZER.serialize(original);
     Cache.AccountDetailsProto expected =
         Cache.AccountDetailsProto.newBuilder().setAccount(ACCOUNT_PROTO).build();
@@ -80,16 +82,40 @@
   }
 
   @Test
-  public void config_roundTrip() throws Exception {
+  public void config_gitConfig_roundTrip() throws Exception {
+    Config cfg = new Config();
+    cfg.fromText("[general]\n\tfoo = bar");
     CachedAccountDetails original =
         CachedAccountDetails.create(
-            ACCOUNT, ImmutableMap.of(), CachedPreferences.fromString("[general]\n\tfoo = bar"));
+            ACCOUNT, ImmutableMap.of(), CachedPreferences.fromLegacyConfig(cfg));
 
     byte[] serialized = SERIALIZER.serialize(original);
     Cache.AccountDetailsProto expected =
         Cache.AccountDetailsProto.newBuilder()
             .setAccount(ACCOUNT_PROTO)
-            .setUserPreferences("[general]\n\tfoo = bar")
+            .setUserPreferences(
+                Cache.CachedPreferencesProto.newBuilder().setLegacyGitConfig(cfg.toText()))
+            .build();
+    ProtoTruth.assertThat(Cache.AccountDetailsProto.parseFrom(serialized)).isEqualTo(expected);
+    Truth.assertThat(SERIALIZER.deserialize(serialized)).isEqualTo(original);
+  }
+
+  @Test
+  public void config_protoConfig_roundTrip() throws Exception {
+    Entities.UserPreferences proto =
+        Entities.UserPreferences.newBuilder()
+            .setGeneralPreferencesInfo(
+                Entities.UserPreferences.GeneralPreferencesInfo.newBuilder().setChangesPerPage(17))
+            .build();
+    CachedAccountDetails original =
+        CachedAccountDetails.create(
+            ACCOUNT, ImmutableMap.of(), CachedPreferences.fromUserPreferencesProto(proto));
+
+    byte[] serialized = SERIALIZER.serialize(original);
+    Cache.AccountDetailsProto expected =
+        Cache.AccountDetailsProto.newBuilder()
+            .setAccount(ACCOUNT_PROTO)
+            .setUserPreferences(Cache.CachedPreferencesProto.newBuilder().setUserPreferences(proto))
             .build();
     ProtoTruth.assertThat(Cache.AccountDetailsProto.parseFrom(serialized)).isEqualTo(expected);
     Truth.assertThat(SERIALIZER.deserialize(serialized)).isEqualTo(original);
@@ -103,7 +129,7 @@
         CachedAccountDetails.create(
             ACCOUNT,
             ImmutableMap.of(key, ImmutableSet.of(NotifyConfig.NotifyType.ALL_COMMENTS)),
-            CachedPreferences.fromString(""));
+            CachedPreferences.EMPTY);
 
     byte[] serialized = SERIALIZER.serialize(original);
     Cache.AccountDetailsProto expected =
@@ -127,7 +153,7 @@
         CachedAccountDetails.create(
             ACCOUNT,
             ImmutableMap.of(key, ImmutableSet.of(NotifyConfig.NotifyType.ALL_COMMENTS)),
-            CachedPreferences.fromString(""));
+            CachedPreferences.EMPTY);
 
     byte[] serialized = SERIALIZER.serialize(original);
     Cache.AccountDetailsProto expected =
diff --git a/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java b/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
new file mode 100644
index 0000000..772f4b8
--- /dev/null
+++ b/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
@@ -0,0 +1,161 @@
+// Copyright (C) 2023 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.
+
+package com.google.gerrit.server.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.EditPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.proto.Entities.UserPreferences;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class CachedPreferencesTest {
+  @Test
+  public void gitConfig_roundTrip() throws Exception {
+    Config originalCfg = new Config();
+    originalCfg.fromText("[general]\n\tfoo = bar");
+
+    CachedPreferences pref = CachedPreferences.fromLegacyConfig(originalCfg);
+    Config res = pref.asConfig();
+
+    assertThat(res.toText()).isEqualTo(originalCfg.toText());
+  }
+
+  @Test
+  public void gitConfig_getGeneralPreferences() throws Exception {
+    Config originalCfg = new Config();
+    originalCfg.fromText("[general]\n\tchangesPerPage = 2");
+
+    CachedPreferences pref = CachedPreferences.fromLegacyConfig(originalCfg);
+    GeneralPreferencesInfo general = CachedPreferences.general(Optional.empty(), pref);
+
+    assertThat(general.changesPerPage).isEqualTo(2);
+  }
+
+  @Test
+  public void gitConfig_getDiffPreferences() throws Exception {
+    Config originalCfg = new Config();
+    originalCfg.fromText("[diff]\n\tcontext = 3");
+
+    CachedPreferences pref = CachedPreferences.fromLegacyConfig(originalCfg);
+    DiffPreferencesInfo diff = CachedPreferences.diff(Optional.empty(), pref);
+
+    assertThat(diff.context).isEqualTo(3);
+  }
+
+  @Test
+  public void gitConfig_getEditPreferences() throws Exception {
+    Config originalCfg = new Config();
+    originalCfg.fromText("[edit]\n\ttabSize = 5");
+
+    CachedPreferences pref = CachedPreferences.fromLegacyConfig(originalCfg);
+    EditPreferencesInfo edit = CachedPreferences.edit(Optional.empty(), pref);
+
+    assertThat(edit.tabSize).isEqualTo(5);
+  }
+
+  @Test
+  public void userPreferencesProto_roundTrip() throws Exception {
+    UserPreferences originalProto =
+        UserPreferences.newBuilder()
+            .setGeneralPreferencesInfo(
+                UserPreferences.GeneralPreferencesInfo.newBuilder().setChangesPerPage(7))
+            .build();
+
+    CachedPreferences pref = CachedPreferences.fromUserPreferencesProto(originalProto);
+    UserPreferences res = pref.asUserPreferencesProto();
+
+    assertThat(res).isEqualTo(originalProto);
+  }
+
+  @Test
+  public void userPreferencesProto_getGeneralPreferences() throws Exception {
+    UserPreferences originalProto =
+        UserPreferences.newBuilder()
+            .setGeneralPreferencesInfo(
+                UserPreferences.GeneralPreferencesInfo.newBuilder().setChangesPerPage(11))
+            .build();
+
+    CachedPreferences pref = CachedPreferences.fromUserPreferencesProto(originalProto);
+    GeneralPreferencesInfo general = CachedPreferences.general(Optional.empty(), pref);
+
+    assertThat(general.changesPerPage).isEqualTo(11);
+  }
+
+  @Test
+  public void userPreferencesProto_getDiffPreferences() throws Exception {
+    UserPreferences originalProto =
+        UserPreferences.newBuilder()
+            .setDiffPreferencesInfo(UserPreferences.DiffPreferencesInfo.newBuilder().setContext(13))
+            .build();
+
+    CachedPreferences pref = CachedPreferences.fromUserPreferencesProto(originalProto);
+    DiffPreferencesInfo diff = CachedPreferences.diff(Optional.empty(), pref);
+
+    assertThat(diff.context).isEqualTo(13);
+  }
+
+  @Test
+  public void userPreferencesProto_getEditPreferences() throws Exception {
+    UserPreferences originalProto =
+        UserPreferences.newBuilder()
+            .setEditPreferencesInfo(UserPreferences.EditPreferencesInfo.newBuilder().setTabSize(17))
+            .build();
+
+    CachedPreferences pref = CachedPreferences.fromUserPreferencesProto(originalProto);
+    EditPreferencesInfo edit = CachedPreferences.edit(Optional.empty(), pref);
+
+    assertThat(edit.tabSize).isEqualTo(17);
+  }
+
+  @Test
+  public void defaultPreferences_acceptingGitConfig() throws Exception {
+    Config cfg = new Config();
+    cfg.fromText("[general]\n\tchangesPerPage = 19");
+    CachedPreferences defaults = CachedPreferences.fromLegacyConfig(cfg);
+    CachedPreferences userPreferences =
+        CachedPreferences.fromUserPreferencesProto(UserPreferences.getDefaultInstance());
+
+    assertThat(CachedPreferences.general(Optional.of(defaults), userPreferences)).isNotNull();
+    assertThat(CachedPreferences.diff(Optional.of(defaults), userPreferences)).isNotNull();
+    assertThat(CachedPreferences.edit(Optional.of(defaults), userPreferences)).isNotNull();
+  }
+
+  @Test
+  public void defaultPreferences_throwingForProto() throws Exception {
+    CachedPreferences defaults =
+        CachedPreferences.fromUserPreferencesProto(UserPreferences.getDefaultInstance());
+    CachedPreferences userPreferences =
+        CachedPreferences.fromUserPreferencesProto(UserPreferences.getDefaultInstance());
+    assertThrows(
+        StorageException.class,
+        () -> CachedPreferences.general(Optional.of(defaults), userPreferences));
+    assertThrows(
+        StorageException.class,
+        () -> CachedPreferences.diff(Optional.of(defaults), userPreferences));
+    assertThrows(
+        StorageException.class,
+        () -> CachedPreferences.edit(Optional.of(defaults), userPreferences));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java b/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
new file mode 100644
index 0000000..bbc3c0a
--- /dev/null
+++ b/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
@@ -0,0 +1,304 @@
+// Copyright (C) 2023 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.
+
+package com.google.gerrit.server.config;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static java.util.Arrays.stream;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.EditPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.proto.Entities.UserPreferences;
+import com.google.gerrit.proto.Entities.UserPreferences.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.DateFormat;
+import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.DefaultBase;
+import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.DiffView;
+import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.EmailFormat;
+import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.EmailStrategy;
+import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.MenuItem;
+import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.Theme;
+import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.TimeFormat;
+import com.google.gerrit.server.config.UserPreferencesConverter.DiffPreferencesInfoConverter;
+import com.google.gerrit.server.config.UserPreferencesConverter.EditPreferencesInfoConverter;
+import com.google.gerrit.server.config.UserPreferencesConverter.GeneralPreferencesInfoConverter;
+import com.google.protobuf.Descriptors.Descriptor;
+import com.google.protobuf.Descriptors.EnumDescriptor;
+import java.util.EnumSet;
+import java.util.function.Function;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class UserPreferencesConverterTest {
+  @Test
+  public void generalPreferencesInfo_compareEnumNames() {
+    // The converter assumes that the enum type equivalents have exactly the same values in both
+    // classes. This test goes over all the enums to verify this assumption.
+    //
+    // If this test breaks, you are likely changing an enum. Please add it to the upstream Java
+    // class first, and on import - also update the proto version and the converter.
+    ImmutableMap<String, EnumDescriptor> protoEnums =
+        getProtoEnum(UserPreferences.GeneralPreferencesInfo.getDescriptor());
+    ImmutableMap<String, EnumSet<?>> javaEnums = getJavaEnums(GeneralPreferencesInfo.class);
+    assertThat(protoEnums.keySet()).containsExactlyElementsIn(javaEnums.keySet());
+    for (String enumName : protoEnums.keySet()) {
+      ImmutableList<String> protoEnumValues =
+          protoEnums.get(enumName).getValues().stream()
+              .map(v -> v.getName())
+              .collect(toImmutableList());
+      ImmutableList<String> javaEnumValues =
+          javaEnums.get(enumName).stream().map(Enum::name).collect(toImmutableList());
+      assertThat(protoEnumValues).containsExactlyElementsIn(javaEnumValues);
+    }
+  }
+
+  @Test
+  public void generalPreferencesInfo_doubleConversionWithAllFieldsSet() {
+    UserPreferences.GeneralPreferencesInfo originalProto =
+        UserPreferences.GeneralPreferencesInfo.newBuilder()
+            .setChangesPerPage(42)
+            .setDownloadScheme("DownloadScheme")
+            .setTheme(Theme.DARK)
+            .setDateFormat(DateFormat.UK)
+            .setTimeFormat(TimeFormat.HHMM_24)
+            .setExpandInlineDiffs(true)
+            .setRelativeDateInChangeTable(true)
+            .setDiffView(DiffView.UNIFIED_DIFF)
+            .setSizeBarInChangeTable(true)
+            .setLegacycidInChangeTable(true)
+            .setMuteCommonPathPrefixes(true)
+            .setSignedOffBy(true)
+            .setEmailStrategy(EmailStrategy.CC_ON_OWN_COMMENTS)
+            .setEmailFormat(EmailFormat.HTML_PLAINTEXT)
+            .setDefaultBaseForMerges(DefaultBase.FIRST_PARENT)
+            .setPublishCommentsOnPush(true)
+            .setDisableKeyboardShortcuts(true)
+            .setDisableTokenHighlighting(true)
+            .setWorkInProgressByDefault(true)
+            .addAllMyMenuItems(
+                ImmutableList.of(
+                    MenuItem.newBuilder()
+                        .setUrl("url1")
+                        .setName("name1")
+                        .setTarget("target1")
+                        .setId("id1")
+                        .build(),
+                    MenuItem.newBuilder()
+                        .setUrl("url2")
+                        .setName("name2")
+                        .setTarget("target2")
+                        .setId("id2")
+                        .build()))
+            .addAllChangeTable(ImmutableList.of("table1", "table2"))
+            .setAllowBrowserNotifications(true)
+            .build();
+    UserPreferences.GeneralPreferencesInfo resProto =
+        GeneralPreferencesInfoConverter.toProto(
+            GeneralPreferencesInfoConverter.fromProto(originalProto));
+    assertThat(resProto).isEqualTo(originalProto);
+  }
+
+  @Test
+  public void generalPreferencesInfo_emptyJavaToProto() {
+    GeneralPreferencesInfo info = new GeneralPreferencesInfo();
+    UserPreferences.GeneralPreferencesInfo res = GeneralPreferencesInfoConverter.toProto(info);
+    assertThat(res).isEqualToDefaultInstance();
+  }
+
+  @Test
+  public void generalPreferencesInfo_defaultJavaToProto() {
+    GeneralPreferencesInfo info = GeneralPreferencesInfo.defaults();
+    UserPreferences.GeneralPreferencesInfo res = GeneralPreferencesInfoConverter.toProto(info);
+    assertThat(res)
+        .ignoringFieldAbsence()
+        .isEqualTo(UserPreferences.GeneralPreferencesInfo.getDefaultInstance());
+  }
+
+  @Test
+  public void generalPreferencesInfo_emptyProtoToJava() {
+    UserPreferences.GeneralPreferencesInfo proto =
+        UserPreferences.GeneralPreferencesInfo.getDefaultInstance();
+    GeneralPreferencesInfo res = GeneralPreferencesInfoConverter.fromProto(proto);
+    assertThat(res).isEqualTo(new GeneralPreferencesInfo());
+  }
+
+  @Test
+  public void diffPreferencesInfo_compareEnumNames() {
+    // The converter assumes that the enum type equivalents have exactly the same values in both
+    // classes. This test goes over all the enums to verify this assumption.
+    //
+    // If this test breaks, you are likely changing an enum. Please add it to the upstream Java
+    // class first, and on import - also update the proto version and the converter.
+    ImmutableMap<String, EnumDescriptor> protoEnums =
+        getProtoEnum(UserPreferences.DiffPreferencesInfo.getDescriptor());
+    ImmutableMap<String, EnumSet<?>> javaEnums = getJavaEnums(DiffPreferencesInfo.class);
+    assertThat(protoEnums.keySet()).containsExactlyElementsIn(javaEnums.keySet());
+    for (String enumName : protoEnums.keySet()) {
+      ImmutableList<String> protoEnumValues =
+          protoEnums.get(enumName).getValues().stream()
+              .map(v -> v.getName())
+              .collect(toImmutableList());
+      ImmutableList<String> javaEnumValues =
+          javaEnums.get(enumName).stream().map(Enum::name).collect(toImmutableList());
+      assertThat(protoEnumValues).containsExactlyElementsIn(javaEnumValues);
+    }
+  }
+
+  @Test
+  public void diffPreferencesInfo_doubleConversionWithAllFieldsSet() {
+    UserPreferences.DiffPreferencesInfo originalProto =
+        UserPreferences.DiffPreferencesInfo.newBuilder()
+            .setContext(1)
+            .setTabSize(2)
+            .setFontSize(3)
+            .setLineLength(4)
+            .setCursorBlinkRate(5)
+            .setExpandAllComments(false)
+            .setIntralineDifference(true)
+            .setManualReview(false)
+            .setShowLineEndings(true)
+            .setShowTabs(false)
+            .setShowWhitespaceErrors(true)
+            .setSyntaxHighlighting(false)
+            .setHideTopMenu(true)
+            .setAutoHideDiffTableHeader(false)
+            .setHideLineNumbers(true)
+            .setRenderEntireFile(false)
+            .setHideEmptyPane(true)
+            .setMatchBrackets(false)
+            .setLineWrapping(true)
+            .setIgnoreWhitespace(Whitespace.IGNORE_TRAILING)
+            .setRetainHeader(true)
+            .setSkipDeleted(false)
+            .setSkipUnchanged(true)
+            .setSkipUncommented(false)
+            .build();
+    UserPreferences.DiffPreferencesInfo resProto =
+        DiffPreferencesInfoConverter.toProto(DiffPreferencesInfoConverter.fromProto(originalProto));
+    assertThat(resProto).isEqualTo(originalProto);
+  }
+
+  @Test
+  public void diffPreferencesInfo_emptyJavaToProto() {
+    DiffPreferencesInfo info = new DiffPreferencesInfo();
+    UserPreferences.DiffPreferencesInfo res = DiffPreferencesInfoConverter.toProto(info);
+    assertThat(res).isEqualToDefaultInstance();
+  }
+
+  @Test
+  public void diffPreferencesInfo_defaultJavaToProto() {
+    DiffPreferencesInfo info = DiffPreferencesInfo.defaults();
+    UserPreferences.DiffPreferencesInfo res = DiffPreferencesInfoConverter.toProto(info);
+    assertThat(res)
+        .ignoringFieldAbsence()
+        .isEqualTo(UserPreferences.DiffPreferencesInfo.getDefaultInstance());
+  }
+
+  @Test
+  public void diffPreferencesInfo_emptyProtoToJava() {
+    UserPreferences.DiffPreferencesInfo proto =
+        UserPreferences.DiffPreferencesInfo.getDefaultInstance();
+    DiffPreferencesInfo res = DiffPreferencesInfoConverter.fromProto(proto);
+    assertThat(res).isEqualTo(new DiffPreferencesInfo());
+  }
+
+  @Test
+  public void editPreferencesInfo_compareEnumNames() {
+    // The converter assumes that the enum type equivalents have exactly the same values in both
+    // classes. This test goes over all the enums to verify this assumption.
+    //
+    // If this test breaks, you are likely changing an enum. Please add it to the upstream Java
+    // class first, and on import - also update the proto version and the converter.
+    ImmutableMap<String, EnumDescriptor> protoEnums =
+        getProtoEnum(UserPreferences.EditPreferencesInfo.getDescriptor());
+    ImmutableMap<String, EnumSet<?>> javaEnums = getJavaEnums(EditPreferencesInfo.class);
+    assertThat(protoEnums.keySet()).containsExactlyElementsIn(javaEnums.keySet());
+    for (String enumName : protoEnums.keySet()) {
+      ImmutableList<String> protoEnumValues =
+          protoEnums.get(enumName).getValues().stream()
+              .map(v -> v.getName())
+              .collect(toImmutableList());
+      ImmutableList<String> javaEnumValues =
+          javaEnums.get(enumName).stream().map(Enum::name).collect(toImmutableList());
+      assertThat(protoEnumValues).containsExactlyElementsIn(javaEnumValues);
+    }
+  }
+
+  @Test
+  public void editPreferencesInfo_doubleConversionWithAllFieldsSet() {
+    UserPreferences.EditPreferencesInfo originalProto =
+        UserPreferences.EditPreferencesInfo.newBuilder()
+            .setTabSize(2)
+            .setLineLength(3)
+            .setIndentUnit(5)
+            .setCursorBlinkRate(7)
+            .setHideTopMenu(true)
+            .setShowTabs(false)
+            .setShowWhitespaceErrors(true)
+            .setSyntaxHighlighting(false)
+            .setHideLineNumbers(true)
+            .setMatchBrackets(false)
+            .setLineWrapping(true)
+            .setIndentWithTabs(false)
+            .setAutoCloseBrackets(true)
+            .setShowBase(false)
+            .build();
+    UserPreferences.EditPreferencesInfo resProto =
+        EditPreferencesInfoConverter.toProto(EditPreferencesInfoConverter.fromProto(originalProto));
+    assertThat(resProto).isEqualTo(originalProto);
+  }
+
+  @Test
+  public void editPreferencesInfo_emptyJavaToProto() {
+    EditPreferencesInfo info = new EditPreferencesInfo();
+    UserPreferences.EditPreferencesInfo res = EditPreferencesInfoConverter.toProto(info);
+    assertThat(res).isEqualToDefaultInstance();
+  }
+
+  @Test
+  public void editPreferencesInfo_defaultJavaToProto() {
+    EditPreferencesInfo info = EditPreferencesInfo.defaults();
+    UserPreferences.EditPreferencesInfo res = EditPreferencesInfoConverter.toProto(info);
+    assertThat(res)
+        .ignoringFieldAbsence()
+        .isEqualTo(UserPreferences.EditPreferencesInfo.getDefaultInstance());
+  }
+
+  @Test
+  public void editPreferencesInfo_emptyProtoToJava() {
+    UserPreferences.EditPreferencesInfo proto =
+        UserPreferences.EditPreferencesInfo.getDefaultInstance();
+    EditPreferencesInfo res = EditPreferencesInfoConverter.fromProto(proto);
+    assertThat(res).isEqualTo(new EditPreferencesInfo());
+  }
+
+  private ImmutableMap<String, EnumDescriptor> getProtoEnum(Descriptor d) {
+    return d.getEnumTypes().stream().collect(toImmutableMap(e -> e.getName(), Function.identity()));
+  }
+
+  @SuppressWarnings("unchecked")
+  private ImmutableMap<String, EnumSet<?>> getJavaEnums(Class<?> c) {
+    return stream(c.getDeclaredClasses())
+        .filter(Class::isEnum)
+        .collect(
+            toImmutableMap(Class::getSimpleName, e -> EnumSet.allOf(e.asSubclass(Enum.class))));
+  }
+}
diff --git a/proto/cache.proto b/proto/cache.proto
index 7e38d92..87ae0e4 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -323,6 +323,15 @@
   repeated string notify_type = 3;
 }
 
+// Serialized user preferences.
+// Next ID: 3
+message CachedPreferencesProto {
+  oneof Preferences {
+    devtools.gerritcodereview.UserPreferences user_preferences = 1;
+    string legacy_git_config = 2;
+  }
+}
+
 // Serialized form of
 // com.google.gerrit.entities.Account.
 // Next ID: 9
@@ -345,11 +354,12 @@
 }
 
 // Serialized form of com.google.gerrit.server.account.CachedAccountDetails.
-// Next ID: 4
+// Next ID: 5
 message AccountDetailsProto {
   AccountProto account = 1;
   repeated ProjectWatchProto project_watch_proto = 2;
-  string user_preferences = 3;
+  CachedPreferencesProto user_preferences = 4;
+  reserved 3;
 }
 
 // Serialized form of com.google.gerrit.entities.Project.
diff --git a/proto/entities.proto b/proto/entities.proto
index 20d5444..372426a 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -168,3 +168,145 @@
 message PaginationToken {
   optional string next_page_token = 1;
 }
+
+// Proto representation of the User preferences classes
+// Next ID: 4
+message UserPreferences {
+  // Next ID: 23
+  message GeneralPreferencesInfo {
+    // Number of changes to show in a screen.
+    optional int32 changes_per_page = 1 [default = 25];
+
+    // Type of download URL the user prefers to use. */
+    optional string download_scheme = 2;
+
+    enum Theme {
+      AUTO = 0;
+      DARK = 1;
+      LIGHT = 2;
+    }
+    optional Theme theme = 3;
+
+    enum DateFormat {
+      STD = 0;
+      US = 1;
+      ISO = 2;
+      EURO = 3;
+      UK = 4;
+    }
+    optional DateFormat date_format = 4;
+
+    enum TimeFormat {
+      HHMM_12 = 0;
+      HHMM_24 = 1;
+    }
+    optional TimeFormat time_format = 5;
+
+    optional bool expand_inline_diffs = 6;
+    optional bool relative_date_in_change_table = 20;
+
+    enum DiffView {
+      SIDE_BY_SIDE = 0;
+      UNIFIED_DIFF = 1;
+    }
+    optional DiffView diff_view = 21;
+
+    optional bool size_bar_in_change_table = 22 [default = true];
+    optional bool legacycid_in_change_table = 7;
+    optional bool mute_common_path_prefixes = 8 [default = true];
+    optional bool signed_off_by = 9;
+
+    enum EmailStrategy {
+      ENABLED = 0;
+      CC_ON_OWN_COMMENTS = 1;
+      ATTENTION_SET_ONLY = 2;
+      DISABLED = 3;
+    }
+    optional EmailStrategy email_strategy = 10;
+
+    enum EmailFormat {
+      PLAINTEXT = 0;
+      HTML_PLAINTEXT = 1;
+    }
+    optional EmailFormat email_format = 11 [default = HTML_PLAINTEXT];
+
+    enum DefaultBase {
+      AUTO_MERGE = 0;
+      FIRST_PARENT = 1;
+    }
+    optional DefaultBase default_base_for_merges = 12 [default = FIRST_PARENT];
+
+    optional bool publish_comments_on_push = 13;
+    optional bool disable_keyboard_shortcuts = 14;
+    optional bool disable_token_highlighting = 15;
+    optional bool work_in_progress_by_default = 16;
+
+    message MenuItem {
+      optional string url = 1;
+      optional string name = 2;
+      optional string target = 3;
+      optional string id = 4;
+    }
+    repeated MenuItem my_menu_items = 17;
+
+    repeated string change_table = 18;
+    optional bool allow_browser_notifications = 19 [default = true];
+  }
+  optional GeneralPreferencesInfo general_preferences_info = 1;
+
+  // Next ID: 25
+  message DiffPreferencesInfo {
+    optional int32 context = 1 [default = 10];
+    optional int32 tab_size = 2 [default = 8];
+    optional int32 font_size = 3 [default = 12];
+    optional int32 line_length = 4 [default = 100];
+    optional int32 cursor_blink_rate = 5;
+    optional bool expand_all_comments = 6;
+    optional bool intraline_difference = 7 [default = true];
+    optional bool manual_review = 8;
+    optional bool show_line_endings = 9 [default = true];
+    optional bool show_tabs = 10 [default = true];
+    optional bool show_whitespace_errors = 11 [default = true];
+    optional bool syntax_highlighting = 12 [default = true];
+    optional bool hide_top_menu = 13;
+    optional bool auto_hide_diff_table_header = 14 [default = true];
+    optional bool hide_line_numbers = 15;
+    optional bool render_entire_file = 16;
+    optional bool hide_empty_pane = 17;
+    optional bool match_brackets = 18;
+    optional bool line_wrapping = 19;
+
+    enum Whitespace {
+      IGNORE_NONE = 0;
+      IGNORE_TRAILING = 1;
+      IGNORE_LEADING_AND_TRAILING = 2;
+      IGNORE_ALL = 3;
+    }
+    optional Whitespace ignore_whitespace = 20;
+
+    optional bool retain_header = 21;
+    optional bool skip_deleted = 22;
+    optional bool skip_unchanged = 23;
+    optional bool skip_uncommented = 24;
+  }
+  optional DiffPreferencesInfo diff_preferences_info = 2;
+
+  // Next ID: 15
+  message EditPreferencesInfo {
+    optional int32 tab_size = 1 [default = 8];
+    optional int32 line_length = 2 [default = 100];
+    optional int32 indent_unit = 3 [default = 2];
+    optional int32 cursor_blink_rate = 4;
+    optional bool hide_top_menu = 5;
+    optional bool show_tabs = 6 [default = true];
+    optional bool show_whitespace_errors = 7;
+    optional bool syntax_highlighting = 8 [default = true];
+    optional bool hide_line_numbers = 9;
+    optional bool match_brackets = 10 [default = true];
+    optional bool line_wrapping = 11;
+    optional bool indent_with_tabs = 12;
+    optional bool auto_close_brackets = 13;
+    optional bool show_base = 14;
+  }
+  optional EditPreferencesInfo edit_preferences_info = 3;
+}