Chromeのストレージ永続化仕様を詳しく追ってみた

# 1. はじめに

ChromeやFirefoxで使えるストレージ永続化機能はご存知でしょうか。  
https://developers.google.com/web/updates/2016/06/persistent-storage に `Persistent Storage` という題で詳しく触れられていますが、通常 LRU で消去されてしまう `IndexedDB` や `CacheStorage` の内容を**永続化**してくれる機能だと理解しています。  

`CacheStorage` と `Persistent Storage` の組み合わせは強力で、理論上大容量のアセットを永続化できるようになることから、PWAでのオフライン戦略や通信量削減戦略に対して強力な武器になると考えています。  

しかし残念ながら、例によって `Safari` はサポートしていません。そのため、`WebKit` の利用を強制される iOS では、例え Chrome を使っていたとしてもこの恩恵を受けることはできません。  

ただし Android では可能なので、Android だけでもユーザー体験を良くしたいという場合には効果があると考えています。  

実際に自分が開発しているサービスでも Android だけ利用可能にしようと開発していましたが、実装・検証していく中で上で紹介した記事には言及されていない挙動をとる場合があることに気づきました。  

具体的には、ストレージ永続化をおこなうためには `window.navigator.storage.persist()` という API をたたいてストレージ永続化用のパーミッションを取得しなければならず、パーミッション取得のために必要な条件がいくつか存在するのですが、その条件がどうにも記事中のものとは異なる気がしたのです。  

そこで今回、検証中に遭遇した謎挙動の理由を探るべく `chromium` のソースを追ったり、デバッグ実行しながら詳細な挙動の確認をおこなったことで理由が掴めた気がしたので、忘れないうちにまとめようと思いました。  

本記事では、 `Persistent Storage` 機能を利用するためにクリアしなければいけない条件を整理するために、 Google 側が記事中で提示している条件を挙げたあと、開発・検証中に遭遇した条件にそぐわない挙動を挙げ、`chromium` ソースコードの該当箇所を示しながらなぜそういった挙動になったのかを解き明かし、最終的により具体的なパーミッション取得条件を示します。

# 2. 公式で挙げられているパーミッション取得のための条件

条件を挙げる前に、この機能がサポートされたのが `Chrome 55` からであるため、それ以前のバージョンではサポート外にしたほうが良いでしょう。  

ですので、まず API を利用できるかどうかの判断として

- `Chrome 55` 以上
- `window.navigator.storage.persist` や `window.navigator.storage.persisted` が存在する

を判定条件とするのが良さそうです。  
これをクリアしたブラウザで、次に示す条件をひとつでもクリアするとストレージ永続化が利用できるようになります。

- The site is bookmarked (and the user has 5 or less bookmarks)
- The site has high site engagement
- The site has been added to home screen
- The site has push notifications enabled

※ https://developers.google.com/web/updates/2016/06/persistent-storage から引用

つまり

- 対象サイトがブックマークされていること ( ただしブックマークの数は **5以下** でなければならない )
- 対象サイトが **high** レベルのエンゲージメントスコアを出していること
- 対象サイトが ホームスクリーン に追加されていること
- 対象サイトがPUSH通知を許可していること

と書かれています。 `site engagement` というのは https://www.chromium.org/developers/design-documents/site-engagement で言及されているのですが、 [chrome://site-engagement]() で確認することのできる各サイトのスコア ( 0 ~ 100 の間 ) です。スコアの計算方法は先に挙げた記事中で書かれていますが、サイト上でタッチ操作やスクロール操作をおこなったり、対象サイトをURL直打ちで開いたりすると上がります。ようは **その人にとってどれだけ時間を割いて利用しているサイトかどうか** というのを定量的に測ろうとした仕組みということで、例えば **Google側がもっている対象サイトにおける絶対評価** とかではないというところがポイントになります。  
つまり人によって超有名サイトであってもスコアが低くなる可能性はあるし、逆に誰も知らないような個人サイトであってもスコアが高くなる可能性はあります。

さて、この時点ですでに疑問が湧きました。 **high** って具体的にいくつだろう...？

# 3. 開発・検証する中で遭遇した謎挙動

## 3.1 high の定義がわからない

パーミッション取得のための条件は、 **先に挙げた条件のどれかひとつでも満たしていれば良い**。と書かれています。つまり、ブックマークしておらず、ホームスクリーンにも追加しておらず、PUSH通知の許可もしていないサイトであれば

- 対象サイトが **high** レベルのエンゲージメントスコアを出していること

を満たさなければならないはずです。スコアは数値であり、 **high** という閾値がいくつなのかわからないにせよ、  
`50` でパーミッションが取得でき、 `30` で取得できず、 `10` で取得できるなんてことにはならないはずです。....ならない...はずなのです。  

ですが実際は上記のようなケースが存在したため、 **high** の定義がなんなのか更にわからなくなりました。

## 3.2 どれかひとつの条件を満たせばいいという話ではなかったのか

いったんエンゲージメントスコアのことは忘れて、今度はブックマークを試してみました。  
もちろんブックマーク数は 5 つ以下におさえた状態であるサイトを登録しました。  
`window.navigator.storage.persist()` をたたくと `true` が返ってきます、成功です。  

つづけて別のサイトをブックマークしてみました ( まだ 5 つ以下です )。  
... `false` が返ってきました。

