docs: protected static CapsVersionAndHash generateVerificationString(DiscoverInfo discoverInfo, String hash) {
          if (hash == null) {
              hash = DEFAULT_HASH;
          }
          // SUPPORTED_HASHES uses the format of MessageDigest, which is uppercase, e.g. "SHA-1" instead of "sha-1"
          MessageDigest md = SUPPORTED_HASHES.get(hash.toUpperCase(Locale.US));
          if (md == null)
              return null;
          // Then transform the hash to lowercase, as this value will be put on the wire within the caps element's hash
          // attribute. I'm not sure if the standard is case insensitive here, but let's assume that even it is, there could
          // be "broken" implementation in the wild, so we *always* transform to lowercase.
          hash = hash.toLowerCase(Locale.US);
  
          DataForm extendedInfo =  DataForm.from(discoverInfo);
  
          // 1. Initialize an empty string S ('sb' in this method).
          StringBuilder sb = new StringBuilder(); // Use StringBuilder as we don't
                                                  // need thread-safe StringBuffer
  
          // 2. Sort the service discovery identities by category and then by
          // type and then by xml:lang
          // (if it exists), formatted as CATEGORY '/' [TYPE] '/' [LANG] '/'
          // [NAME]. Note that each slash is included even if the LANG or
          // NAME is not included (in accordance with XEP-0030, the category and
          // type MUST be included.
          SortedSet<DiscoverInfo.Identity> sortedIdentities = new TreeSet<>();
  
          sortedIdentities.addAll(discoverInfo.getIdentities());
  
          // 3. For each identity, append the 'category/type/lang/name' to S,
          // followed by the '<' character.
          for (DiscoverInfo.Identity identity : sortedIdentities) {
              sb.append(identity.getCategory());
              sb.append('/');
              sb.append(identity.getType());
              sb.append('/');
              sb.append(identity.getLanguage() == null ? "" : identity.getLanguage());
              sb.append('/');
              sb.append(identity.getName() == null ? "" : identity.getName());
              sb.append('<');
          }
  
          // 4. Sort the supported service discovery features.
          SortedSet<String> features = new TreeSet<>();
          for (Feature f : discoverInfo.getFeatures())
              features.add(f.getVar());
  
          // 5. For each feature, append the feature to S, followed by the '<'
          // character
          for (String f : features) {
              sb.append(f);
              sb.append('<');
          }
  
          // only use the data form for calculation is it has a hidden FORM_TYPE
          // field
          // see XEP-0115 5.4 step 3.6
          if (extendedInfo != null && extendedInfo.hasHiddenFormTypeField()) {
              synchronized (extendedInfo) {
                  // 6. If the service discovery information response includes
                  // XEP-0128 data forms, sort the forms by the FORM_TYPE (i.e.,
                  // by the XML character data of the <value/> element).
                  SortedSet<FormField> fs = new TreeSet<>(new Comparator<FormField>() {
                      @Override
                      public int compare(FormField f1, FormField f2) {
                          return f1.getVariable().compareTo(f2.getVariable());
                      }
                  });
  
                  FormField ft = null;
  
                  for (FormField f : extendedInfo.getFields()) {
                      if (!f.getVariable().equals("FORM_TYPE")) {
                          fs.add(f);
                      } else {
                          ft = f;
                      }
                  }
  
                  // Add FORM_TYPE values
                  if (ft != null) {
                      formFieldValuesToCaps(ft.getValues(), sb);
                  }
  
                  // 7. 3. For each field other than FORM_TYPE:
                  // 1. Append the value of the "var" attribute, followed by the
                  // '<' character.
                  // 2. Sort values by the XML character data of the <value/>
                  // element.
                  // 3. For each <value/> element, append the XML character data,
                  // followed by the '<' character.
                  for (FormField f : fs) {
                      sb.append(f.getVariable());
                      sb.append('<');
                      formFieldValuesToCaps(f.getValues(), sb);
                  }
              }
          }
          // 8. Ensure that S is encoded according to the UTF-8 encoding (RFC
          // 3269).
          // 9. Compute the verification string by hashing S using the algorithm
          // specified in the 'hash' attribute (e.g., SHA-1 as defined in RFC
          // 3174).
          // The hashed data MUST be generated with binary output and
          // encoded using Base64 as specified in Section 4 of RFC 4648
          // (note: the Base64 output MUST NOT include whitespace and MUST set
          // padding bits to zero).
          byte[] bytes;
          try {
              bytes = sb.toString().getBytes(StringUtils.UTF8);
          }
          catch (UnsupportedEncodingException e) {
              throw new AssertionError(e);
          }
          byte[] digest;
          synchronized (md) {
              digest = md.digest(bytes);
          }
          String version = Base64.encodeToString(digest);
          return new CapsVersionAndHash(version, hash);
      }