大規模ソフトウェア(Chromium)を手探る user profileに設定を追加する

前回までのブログで任意のキーワードを含む検索結果に基づく自動補完を除くことができるようになりました。 今回はChromeのUI側で変更されたキーワードをuser profileで保存し、C++側のOmniboxの自動補完を行なっているクラスでこのデータを呼べるように変更します。
昨年(2016年度)の「大規模ソフトウェアを手探る」でChromiumを題材にしていた方がuser profileへの追加をした際のステップを丁寧に説明してくださっており、実装の際にとても参考になりました。

fizzy-yuya.hatenablog.com

Preferenceに表示したくないキーワードのデータを追加する

実際にキーワードフィルターをかける場合にはChromeの設定画面でユーザーのよって登録されたキーワードのデータを取ってきてフィルターをかける必要があります。ユーザーの登録したChromiumの設定はUser Profileに保存され、それぞれに固有のkey(pref_name)によって識別されます。

preferecesに任意の設定について追加したい場合には、

  1. PrefNamesクラスで新しく設定したいpreferenceのnameとデフォルト値を登録する。
  2. ProfileImplクラスのRegisterProfilePrefsd()という関数でこのpreferenceを登録する。

というステップでProfileにこの新しいpreferenceの値がストアされるようになり、PrefServiceを呼ぶことで、C++のプログラムから参照できるようになります。

詳しい説明は公式を参照してください。

まずchrome/common/pref_names.hに追加したいpreferenceの項目を追加します。今回はとりあえず全てのpreferenceの最後に新しくkRestrictedKeywordという項目を追加しました。

namespace prefs {
// Profile prefs. Please add Local State prefs below instead.
extern const char kChildAccountStatusKnown[];
...
// 変更ここから
extern const char kRestrictedKeyword[];
// 変更ここまで
}  // namespace prefs

これで宣言はできたので、cpp側でUI(JavaScript)側からこのpreferenceを参照する時に必要なpref_nameを登録します。ここに格納される値はデフォルト値ではなくてキーとして使われる識別子であることに注意してください。1

namespace prefs {
// *************** PROFILE PREFS ***************
// These are attached to the user profile
...
// 変更ここから
const char kRestrictedKeyword[] = "RestrictedKeyword";
// 変更ここまで
}  // namespace prefs

上のドキュメントにも書いてある通り、 pref_names.{h,cc}で定義された新たなpreferenceはchrome /browser/profiles/profile_impl.ccで登録される必要があります。

 // RegisterProfilePrefsd()というvoid関数でpreferenceの登録が行われている
void ProfileImpl::RegisterProfilePrefs(
    user_prefs::PrefRegistrySyncable* registry) {
  registry->RegisterBooleanPref(prefs::kSavingBrowserHistoryDisabled, false);
...
 // 変更ここから
  registry->RegisterStringPref(prefs::kRestrictedKeyword, std::string());
// 変更ここまで
}

デフォルトの値を設定したい場合にはRegisterStringPref()の二つ目の引数で設定ができ、例えばregistry->RegisterStringPref(prefs::kRestrictedKeyword, "foobarhoge");とすればユーザーによって制限したキーワードが設定されるまで、"foobarhoge"がkRestrictedKeywordのデフォルト値として、foobarhogeを含む検索結果が表示されないようになります。

これでuser profileに新しいpreferenceを追加することができたので、いよいよC++側からpreferenceの情報を取得して自動補完候補にフィルターをかける機能を実装しようと思います。

AutocompleteControllerからUser profileを参照する

ユーザーのpreferencesを取得するためには、PrefServiceをservice clientから呼ぶ必要があります。 前回のブログの実装では実質的に自動補完の候補をソートし、重複を除く関数であるSortAndDedup()というAutocompleteResultクラスの関数の中でフィルター機能を実装しました。

ただこの実装方法を用いると、AutocompleteResultクラスがservice clientに順ずるものにアクセスできないため、この関数を呼び出しているAutocompleteController側から何らかの方法でkRestrictedKeywordの情報を渡す必要があります。 AutocompleteControllerはSortAndCull()という関数を経由して間接的にSortAndDedup()関数を読んでいます。

SortAndCull()関数がconst string kRestrictedKeywordという引数を追加で取るように変更することも考えたのですが、Call Hierarchyをチェックしたところ 15 occurrencesだった(つまり15箇所で呼ばれている)ため、 全部変更するのちょっとめんどくさいな...ということで、新たにSortAndCullWithKeyword()という関数をAutocompleteResultクラスに追加し、keywordが存在する場合にはこちらの関数が呼ばれるように変更しました。