ブックマークした A というサイトも B というサイトも、他の条件はほぼ同じです。エンゲージメントスコアは共に 3 程度。ブックマーク前の状態で両方のサイトとも `window.navigator.storage.persist()` の結果で `false` が返ってくることも確認していました。  
となると、条件を満たしていたとしてもダメな場合があるとしか想像できません。  
何も信用できなくなってきました。  

同様の現象は、自分では検証していないのですが Android 端末でホームスクリーンに追加した場合にも起きたようです。  
Aという端末ではホームスクリーンに追加したとたん利用可能になったが、Bではホームスクリーンに追加してあるのにダメだったとのことでした。  

# 4. ソースコードから仕様を追う

明らかにドキュメント化されていない仕様がありそうな気配を感じたので、 `chromium` のソースを落としてきてビルド、lldbでデバッグしたり printデバッグなどをおこないました。  

(余談) 自分は macOS で作業したのですが、当初 GitHub 上の mirror からソースを落としてきてビルドしようと試みて途中でビルドエラーで怒られてしまったので、 https://chromium.googlesource.com/chromium/src/+/master/docs/mac_build_instructions.md にならって素直にその通りやるのがよかったです

## 4.1. 該当箇所のソースコードを見つける

今回どうやって `window.navigator.storage.persist()` の実装箇所を見つけたのか、その過程をできるだけ詳細に残しておこうと思います。ソースコードを追っていく流れをそのまま書いているので、過程に興味がない方はこの章をスキップしていただくと、ピンポイントで関係のあるソースコードの箇所からの説明からになります。

まずはじめに、 `window.navigator.storage.persist()` をたたいたときに呼ばれるソースコードを見てみます。これは https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/modules/quota/storage_manager.cc#L82-L104 に書かれていて、中で `RequestPermission` を呼んでいるのがわかります。

ソースコードの読み始めのポイントとしては、 `JavaScript` 側でのAPI呼び出しに対応するC++側のAPIが必ずあるはずなので、その処理を見つけることです。今回であれば `persist` という名前で検索すればある程度しぼりこむことができます。


- storage_manager.cc

```cpp
ScriptPromise StorageManager::persist(ScriptState* script_state) {
  auto* resolver = MakeGarbageCollected<ScriptPromiseResolver>(script_state);
  ScriptPromise promise = resolver->Promise();
  ExecutionContext* execution_context = ExecutionContext::From(script_state);
  DCHECK(execution_context->IsSecureContext());  // [SecureContext] in IDL
  const SecurityOrigin* security_origin =
      execution_context->GetSecurityOrigin();
  if (security_origin->IsOpaque()) {
    resolver->Reject(V8ThrowException::CreateTypeError(
        script_state->GetIsolate(), kUniqueOriginErrorMessage));
    return promise;
  }

  Document* doc = To<Document>(execution_context);
  GetPermissionService(ExecutionContext::From(script_state))
      .RequestPermission(
          CreatePermissionDescriptor(PermissionName::DURABLE_STORAGE),
          LocalFrame::HasTransientUserActivation(doc->GetFrame()),
          WTF::Bind(&StorageManager::PermissionRequestComplete,
                    WrapPersistent(this), WrapPersistent(resolver)));

  return promise;
}
```

ここでひとつ重要なことがわかりました。 `chromium` の中で `Persistent Storage` のパーミッションを扱う際のキーワードが `DURABLE_STORAGE` だということです。

`RequestPermission` の実装は https://github.com/chromium/chromium/blob/master/content/browser/permissions/permission_service_impl.cc#L162-L171 にあり

読み進めていくと
https://github.com/chromium/chromium/blob/master/content/browser/permissions/permission_service_impl.cc#L215-L222 のあたりで

- permission_service_impl.cc

```cpp
int id =
      PermissionControllerImpl::FromBrowserContext(browser_context)
          ->RequestPermissions(
              types, context_->render_frame_host(), origin_.GetURL(),
              user_gesture,
              base::Bind(&PermissionServiceImpl::OnRequestPermissionsResponse,
                         weak_factory_.GetWeakPtr(), pending_request_id));
```

に気づきます。ここで `BrowserContext` なるものを使って `PermissionController` 経由で `RequestPermissions` を呼び出しています。 `PermissionController::RequestPermissions` の処理は https://github.com/chromium/chromium/blob/master/content/browser/permissions/permission_controller_impl.cc#L199-L229  に書かれていて、 `PermissionControllerDelegate` のインスタンスを `BrowserContext` の `GetPermissionControllerDelegate()` 経由で取得し、`delegate` の `RequestPermissions` を呼んでいます。

- permission_controller_impl.cc

```cpp
int PermissionControllerImpl::RequestPermissions(
    const std::vector<PermissionType>& permissions,
    RenderFrameHost* render_frame_host,
    const GURL& requesting_origin,
    bool user_gesture,
    const base::Callback<
        void(const std::vector<blink::mojom::PermissionStatus>&)>& callback) {
  for (PermissionType permission : permissions)
    NotifySchedulerAboutPermissionRequest(render_frame_host, permission);

  auto it = devtools_permission_overrides_.find(requesting_origin.GetOrigin());
  if (it != devtools_permission_overrides_.end()) {
    std::vector<blink::mojom::PermissionStatus> result;
    for (auto& permission : permissions)
      result.push_back(GetPermissionOverrideStatus(it->second, permission));
    callback.Run(result);
    return kNoPendingOperation;
  }

  PermissionControllerDelegate* delegate =
      browser_context_->GetPermissionControllerDelegate();
  if (!delegate) {
    std::vector<blink::mojom::PermissionStatus> result(
        permissions.size(), blink::mojom::PermissionStatus::DENIED);
    callback.Run(result);
    return kNoPendingOperation;
  }
  return delegate->RequestPermissions(permissions, render_frame_host,
                                      requesting_origin, user_gesture,
                                      callback);
}
```

