CachedPreferences: add support for proto based preferences

CachedPreferences originally only supported `jgit.lib.Config` data.
This change is adding support for proto-based ones. The new protos are
completely equivalent to the Java preference classes, and they should be
kept in sync (enforced by the converter test).

The change only applies for account-level preferences. Default host
preferences are still assumed to be git configs.

This is a part of a series of changes intended to add support for
different account storage systems.

Bug: Google b/289357382
Release-Notes: skip
Change-Id: I99ed211f3f25d59f03d70ccd05c860be5c747b3d
diff --git a/java/com/google/gerrit/server/config/CachedPreferences.java b/java/com/google/gerrit/server/config/CachedPreferences.java
index 388f58a..6003bc6 100644
--- a/java/com/google/gerrit/server/config/CachedPreferences.java
+++ b/java/com/google/gerrit/server/config/CachedPreferences.java
@@ -15,11 +15,15 @@
 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 com.google.protobuf.TextFormat;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -27,17 +31,24 @@
 /**
  * Container class for preferences serialized as Git-style config files. Keeps the values as {@link
  * String}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 CachedPreferences EMPTY = fromString("");
+  public static final CachedPreferences EMPTY = fromString("");
 
   public abstract String config();
 
-  /** Returns a cache-able representation of the config. */
+  /** Returns a cache-able representation of the git config. */
   public static CachedPreferences fromConfig(Config cfg) {
-    return new AutoValue_CachedPreferences(cfg.toText());
+    return fromProto(CachedPreferencesProto.newBuilder().setLegacyGitConfig(cfg.toText()).build());
+  }
+
+  /** Returns a cache-able representation of the preferences proto. */
+  public static CachedPreferences fromUserPreferencesProto(UserPreferences proto) {
+    return fromProto(CachedPreferencesProto.newBuilder().setUserPreferences(proto).build());
   }
 
   /**
@@ -50,47 +61,118 @@
 
   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());
+      CachedPreferencesProto proto = asProto();
+      if (proto.hasLegacyGitConfig()) {
+        Config cfg = new Config();
+        cfg.fromText(proto.getLegacyGitConfig());
+        return cfg;
+      }
     } 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() {
+    CachedPreferencesProto proto = asProto();
+    if (proto.hasUserPreferences()) {
+      return proto.getUserPreferences();
+    }
+    throw new StorageException(
+        String.format("Cannot parse the given config as a UserPreferences proto. Got [%s]", proto));
+  }
+
+  private static CachedPreferences fromProto(CachedPreferencesProto proto) {
+    return new AutoValue_CachedPreferences(proto.toString());
+  }
+
+  private CachedPreferencesProto asProto() {
+    try {
+      CachedPreferencesProto.Builder builder = CachedPreferencesProto.newBuilder();
+      TextFormat.merge(config(), builder);
+      if (builder
+          .getPreferencesCase()
+          .equals(CachedPreferencesProto.PreferencesCase.PREFERENCES_NOT_SET)) {
+        // In case of an empty config, TextFormat will create an empty proto instead of throwing.
+        builder.setLegacyGitConfig(config());
+      }
+      return builder.build();
+    } catch (TextFormat.ParseException e) {
+      return CachedPreferencesProto.newBuilder().setLegacyGitConfig(config()).build();
+    }
   }
 
   @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.asProto();
+      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/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/config/CachedPreferencesTest.java b/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
new file mode 100644
index 0000000..c3bd102
--- /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.fromConfig(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.fromConfig(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.fromConfig(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.fromConfig(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.fromConfig(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..c553e2c 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
@@ -349,6 +358,7 @@
 message AccountDetailsProto {
   AccountProto account = 1;
   repeated ProjectWatchProto project_watch_proto = 2;
+  // TODO(nitzan): We should use CachedPreferencesProto instead.
   string user_preferences = 3;
 }
 
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;
+}