まず、関数の宣言をヘッダーファイルに追加します。

  void SortAndCull(const AutocompleteInput& input,
                   TemplateURLService* template_url_service);
  // 変更ここから
  void SortAndCullWithKeyword(const AutocompleteInput& input,
                              TemplateURLService* template_url_service,
                              const std::string& keyword);
  // 変更ここまで

次に、新たに追加したSortAndCullWithKeyword()をcppファイルで定義します。基本的にはオリジナルのSortAndCull()関数を内部で呼びつつ、 keywordに従ってフィルターをかけています。フィルターをかけるに当たって、入力文字列とマッチした自動補完候補のベクターであるmatchesの先頭がdefault_matchとして保持されていなくてはいけないので、最後にdefault_matchを設定し直しています。

// SortAndCull()関数の次にSortAndCullWithKeyword()関数を追加する。
// 変更ここから
void AutocompleteResult::SortAndCullWithKeyword(
    const AutocompleteInput& input,
    TemplateURLService* template_url_service,
    const std::string& keyword) {
  // デフォルトのSortAndCullを呼ぶ
  SortAndCull(input, template_url_service);

  // キーワードフィルターをかける
  for (auto itr = matches_.begin(); itr != matches_.end();) {
    bool be_removed = itr->type == AutocompleteMatchType::HISTORY_TITLE ||
                      itr->type == AutocompleteMatchType::HISTORY_BODY ||
                      itr->type == AutocompleteMatchType::SEARCH_HISTORY ||
                      itr->type == AutocompleteMatchType::SEARCH_SUGGEST_TAIL;
    if (be_removed && base::EqualsASCII(itr->contents, keyword) == true) {
      itr = matches_.erase(itr);
    } else {
      itr++;
    }
  }

  // default_match_を設定し直す
  default_match_ = matches_.begin();
}
// 変更ここまで

最後に、AutocompleteController側からSortAndCullWIth()の代わりにSortAndCullWithKeyword()を呼び、preferencesにストアされているkRestrictedKeywordを渡すように書き換えます。