この `GetPermissionControllerDelegate` は `BrowserContext` では pure virtual として宣言されており、継承先で実装するようになっています。継承先はブラウザの種類によって多岐にわたり、 chromecast や headless browser などいくつかの実装があるようです。  

今回見たいのはおそらく https://github.com/chromium/chromium/blob/master/chrome/browser/profiles/profile_impl.cc#L1215-L1217 で実装されているところで、中で `PermissionManagerFactory` をつかっているのがわかります。

- profile_impl.cc

```cpp
content::PermissionControllerDelegate*
ProfileImpl::GetPermissionControllerDelegate() {
  return PermissionManagerFactory::GetForProfile(this);
}
```

`PermissionManagerFactory::GetForProfile` の実装は https://github.com/chromium/chromium/blob/master/chrome/browser/permissions/permission_manager_factory.cc#L14-L18 に書かれており、 `GetServiceForBrowserContext` を通して `PermissionManager` インスタンスを作っていることがわかります。 つまり `PermissionControllerDelegate` の正体は `PermissionManager` クラスで、結局のところこのクラスの `RequestPermissions` を呼んでいることがわかりました。  

- permission_manager_factory.cc

```cpp
PermissionManager*
PermissionManagerFactory::GetForProfile(Profile* profile) {
  return static_cast<PermissionManager*>(
      GetInstance()->GetServiceForBrowserContext(profile, true));
}
```

`PermissionManager::RequestPermissions` の実装は https://github.com/chromium/chromium/blob/master/chrome/browser/permissions/permission_manager.cc#L393-L445 に書かれており、読み進めると


- permission_manager.cc

```cpp
 PermissionContextBase* context = GetPermissionContext(permission);
    DCHECK(context);

    context->RequestPermission(
        web_contents, request, canonical_requesting_origin, user_gesture,
        base::BindOnce(
            &PermissionResponseCallback::OnPermissionsRequestResponseStatus,
            std::move(response_callback)));
```

で `GetPermissionContext` を `permission` を引数に ( ここでは `DURABLE_STORAGE` ) 呼んでいるのがわかります。 `GetPermissionContext` の実装は

```cpp
PermissionContextBase* PermissionManager::GetPermissionContext(
    ContentSettingsType type) {
  const auto& it = permission_contexts_.find(type);
  return it == permission_contexts_.end() ? nullptr : it->second.get();
}
```

のようになっていて、 `ContentSettingsType` が `CONTENT_SETTINGS_TYPE_DURABLE_STORAGE` で検索していることがわかります。ではこの `permission_contexts_` がどうやって初期化されているかというと、 https://github.com/chromium/chromium/blob/master/chrome/browser/permissions/permission_manager.cc#L280-L334 に初期化処理があり

```cpp
PermissionManager::PermissionManager(Profile* profile) : profile_(profile) {
  ... (略) ...
  permission_contexts_[CONTENT_SETTINGS_TYPE_DURABLE_STORAGE] =
      std::make_unique<DurableStoragePermissionContext>(profile);
  ... (略) ...
}
```

正体が `DurableStoragePermissionContext` だとわかりました！
では この実装を読みにいきます。

## 4.2 該当箇所のソースを読む

`window.navigator.storage.persist()` を呼んだ時にめぐりめぐって呼ばれるのは `DurableStoragePermissionContext::DecidePermission` という部分です。

https://github.com/chromium/chromium/blob/master/chrome/browser/storage/durable_storage_permission_context.cc#L35-L99

- durable_storage_permission_context.cc

```cpp
void DurableStoragePermissionContext::DecidePermission(
    content::WebContents* web_contents,
    const PermissionRequestID& id,
    const GURL& requesting_origin,
    const GURL& embedding_origin,
    bool user_gesture,
    BrowserPermissionCallback callback) {
  DCHECK(content::BrowserThread::CurrentlyOn(content::BrowserThread::UI));
  DCHECK_NE(CONTENT_SETTING_ALLOW,
            GetPermissionStatus(nullptr /* render_frame_host */,
                                requesting_origin, embedding_origin)
                .content_setting);
  DCHECK_NE(CONTENT_SETTING_BLOCK,
            GetPermissionStatus(nullptr /* render_frame_host */,
                                requesting_origin, embedding_origin)
                .content_setting);

  // Durable is only allowed to be granted to the top-level origin. Embedding
  // origin is the last committed navigation origin to the web contents.
  if (requesting_origin != embedding_origin) {
    NotifyPermissionSet(id, requesting_origin, embedding_origin,
                        std::move(callback), false /* persist */,
                        CONTENT_SETTING_DEFAULT);
    return;
  }

  scoped_refptr<content_settings::CookieSettings> cookie_settings =
      CookieSettingsFactory::GetForProfile(profile());

  // Don't grant durable for session-only storage, since it won't be persisted
  // anyway. Don't grant durable if we can't write cookies.
  if (cookie_settings->IsCookieSessionOnly(requesting_origin) ||
      !cookie_settings->IsCookieAccessAllowed(requesting_origin,
                                              requesting_origin)) {
    NotifyPermissionSet(id, requesting_origin, embedding_origin,
                        std::move(callback), false /* persist */,
                        CONTENT_SETTING_DEFAULT);
    return;
  }

  const size_t kMaxImportantResults = 10;
  std::vector<ImportantSitesUtil::ImportantDomainInfo> important_sites =
      ImportantSitesUtil::GetImportantRegisterableDomains(profile(),
                                                          kMaxImportantResults);

  std::string registerable_domain =
      net::registry_controlled_domains::GetDomainAndRegistry(
          requesting_origin,
          net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES);
  if (registerable_domain.empty() && requesting_origin.HostIsIPAddress())
    registerable_domain = requesting_origin.host();

  for (const auto& important_site : important_sites) {
    if (important_site.registerable_domain == registerable_domain) {
      NotifyPermissionSet(id, requesting_origin, embedding_origin,
                          std::move(callback), true /* persist */,
                          CONTENT_SETTING_ALLOW);
      return;
    }
  }

  NotifyPermissionSet(id, requesting_origin, embedding_origin,
                      std::move(callback), false /* persist */,
                      CONTENT_SETTING_DEFAULT);
}
```

このうち、 `persist()` の結果で `true` を返してくれるのは

```cpp
const size_t kMaxImportantResults = 10;
  std::vector<ImportantSitesUtil::ImportantDomainInfo> important_sites =
      ImportantSitesUtil::GetImportantRegisterableDomains(profile(),
                                                          kMaxImportantResults);

  std::string registerable_domain =
      net::registry_controlled_domains::GetDomainAndRegistry(
          requesting_origin,
          net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES);
  if (registerable_domain.empty() && requesting_origin.HostIsIPAddress())
    registerable_domain = requesting_origin.host();

  for (const auto& important_site : important_sites) {
    if (important_site.registerable_domain == registerable_domain) {
      NotifyPermissionSet(id, requesting_origin, embedding_origin,
                          std::move(callback), true /* persist */,
                          CONTENT_SETTING_ALLOW);
      return;
    }
  }
  ```

  の部分で、 `ImportantSitesUtil::GetImportantRegisterableDomains` で返ってきた結果の中に、対象サイトから取得した `registerable_domain` が存在していれば `true` が返っているとわかります。ここで非常に重要なことは、 `ImportantSitesUtil::GetImportantRegisterableDomains` の結果が **最大10件** だということ。もうひとつは、 **`registerable_domain` の決定方法** です。

`registerable_domain` は、ドメイン名によるでしょうが第2・第3レベルまでのドメイン名になるようです。例えば、 `https://mail.google.com` であれば `google.com` が `registerable_domain` になり、 `https://www.google.co.jp` の場合は `google.co.jp` になります。このルールが先に挙げた謎の挙動のヒントになってきます。

つづいて、`ImportantSitesUtil::GetImportantRegisterableDomains` を見にいきます。
実装は https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/important_sites_util.cc#L383-L424 に書かれています

- important_sites_util.cc

```cpp
std::vector<ImportantDomainInfo>
ImportantSitesUtil::GetImportantRegisterableDomains(Profile* profile,
                                                    size_t max_results) {
  std::map<std::string, ImportantDomainInfo> important_info;
  std::map<GURL, double> engagement_map;

  PopulateInfoMapWithEngagement(profile, blink::mojom::EngagementLevel::MEDIUM,
                                &engagement_map, &important_info);

  PopulateInfoMapWithContentTypeAllowed(
      profile, CONTENT_SETTINGS_TYPE_NOTIFICATIONS,
      ImportantReason::NOTIFICATIONS, &important_info);

  PopulateInfoMapWithContentTypeAllowed(
      profile, CONTENT_SETTINGS_TYPE_DURABLE_STORAGE, ImportantReason::DURABLE,
      &important_info);

  PopulateInfoMapWithBookmarks(profile, engagement_map, &important_info);

  std::unordered_set<std::string> blacklisted_domains =
      GetBlacklistedImportantDomains(profile);

  std::vector<std::pair<std::string, ImportantDomainInfo>> items(
      important_info.begin(), important_info.end());
  std::sort(items.begin(), items.end(), &CompareDescendingImportantInfo);

  std::vector<ImportantDomainInfo> final_list;
  for (std::pair<std::string, ImportantDomainInfo>& domain_info : items) {
    if (final_list.size() >= max_results)
      return final_list;
    if (blacklisted_domains.find(domain_info.first) !=
        blacklisted_domains.end()) {
      continue;
    }
    final_list.push_back(domain_info.second);
    RECORD_UMA_FOR_IMPORTANT_REASON(
        "Storage.ImportantSites.GeneratedReason",
        "Storage.ImportantSites.GeneratedReasonCount",
        domain_info.second.reason_bitfield);
  }

  return final_list;
}
```

ここではじめに紹介したパーミッション取得のための条件を思い出します。

- 対象サイトがブックマークされていること ( ただしブックマークの数は **5以下** でなければならない )
- 対象サイトが **high** レベルのエンゲージメントスコアを出していること
- 対象サイトが ホームスクリーン に追加されていること
- 対象サイトがPUSH通知のパーミッションを許可していること

上記の条件がまさにコード中に書かれているのが分かります。
ざっと処理の流れを説明すると

```cpp
std::map<std::string, ImportantDomainInfo> important_info;
```

に対してひとつずつ条件を照らし合わせながら該当するものを追加していき、  
`CompareDescendingImportantInfo` のルールにのっとってこれを sort した後、blacklist にのっているドメインを除きつつ、最大10件になるように `final_list` を構築して返却しています。