void AutocompleteController::UpdateResult(
    bool regenerate_result,
    bool force_notify_default_match_changed) {
  TRACE_EVENT0("omnibox", "AutocompleteController::UpdateResult");
  const bool last_default_was_valid = result_.default_match() != result_.end();
  // The following three variables are only set and used if
  // |last_default_was_valid|.
  base::string16 last_default_fill_into_edit, last_default_keyword,
      last_default_associated_keyword;
  if (last_default_was_valid) {
    last_default_fill_into_edit = result_.default_match()->fill_into_edit;
    last_default_keyword = result_.default_match()->keyword;
    if (result_.default_match()->associated_keyword) {
      last_default_associated_keyword =
          result_.default_match()->associated_keyword->keyword;
    }
  }

  if (regenerate_result)
    result_.Reset();

  AutocompleteResult last_result;
  last_result.Swap(&result_);

  for (Providers::const_iterator i(providers_.begin());
       i != providers_.end(); ++i)
    result_.AppendMatches(input_, (*i)->matches());

  // 変更ここから 
  PrefService* prefs = provider_client_->GetPrefs();
  const std::string keyword = prefs->GetString(prefs::kRestrictedKeyword);
  // 非表示キーワードが設定されていた時はSortAndCullWithKeyword()を、
  // そうでない時は通常のSortAndCull()を呼ぶ。
  if (keyword) {
    result_.SortAndCullWithKeyword(input_, template_url_service_, keyword);
  } else {
    result_.SortAndCull(input_, template_url_service_);
  }
  // 変更ここまで

これでuser profile preferenceからユーザが設定したキーワードの情報を読み込み、Omniboxの自動補完候補に出したくないものを単語でフィルタリングできるようになりました。やったね。

ただこれだと、単一のキーワードに対してのみしかフィルタリング機能を使えないことになります。やっぱり複数のキーワードに対してフィルタリング機能を使いたいよね、ということになったので、キーワードをカンマで区切られた文字列で渡してもらい、それをtokenizeして複数のキーワードに対してフィルター機能を実装をするようにしました。

// 変更ここから
// tokenizeを行う関数を追加する
std::vector<std::string> TokenizesKeywordsStringToKeywordsVec(
    const std::string& keyword) {
  std::vector<std::string> tokens;
  std::size_t start = 0, end = 0;
  while ((end = keyword.find(',', start)) != std::string::npos) {
    tokens.push_back(keyword.substr(start, end - start));
    start = end + 1;
  }
  tokens.push_back(keyword.substr(start));
  return tokens;
}

void AutocompleteResult::SortAndCullWithKeyword(
    const AutocompleteInput& input,
    TemplateURLService* template_url_service,
    const std::string& keyword) {
  SortAndCull(input, template_url_service);

  std::vector<std::string> tokens =
      TokenizesKeywordsStringToKeywordsVec(keyword);
  for (auto i = tokens.begin(); i != tokens.end(); i++) {
    LOG(INFO) << *i << ' ';
    for (auto itr = matches_.begin(); itr != matches_.end();) {
      bool be_removed = itr->type == AutocompleteMatchType::HISTORY_TITLE ||
                        itr->type == AutocompleteMatchType::HISTORY_BODY ||
                        itr->type == AutocompleteMatchType::SEARCH_HISTORY ||
                        itr->type == AutocompleteMatchType::SEARCH_SUGGEST_TAIL;
      if (be_removed && base::EqualsASCII(itr->contents, *i) == true) {
        itr = matches_.erase(itr);
      } else {
        itr++;
      }
    }
  }
  default_match_ = matches_.begin();
}
// 変更ここまで

また、この実装だとまだ「完全に一致キーワードと一致する検索履歴」しかフィルターがかけられず、また特に英語の場合は大文字小文字の区別がされてしまって 結果的にフィルターがうまくかからないケースもありえます。 そこで最後に以下のように変更を加えました。 またついでにmatches_から消去して良いか、matchTypeから判別する部分についても関数として切り出し、Tokenizeを行う関数、全てのアルファベットの入力文字列を小文字化する関数とともに無名名前空間に入れます。

// 変更ここまで
namespace { 

base::string16 NormalizeRestrictedKeyword(std::string s) {
  return base::ToLowerASCII(base::UTF8ToUTF16(std::move(s)));
}

std::vector<base::string16> TokenizesKeywordsStringToKeywordsVec(
    const std::string& keyword) {

std::vector<std::string> TokenizesKeywordsStringToKeywordsVec(
    const std::string& keyword) {
  std::vector<base::string16> tokens;
  std::size_t start = 0, end;
  while ((end = keyword.find(',', start)) != std::string::npos) {
  std::vector<base::string16> tokens;
  std::size_t start = 0, end;
  while ((end = keyword.find(',', start)) != std::string::npos) {
    tokens.push_back(
        NormalizeRestrictedKeyword(keyword.substr(start, end - start)));
    start = end + 1;
  }

  tokens.push_back(NormalizeRestrictedKeyword(keyword.substr(start)));
  return tokens;
}

    
bool IsRemovableTypeFromMatch(AutocompleteMatchType::Type type) {
  return type == AutocompleteMatchType::HISTORY_TITLE ||
         type == AutocompleteMatchType::HISTORY_BODY ||
         type == AutocompleteMatchType::SEARCH_HISTORY ||
         type == AutocompleteMatchType::SEARCH_SUGGEST_TAIL;
}

}  // namespace

void AutocompleteResult::SortAndCullWithKeyword(
    const AutocompleteInput& input,
    TemplateURLService* template_url_service,
    const std::string& keyword) {
  std::vector<base::string16> restricted_keywords =
      TokenizesKeywordsStringToKeywordsVec(keyword);
  matches_.erase(
      std::remove_if(
          matches_.begin(), matches_.end(),
          [&restricted_keywords](const AutocompleteMatch& match) {
            bool removable = IsRemovableTypeFromMatch(match.type);
            if (removable) {
              base::string16 match_text = base::ToLowerASCII(match.contents);
              for (auto& restricted_keyword : restricted_keywords) {
                if (match_text.find(restricted_keyword) !=
                        base::string16::npos) {
                  return true;
                }
              }
            }
            return false;
          }),
      matches_.end());

  SortAndCull(input, template_url_service);
}
// 変更ここまで

ただこの変更を加えて実行してみると、キーワードフィルタリング自体はできるのですが、なぜか「キーワードと入力文字列が完全一致した時にcrashする」という現象が発生するようになります…なぜだ… まずは王道、crashした時のback traceをみてみます。

Check failed: input.text().empty() != default_match_->
    allowed_to_be_default_match (0 vs. 0)fill_into_edit=ポケモンgo, provider=Search, input=pokemongo
#0 0x7f6fac40677d base::debug::StackTrace::StackTrace()
#1 0x7f6fac404b4c base::debug::StackTrace::StackTrace()
#2 0x7f6fac4962ba logging::LogMessage::~LogMessage()
#3 0x558c51544aee AutocompleteResult::SortAndCull()
#4 0x558c51540e13 AutocompleteResult::CopyOldMatches()
#5 0x558c538379e0 AutocompleteController::UpdateResult()
#6 0x558c53836882 AutocompleteController::Start()

この時はOmniboxの入力している最中に落ちているのでinput.text().empty() == falseとなっています。
そのため、本来ならばdefault_match_-> allowed_to_be_default_match == trueにならなくてはいのですが
default_match_->allowed_to_be_default_match == falseになっているためにDCHECK()で落ちているようです。 この謎のdefault_match_allowed_to_be_default_matchについて、少し調べてみます。

default_match_とallowed_to_be_default_matchについて調べる

default_matchについて、AutocompleteResultクラスには特にコメント等で詳細な記述がないため、 default_matchはAutocompleteMatchというクラスのインスタンスになるため、このクラスを定義するautocomplete_match.cppファイルでallowed_to_be_default_matchについての めぼしい記述がないか探してみます。 するとallowed_to_be_default_matchについて以下のような記述を発見しました。

  // If false, the omnibox should prevent this match from being the
  // default match.  Providers should set this to true only if the
  // user's input, plus any inline autocompletion on this match, would
  // lead the user to expect a navigation to this match's destination.
  // For example, with input "foo", a search for "bar" or navigation
  // to "bar.com" should not set this flag; a navigation to "foo.com"
  // should only set this flag if ".com" will be inline autocompleted;
  // and a navigation to "foo/" (an intranet host) or search for "foo"
  // should set this flag.
  bool allowed_to_be_default_match;

allowed_to_be_default_matchは様々なProviderから返される自動補完候補に対して、それがdefault_matchになりうるかなり得ないかを表しているようです。 またallowed_to_be_default_match == trueになるのは、ユーザーの入力そのもの、もしくは入力に対しインライン自動補完を加えた候補に対してのみらしいですね。 そのためfooとユーザーが入力する時にbarbar.comdefault_matchにはなり得ないものの、 foo.comfoodefault_matchとなり得るようです。

つまり、先ほどのバグの原因は
「入力文字列と表示されたくないキーワードが完全に一致するもしくは入力文字列がキーワードを含む時、ユーザーの入力をそのまま候補としてサジェストするmatch(基本的にはこれが一番優先度の高いautocomplete_matchとしてdefault_match_にセットされている)がキーワードフィルタリングによって除かれる。その結果、優先度が二番目以降の候補がSortAndCull()が呼ばれた後default_match_にセットされるが、優先度が二番目以降の候補はallowed_to_be_default_match == trueとなる条件を満たしていないことが多いため2、クラッシュする」
ことだったようです。
元々「Search For...」と呼ばれる、ユーザーの文字列をそのまま検索候補として表示するものについてはbool IsRemovableTypeFromMatch(AutocompleteMatchType::Type type)関数でチェックをかけ、除去されないようにしていたはずでした。
しかし、おそらく同じキーワードの候補重複を排除する際に、他のProviderから返される別の候補で上書きされた結果除去されてしまったようです。

そこで入力文字列と一致する自動補完候補についてはmatches_から除去しないよう、以下のようにコードを修正しました。

// 変更ここから
namespace {
base::string16 NormalizeRestrictedKeyword(std::string s) {
  return base::ToLowerASCII(base::UTF8ToUTF16(std::move(s)));
}

std::vector<base::string16> TokenizesKeywordsStringToKeywordsVec(
    const std::string& keyword) {
  std::vector<base::string16> tokens;
  std::size_t start = 0, end;
  while ((end = keyword.find(',', start)) != std::string::npos) {
  std::vector<base::string16> tokens;
  std::size_t start = 0, end;
  while ((end = keyword.find(',', start)) != std::string::npos) {
    tokens.push_back(
        NormalizeRestrictedKeyword(keyword.substr(start, end - start)));
    start = end + 1;
  }

  tokens.push_back(NormalizeRestrictedKeyword(keyword.substr(start)));
  return tokens;
}

    
bool IsRemovableTypeFromMatch(AutocompleteMatchType::Type type) {
  return type == AutocompleteMatchType::HISTORY_TITLE ||
         type == AutocompleteMatchType::HISTORY_BODY ||
         type == AutocompleteMatchType::SEARCH_HISTORY ||
         type == AutocompleteMatchType::SEARCH_SUGGEST_TAIL;
}

}  // namespace

void AutocompleteResult::SortAndCullWithKeyword(
    const AutocompleteInput& input,
    TemplateURLService* template_url_service,
    const std::string& keyword) {
  std::vector<base::string16> restricted_keywords =
      TokenizesKeywordsStringToKeywordsVec(keyword);
  const base::string16& input_text = input.text();
 // ラムダ式にOmniboxの入力ボックス内の文字列 input_textも一緒に渡す
  matches_.erase(
      std::remove_if(
          matches_.begin(), matches_.end(),
          [&restricted_keywords, &input_text](const AutocompleteMatch& match) {
            bool removable = IsRemovableTypeFromMatch(match.type);
            if (removable) {
              base::string16 match_text = base::ToLowerASCII(match.contents);
              for (auto& restricted_keyword : restricted_keywords) {
                // Omniboxの入力文字列と一致しないかつ表示したくないキーワードを含む検索候補を除くように書き換える。
                if (input_text != match_text &&
                    match_text.find(restricted_keyword) !=
                        base::string16::npos) {
                  return true;
                }
              }
            }
            return false;
          }),
      matches_.end());

  SortAndCull(input, template_url_service);
}
// 変更ここまで

これでユーザーの設定した複数キーワードに対しての検索履歴のフィルタリング機能の実装が完了しました。 あとはこのキーワードを設定する設定画面の追加及びJavaScriptの世界とC++の世界の間で情報をやり取りするためのHandlerクラスの追加等を行う必要があります。 これは次回以降のブログでまた説明します。

フィルタリング部分の変更まとめ

これまでに行なって変更を一覧にして載せておきます。

  • components/omnibox/browser/autocomplete_result.h
// 変更ここから
  void SortAndCullWithKeyword(const AutocompleteInput& input,
                              TemplateURLService* template_url_service,
                              const std::string& keyword);
// 変更ここまで
  • components/omnibox/browser/autocomplete_result.cpp
// 変更ここから
namespace {
base::string16 NormalizeRestrictedKeyword(std::string s) {
  return base::ToLowerASCII(base::UTF8ToUTF16(std::move(s)));
}

std::vector<base::string16> TokenizesKeywordsStringToKeywordsVec(
    const std::string& keyword) {
  std::vector<base::string16> tokens;
  std::size_t start = 0, end;
  while ((end = keyword.find(',', start)) != std::string::npos) {
  std::vector<base::string16> tokens;
  std::size_t start = 0, end;
  while ((end = keyword.find(',', start)) != std::string::npos) {
    tokens.push_back(
        NormalizeRestrictedKeyword(keyword.substr(start, end - start)));
    start = end + 1;
  }

  tokens.push_back(NormalizeRestrictedKeyword(keyword.substr(start)));
  return tokens;
}

    
bool IsRemovableTypeFromMatch(AutocompleteMatchType::Type type) {
  return type == AutocompleteMatchType::HISTORY_TITLE ||
         type == AutocompleteMatchType::HISTORY_BODY ||
         type == AutocompleteMatchType::SEARCH_HISTORY ||
         type == AutocompleteMatchType::SEARCH_SUGGEST_TAIL;
}

}  // namespace

void AutocompleteResult::SortAndCullWithKeyword(
    const AutocompleteInput& input,
    TemplateURLService* template_url_service,
    const std::string& keyword) {
  std::vector<base::string16> restricted_keywords =
      TokenizesKeywordsStringToKeywordsVec(keyword);
  const base::string16& input_text = input.text();
  matches_.erase(
      std::remove_if(
          matches_.begin(), matches_.end(),
          [&restricted_keywords, &input_text](const AutocompleteMatch& match) {
            bool removable = IsRemovableTypeFromMatch(match.type);
            if (removable) {
              base::string16 match_text = base::ToLowerASCII(match.contents);
              for (auto& restricted_keyword : restricted_keywords) {
                if (input_text != match_text &&
                    match_text.find(restricted_keyword) !=
                        base::string16::npos) {
                  return true;
                }
              }
            }
            return false;
          }),
      matches_.end());

  SortAndCull(input, template_url_service);
}
// 変更ここまで
namespace prefs {
...
// 変更ここから
extern const char kRestrictedKeyword[];
// 変更ここまで
}  // namespace prefs
  • chrome/common/pref_names.cpp
namespace prefs {
...
// 変更ここから
const char kRestrictedKeyword[] = "RestrictedKeyword";
// 変更ここまで
}  // namespace prefs
  • chrome/browser/profiles/profile_impl.cc
void ProfileImpl::RegisterProfilePrefs(
    user_prefs::PrefRegistrySyncable* registry) {
...
 // 変更ここから
  registry->RegisterStringPref(prefs::kRestrictedKeyword, std::string());
// 変更ここまで
}

  1. 頭の悪い人なので、最初ここにデフォルト値を設定しているんだと思っていました……

  2. (上の例で言うポケモンGOは意味的にはpokemongoと同値であっても、文字列としてはユーザーの入力と一致しないためdefault_matchとはなり得ない)