ここで先ほど **最大10件** というのが重要だと説明しましたが、 `important_info` に10件より多くの `registerable_domain` が入っていたとしても、  
sortの結果によって11件目以降になると、 `final_list` の中には現れないということがわかります。  
つまり、具体的にどういった条件で `important_info` に追加されていくのかという情報と同じかそれ以上に、 sort の条件が気になります。

`CompareDescendingImportantInfo` の実装はどうなっているでしょうか。

https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/important_sites_util.cc#L192-L203 に書かれており

```cpp
bool CompareDescendingImportantInfo(
    const std::pair<std::string, ImportantDomainInfo>& a,
    const std::pair<std::string, ImportantDomainInfo>& b) {
  int score_a = GetScoreForReasonsBitfield(a.second.reason_bitfield);
  int score_b = GetScoreForReasonsBitfield(b.second.reason_bitfield);
  int bitfield_diff = score_a - score_b;
  if (bitfield_diff != 0)
    return bitfield_diff > 0;
  return a.second.engagement_score > b.second.engagement_score;
}
```

`ImportantDomainInfo` に含まれる `reason_bitfield` の値によって得たスコアどうしを比較しており、これが大きい方が優先されるとわかります。
もし `reason_bitfield` が同じ値の場合は、純粋な `engagement_score` ( [chrome://site-engagement]() で見れる値 ) の大きい方が優先されるようです。　

`reason_bitfield` の値は、複数の `reason` に分解した後、ひとつずつ以下の `GetScoreForReason` を通してスコアに変換して足し合わせています。

```cpp
int GetScoreForReason(ImportantReason reason) {
  switch (reason) {
    case ImportantReason::ENGAGEMENT:
      return 1 << 0;
    case ImportantReason::DURABLE:
      return 1 << 1;
    case ImportantReason::BOOKMARKS:
      return 1 << 2;
    case ImportantReason::HOME_SCREEN:
      return 1 << 3;
    case ImportantReason::NOTIFICATIONS:
      return 1 << 4;
    case ImportantReason::REASON_BOUNDARY:
      return 0;
  }
  return 0;
}
```

たとえば、 `ImportantReason::ENGAGEMENT` と `ImportantReason::DURABLE` を同時に満たす`reason_bitfield` のスコアは `3` になります。  

ここからわかることは、各条件にはそれぞれ優先度が存在し、例えばブックマークに追加することよりもホームスクリーンに追加したりPUSH通知を許可するほうが優先的に選ばれることになるとわかります。  

更に、条件は重ねて適応することができ、例えば同じブックマークに追加したサイトであっても `ImportantReason::ENGAGEMENT` を満たしているかでスコアが `1` 点 変わることがわかります。  

ここまででだいぶ理解は進みましたが、最後に各条件がより具体的にどういったことを求めているかを見ていきたいと思います ( engagement score の **high** レベルの定義も気になります )。

### 4.2.1 high site engagement の意味を知る

`PopulateInfoMapWithEngagement` の中を見ます (
https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/important_sites_util.cc#L236-L270 )

```cpp
void PopulateInfoMapWithEngagement(
    Profile* profile,
    blink::mojom::EngagementLevel minimum_engagement,
    std::map<GURL, double>* engagement_map,
    std::map<std::string, ImportantDomainInfo>* output) {
  SiteEngagementService* service = SiteEngagementService::Get(profile);
  std::vector<mojom::SiteEngagementDetails> engagement_details =
      service->GetAllDetails();
  std::set<GURL> content_origins;

  // We can have multiple origins for a single domain, so we record the one
  // with the highest engagement score.
  for (const auto& detail : engagement_details) {
    if (detail.installed_bonus > 0) {
      // This origin was recently launched from the home screen.
      MaybePopulateImportantInfoForReason(detail.origin, &content_origins,
                                          ImportantReason::HOME_SCREEN, output);
    }

    (*engagement_map)[detail.origin] = detail.total_score;

    if (!service->IsEngagementAtLeast(detail.origin, minimum_engagement))
      continue;

    std::string registerable_domain =
        ImportantSitesUtil::GetRegisterableDomainOrIP(detail.origin);
    ImportantDomainInfo& info = (*output)[registerable_domain];
    if (detail.total_score > info.engagement_score) {
      info.registerable_domain = registerable_domain;
      info.engagement_score = detail.total_score;
      info.example_origin = detail.origin;
      info.reason_bitfield |= 1 << ImportantReason::ENGAGEMENT;
    }
  }
}
```

`engagement_details` というのは、 [chrome://site-engagement]() で見れるリストが返ってきていると思ってもらっても大丈夫です。このリストを traverse しながら、 `installed_bonus` の値が 0 より大きければ ホームスクリーン追加用の `reason_bitfield` ( `ImportantReason::HOME_SCREEN` ) を追加しつつ、 `minimum_engagement` 以上のスコアであるもののみに `reason_bitfield` ( `ImportantReason::ENGAGEMENT` ) を追加します。  

ここで `high` の具体的な条件がわかりました。 `minimum_engagement` です。これは引数で与えられたものだったので、呼び出し元に戻ると `blink::mojom::EngagementLevel::MEDIUM` を渡していることがわかります。この上で `IsEngagementAtLeast` の実装を読んでみます。  

https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/site_engagement_service.cc#L248-L270 に書かれており、 `blink::mojom::EngagementLevel::MEDIUM` に相当する条件が `score >= SiteEngagementScore::GetMediumEngagementBoundary()` だとわかります。

- site_engagement_service.cc

```cpp
bool SiteEngagementService::IsEngagementAtLeast(
    const GURL& url,
    blink::mojom::EngagementLevel level) const {
  DCHECK_LT(SiteEngagementScore::GetMediumEngagementBoundary(),
            SiteEngagementScore::GetHighEngagementBoundary());
  double score = GetScore(url);
  switch (level) {
    case blink::mojom::EngagementLevel::NONE:
      return true;
    case blink::mojom::EngagementLevel::MINIMAL:
      return score > 0;
    case blink::mojom::EngagementLevel::LOW:
      return score >= 1;
    case blink::mojom::EngagementLevel::MEDIUM:
      return score >= SiteEngagementScore::GetMediumEngagementBoundary();
    case blink::mojom::EngagementLevel::HIGH:
      return score >= SiteEngagementScore::GetHighEngagementBoundary();
    case blink::mojom::EngagementLevel::MAX:
      return score == SiteEngagementScore::kMaxPoints;
  }
  NOTREACHED();
  return false;
}
```
そこで `SiteEngagementScore::GetMediumEngagementBoundary()` を読みにいくと 
https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/site_engagement_score.cc#L146-L148 に

- site_engagement_score.cc

```cpp
double SiteEngagementScore::GetMediumEngagementBoundary() {
  return GetParamValues()[MEDIUM_ENGAGEMENT_BOUNDARY].second;
}
```
と書かれており、
https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/site_engagement_score.cc#L85-L86

から `15` 点が閾値だとわかりました！

ここで先ほど軽く説明して終わったホームスクリーンへの追加についても深掘りしたいと思います。

### 4.2.2 「ホームスクリーンに追加」の具体的な条件を知る

4.2.1 の `PopulateInfoMapWithEngagement` の実装の説明の中で、 `installed_bonus > 0` であれば `ImportantReason::HOME_SCREEN` が `reason_bitfield` に足されると説明しましたが、では具体的に `installed_bonus` が 0 より大きくなるためにはどうなればいいのでしょうか。

これを知るためには

```cpp
 std::vector<mojom::SiteEngagementDetails> engagement_details =
      service->GetAllDetails();
```
の中を知る必要があります。
この処理は最終的にサイトごとに `SiteEngagementScore::GetDetails()` が呼ばれるのですが、 https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/site_engagement_score.cc#L265-L276 のあたりを見ると、どうやって `mojom::SiteEngagementDetails` ができているのかがわかります

- site_engagement_score.cc

```cpp
double SiteEngagementScore::GetTotalScore() const {
  return std::min(DecayedScore() + BonusIfShortcutLaunched(), kMaxPoints);
}

mojom::SiteEngagementDetails SiteEngagementScore::GetDetails() const {
  mojom::SiteEngagementDetails engagement;
  engagement.origin = origin_;
  engagement.base_score = DecayedScore();
  engagement.installed_bonus = BonusIfShortcutLaunched();
  engagement.total_score = GetTotalScore();
  return engagement;
}
```

ここで注目したいのは `BonusIfShortcutLaunched()` です。この結果が `installed_bonus` の値にもなるし、 `total_score` にも反映されます。

```cpp

double SiteEngagementScore::BonusIfShortcutLaunched() const {
  int days_since_shortcut_launch =
      (clock_->Now() - last_shortcut_launch_time_).InDays();
  if (days_since_shortcut_launch <= kMaxDaysSinceShortcutLaunch)
    return GetWebAppInstalledPoints();
  return 0;
}
```

実装は上記のようになっていて、`kMaxDaysSinceShortcutLaunch` は https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/site_engagement_score.cc#L34 に書かれていますが `10` です。

つまり最後にホームスクリーン上に追加したアプリから起動したのが10日以内であれば `GetWebAppInstalledPoints()` ( ちなみに `5` 点です https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/site_engagement_score.cc#L81 ) を付与するよということを表しています。

ここから、例えホームスクリーンに追加していたとしても、10日以上起動していなければインストールボーナスはつかない。つまり `reason_bitfield` に `ImportantReason::HOME_SCREEN` は追加しないということがわかります。

ここまででもう一度 `ImportantSitesUtil::GetImportantRegisterableDomains` の実装に戻りましょう。 4.2.1 と 4.2.2 では `PopulateInfoMapWithEngagement` の中を具体的にみていきました。  
そのあとに書かれている

```cpp
PopulateInfoMapWithContentTypeAllowed(
      profile, CONTENT_SETTINGS_TYPE_NOTIFICATIONS,
      ImportantReason::NOTIFICATIONS, &important_info);
```

の部分は読んでそのままで、 PUSH通知をすでに許可している場合は、 `ImportantReason::NOTIFICATIONS` が `reason_bitfield` に加えられます。

続けて書いてある

```cpp
PopulateInfoMapWithContentTypeAllowed(
      profile, CONTENT_SETTINGS_TYPE_DURABLE_STORAGE, ImportantReason::DURABLE,
      &important_info);
```

は、 **すでにDURABLE_STORAGEの条件をみたしている場合** は `ImportantReason::DURABLE` を `reason_bitfield` に加えるという意味になります。つまり、一度でも `window.navigatgor.storage.persist()` でストレージ永続化できていた場合はここで必ず `reason_bitfield` に 2点 加算されます。これも `registerable_domain` の優先度を決める上で非常に重要になってきます。

こうなると残りは 

```cpp
PopulateInfoMapWithBookmarks(profile, engagement_map, &important_info);
```

だけなので、次でブックマークに関する具体的な条件をみていきます

### 4.2.3 「ブックマークに追加」の具体的な条件を知る

実装は https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/important_sites_util.cc#L307-L352 に書かれており、ブックマークの数が `kMaxBookmarks` を超えているかどうかで処理が異なることがわかります。  

`kMaxBookmarks` は https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/important_sites_util.cc#L61 に書かれていますが `5` です。  
つまり、条件になっていた「ブックマーク数が5以下だったら」の数ですね。

```cpp
void PopulateInfoMapWithBookmarks(
    Profile* profile,
    const std::map<GURL, double>& engagement_map,
    std::map<std::string, ImportantDomainInfo>* output) {
  SiteEngagementService* service = SiteEngagementService::Get(profile);
  BookmarkModel* model =
      BookmarkModelFactory::GetForBrowserContextIfExists(profile);
  if (!model)
    return;
  std::vector<UrlAndTitle> untrimmed_bookmarks;
  model->GetBookmarks(&untrimmed_bookmarks);

  // Process the bookmarks and optionally trim them if we have too many.
  std::vector<UrlAndTitle> result_bookmarks;
  if (untrimmed_bookmarks.size() > kMaxBookmarks) {
    std::copy_if(untrimmed_bookmarks.begin(), untrimmed_bookmarks.end(),
                 std::back_inserter(result_bookmarks),
                 [service](const UrlAndTitle& entry) {
                   return service->IsEngagementAtLeast(
                       entry.url.GetOrigin(),
                       blink::mojom::EngagementLevel::LOW);
                 });
    // TODO(dmurph): Simplify this (and probably much more) once
    // SiteEngagementService::GetAllDetails lands (crbug/703848), as that will
    // allow us to remove most of these lookups and merging of signals.
    std::sort(
        result_bookmarks.begin(), result_bookmarks.end(),
        [&engagement_map](const UrlAndTitle& a, const UrlAndTitle& b) {
          auto a_it = engagement_map.find(a.url.GetOrigin());
          auto b_it = engagement_map.find(b.url.GetOrigin());
          double a_score = a_it == engagement_map.end() ? 0 : a_it->second;
          double b_score = b_it == engagement_map.end() ? 0 : b_it->second;
          return a_score > b_score;
        });
    if (result_bookmarks.size() > kMaxBookmarks)
      result_bookmarks.resize(kMaxBookmarks);
  } else {
    result_bookmarks = std::move(untrimmed_bookmarks);
  }

  std::set<GURL> content_origins;
  for (const UrlAndTitle& bookmark : result_bookmarks) {
    MaybePopulateImportantInfoForReason(bookmark.url, &content_origins,
                                        ImportantReason::BOOKMARKS, output);
  }
}
```

ブックマーク数が5以下の場合は、 `result_bookmarks` にそのままそれらが入り、 `ImportantReason::BOOKMARKS` が `reason_bitfield` に追加されます。  
**そうでない場合は** 全ブックマークの中から `blink::mojom::EngagementLevel::LOW` ( https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/site_engagement_service.cc#L259-L260 から `1` ) 以上のスコアのものを集めたあと、 `engagement_score` の値によって sort し、上から 5件 を `result_bookmarks` に追加するという挙動になるようです。  

つまり、5件以下であればそのまま `ImportantReason::BOOKMARKS` が追加され、そうでなかったとしても、ブックマークしたサイトのスコアが `1` 以上であり、かつスコアの大きい順の上位5つ以内に入っていれば同様に `ImportantReason::BOOKMARKS` が `reason_bitfield` に足されるとわかります。

# 5. 謎だった挙動を解明する

4章でソースコードを追いながら詳細な仕様を把握できました。  
このことから不可解だった次の挙動にも説明ができるようになります。

- スコアが `50` でパーミッションが取得でき、 `30` で取得できず、 `10` で取得できてしまう
- ブックマークした A というサイトと B というサイトがあるが A しかパーミッションを取得できない
- ホームスクリーンに追加してもパーミッションを取得できない

まず条件をあらためて整理します。  
もともとの条件はこうでした。

- 対象サイトがブックマークされていること ( ただしブックマークの数は **5以下** でなければならない )
- 対象サイトが **high** レベルのエンゲージメントスコアを出していること
- 対象サイトが ホームスクリーン に追加されていること
- 対象サイトがPUSH通知のパーミッションを許可していること

その上、調べていくと次のことがわかりました

- パーミッションを許可できるのは上位10件のサイトまで
- 別のサイトであっても `registerable_domain` は被る可能性がある
- エンゲージメントスコアだけを条件にする場合は15点以上必要
- ホームスクリーンに追加することを条件にするなら、最後に起動してから10日以内でなければならない
- すでにパーミッションを与えているサイトには `reason_bitfield` に `2` 点が加わる
- ブックマークが5件を超える場合は、スコアが1点以上のサイトを対象に上位5件に対して `reason_bitfield` に `4` 点を加える

これをふまえると

- スコアが `50` でパーミッションが取得でき、 `30` で取得できず、 `10` で取得できてしまう

この場合は、 `50` でパーミションがとれたのはスコアが `15` 点以上だったからなのは良いとして、 `10` でも取得できたのはドメイン名が `50` のものと同じだった可能性が高いです( 実際の自分の場合は `https://mail.google.com` が上位にきていて、 `10` のは別の `google.com` ドメインのサイトでした )。逆になぜ `30` でもパーミッションがとれなかったのかは、上位 10件 に漏れてしまっていたからだと考えられます。

罠だったのは、検証のために複数のサイトで `window.navigator.storage.persist()` を実行してまわっていったため、一度許可された場合に `DURABLE_STORAGE` の条件が満たされ、エンゲージメントスコアが 15点以上のサイトは常に `reason_bitfield` が `3` になるという挙動をとる点でした ( `ImportantReason::ENGAGEMENT` が 1点、 `ImportantReason::DURABLE_STORAGE` が 2点 )。この条件を満たしたものが 10 件を超えていると、新しいサイトでパーミッションを取得しようとした場合に、仮に 15点以上だったとしても、 `reason_bitfield` の値が `ImportantReason::ENGAGEMENT` だけのため ( `1` 点 )、永遠にパーミッションを許可できない状態になっていました。

同様に

- ブックマークした A というサイトと B というサイトがあるが A しかパーミッションを取得できない

この件でも、すでに対象のサイトが10件を超えている場合に問題になります。  
もしブックマークの条件が満たされていたとしても、サイトのスコアが `15` 点未満の場合は `ImportantReason::BOOKMARKS` ( `4` 点 ) しか適応されていません。  
このとき、他にホームスクリーンに追加したアプリが複数あったり、ブックマークとスコア15以上を同時に満たすサイトが10件以上ある状況だと、例えブックマークの登録が5件以内だったとしてもパーミッションを取得できない事態になります。

- ホームスクリーンに追加してもパーミッションを取得できない

この場合は、ホームスクリーンへ追加することの優先度がとても高いことから、他のサイトに優先順位で負けることがあまり考えられないため、アプリを最後に起動したのが10日以上前である可能性が高いです。  


# 6. まとめ

`Persistent Storage` のパーミッションを取得するための条件は

- 対象サイトがブックマークされていること ( ただしブックマークの数は **5以下** でなければならない )
- 対象サイトが **high** レベルのエンゲージメントスコアを出していること
- 対象サイトが ホームスクリーン に追加されていること
- 対象サイトがPUSH通知のパーミッションを許可していること

だけでなく、ソースコードを追うことでさらに細かな条件があることがわかりました。

具体的には

- パーミッションを許可できるのは上位 `10` 件の `registerable_domain` に合致するサイトだけ
- 別のサイトであっても `registerable_domain` は被る可能性がある ( `a.google.com` と `b.google.com` はどちらも `google.com` が `registerable_domain` )
- エンゲージメントスコアだけを条件にする場合は `15` 点以上必要
- ホームスクリーンに追加することを条件にするなら、最後に起動してから `10` 日以内でなければならない
- ブックマークが5件を超える場合は、エンゲージメントスコアが1点以上のサイトを対象に上位5件が加点対象になる
- エンゲージメントスコアの条件を満たすと `reason_bitfield` に `1` 点が加わる
- すでにパーミッションを与えているサイトには `reason_bitfield` に `2` 点が加わる
- ブックマークに追加しているサイトには`reason_bitfield` に `4` 点を加える
- ホームスクリーンに追加されているサイトには `reason_bitfield` に `8` 点が加わる
- PUSH通知を許可しているサイトには `reason_bitfield` に `16` 点が加わる
- `reason_bitfield` の合計が大きい順に10件を抽出する。 `reason_bitfield` が同じ場合はエンゲージメントスコアが高い方を優先する

のような条件が裏にあり決められています。

これをふまえると、なかなかサービスの機能としてストレージ永続化を提供するのは難しいなと感じましたが、詳細な条件を知れたことで大分スッキリしましたし、対策も考えやすそうです。

もしすでに謎挙動を観測された経験がある方がいらっしゃれば、
この記事の結論と相違ないかコメントをいただければ幸いです！

# 7. FAQ ( 追記 )

記事中にわかりにくいなという箇所がいくつかあったので、疑問になりそうなところをあらかじめ解説しておこうと思います。

## 7.1 異なるサイトで `registerable_domain` が一緒だった場合の挙動をもう一度

例えば `a.google.com` と `b.google.com` があって、それぞれエンゲージメントスコアが `20` と `1` だったとします。 
条件から考えると `a.google.com` はエンゲージメントスコアが `15` より大きいためパーミッションを取得できますが、 `b.google.com` では一見ダメそうです。しかし、これらは `registerable_domain` が同じ `google.com` なので、 `google.com` が 許可すべきドメイン 10 件の中に入っていさえすればパーミッションは取得できます

## 7.2 得点の説明が `reason_bitfield` と エンゲージメントスコアと 2種類あってわかりにくい

エンゲージメントスコアは chrome://site-engagement でみれる 0 ~ 100 の間の点数です。サイトによって異なります。  
この値は、 最大10件の `registerable_domain` のリストを作るときに利用されます。  
また、 `reason_bitfield` の値も同様にリストを作るときに利用する値ですが、基本的には `reason_bitfield` の値で 降順に並べ、同じ `reason_bitfield` のサイトどうしを比較する際にはじめてエンゲージメントスコアを参照する流れです。  

エンゲージメントスコアは `15` 点以上であるかが基準になっており、 `15` 以上であれば `reason_bitfield` に 1点加算されます。

## 7.3 一度許可したサイトのパーミッションは消せるのか

ブラウザの再起動等では消えないので、別な削除手段をとる必要がありそうです。一度許可してしまうと、常に `reason_bitfield` に 2点 加算されます。