DEVELOPER / 管理者向け技術資料

接続先が満たす要件

同期プロトコル2 · 設定形式v1 · 更新日 2026-10-31

1. 通信先は役割ごとに分かれる

接続先用途・固定条件
Google Drive API v3本・フォルダーの一覧、メタデータ、書籍本体の取得。アプリから直接通信。
oauth2.googleapis.com/tokenサービスアカウント署名によるDriveアクセストークンと、同期入口用Google IDトークン。
設定したendpointPOST /challenge と POST /token。HTTPS originのみ、末尾 /・パス・query・fragment・userinfoなし。audienceはendpointと完全一致。
identitytoolkit.googleapis.comカスタムトークンをFirebase IDトークンへ交換し、UIDを確認。
firestore.googleapis.com指定projectIdの(default)データベースへ読書状態を直接読み書き。

書籍本体は独自認証サービスを経由しません。Driveへの権限、認証サービス入口への権限、読書状態への権限を別々に管理します。

2. 書籍はGoogle Driveから直接取得する

アプリは、サービスアカウントのRS256署名JWTをGoogleの固定token endpointへ送り、https://www.googleapis.com/auth/drive.readonlyのトークンを取得します。対象は、読者に共有されたファイルだけです。サービスアカウントにDrive全体の権限や、組織全体の委任を与える必要はありません。

API v3のfiles.list/getと alt=mediaを使います。主なフィールドは id,name,mimeType,size,modifiedTime,md5Checksum,parents,shortcutDetails(targetId,targetMimeType,targetResourceKey) です。一覧はpageSize=1000、orderBy=folder,nameとし、nextPageTokenをたどります。supportsAllDrives=true と includeItemsFromAllDrives=true を付け、incompleteSearch=trueはエラーとして扱います。同じページトークンが繰り返された場合も処理を中止します。

共有フォルダーの入口は sharedWithMe = true and trashed = false で探します。フォルダー内は 'FOLDER_ID' in parents and trashed = false で取得し、複数フォルダーを一度に取得する場合は最大40個のparent条件をORでまとめます。ファイルIDは空でなく、ASCII英数字、-、_だけで256文字以内です。

対応形式はPDF、ZIP・CBZ、RAR・CBR、EPUBです。選択範囲内の本を端末へ保存し、親フォルダーの指定と、子フォルダー・ファイルの除外を組み合わせられます。対象の追加は差分だけを処理し、変更のないフォルダーは再走査しません。既存対象内の新刊・更新・削除は、明示的な「再同期」で確認します。対象解除や正常に完了した走査で削除を確認した本は、管理対象の本棚から外します。不完全な一覧、通信失敗、権限失効を本の削除と解釈せず、保存済みコピーを保持します。ダウンロード後は、Driveがsizeやmd5Checksumを返した場合に一致を確認します。読書状態の識別子は次節のSHA-256であり、DriveのMD5やfile IDとは用途が異なります。

Driveショートカットの参照先を扱う

一覧には application/vnd.google-apps.shortcut を含め、shortcutDetailsで参照先のID・MIME・resource keyを受け取ります。一覧表示だけでは参照先を一件ずつ取得しません。確定した取得対象の参照先を重複排除し、https://www.googleapis.com/batch/drive/v3 へ最大100件のメタデータGETをmultipart/mixedでまとめます。これはメタデータの一括取得であり、書籍本体のalt=mediaは個別取得です。

resource keyがある場合は X-Goog-Drive-Resource-Keys: FILE_ID/RESOURCE_KEY を対象リクエストへ渡します。参照は32段以内とし、循環、欠落、重複、要求外の応答や権限失効をエラーにします。表示名と本棚での配置はショートカット側、内容取得と整合性確認は参照先側を使います。同じ参照先でも別のショートカットを一覧から勝手に消しません。

現行の同期プロトコル2

管理者向けDrive本棚の読書同期は、署名付き認証で対応番号を伝え、本単位のサーバー正本へ操作を統合する方式です。認証応答の版、同期プロトコル、保存payloadのschemaVersion、暗号化プロフィールのversionは別の番号です。プロフィールの暗号化形式v1や文章EPUBのpayload schema 2を変更する必要はありません。

認証応答と署名付きの対応番号

/challengeと/tokenの成功・失敗JSONに、次の版情報を含めます。下の例はプロトコル2以上だけを受け入れる設定です。

{"responseVersion":1,"syncProtocol":2,"minimumClientProtocol":2}

クライアントはchallengeの版情報を確認し、/tokenのproofの署名対象claimsへsyncProtocol:2を追加します。署名の外側に番号を付けるだけでは互換条件を満たしません。nonce、鍵、本人、不変ID、audience、有効期限、一回消費の検査は維持します。

下限未満、または対応番号がない旧proofには、新しい資格を発行せずHTTP 426を返します。通常の401・403・通信障害と区別してください。

{"responseVersion":1,"syncProtocol":2,"minimumClientProtocol":2,"code":"UPGRADE_REQUIRED","status":426,"error":"読書記録を同期するには、アプリを最新版に更新してください。端末内の記録は保持されています。"}

更新が必要な場合もローカル読書・保存は続行し、未送信データを受領済みにしません。認証サービスの環境変数SYNC_MIN_CLIENT_PROTOCOL=2で旧版の新規認証を止めます。発行済みのFirestore資格はreaderLeaseにより最長300秒で失効します。これは管理者向け読書同期の制限で、書籍の遠隔削除、Drive閲覧権限の変更、一般GoogleログインのDrive同期の停止ではありません。

操作を送って、本単位の正本を取得する

機能宣言syncCapabilities/serverCanonicalV1が{protocolVersion:2,ready:true}の場合に使用します。Firestore RESTのパスは以下のとおりです。すべて本人のUIDと有効なleaseに限定し、正本・一覧へのクライアント書込みは禁止します。

readers/{uid}/canonicalOperations/{operationID}             操作の受信箱
readers/{uid}/canonicalRecords/{bookHash}_00000000-0000-0000-0000-000000000000
readers/{uid}/books/{laneHash}/canonical/00000000-0000-0000-0000-000000000000
readers/{uid}/canonicalAnnotationHeads/{laneHash}_00000000-0000-0000-0000-000000000000

操作はcreate-onlyです。fieldsはpayload(128 KiB以下のJSON文字列)、device(小文字UUID)、records(履歴・既読ならtrue、位置・注釈ならfalse)、processed:false、updatedAt(REQUEST_TIME)です。操作IDはdevice + ':' + (records ? 'r:' : 'a:') + payloadのUTF-8 SHA-256、小文字16進です。再試行では保存済みpayloadの同じバイト列・IDを使い、同じIDの異なる本文を上書きしません。

サーバーはvector clockと削除情報を含めて因果的に統合し、正本、旧形式の投影、横断一覧、操作の受領情報を同じトランザクションで更新します。操作の永続化と正本への反映は別段階です。クライアントは通信中の新しい編集を古い応答で消さず、応答喪失時は保存済み操作または受領digestを照合します。処理後の操作は本文を省略しても、操作ID・digestを残します。削除情報や未処理操作をTTLで消しません。

正本の外側はschemaVersion,sequence,payload,updatedAtです。下記の画像・文章・履歴payloadの形式は正本でも使います。履歴はcanonicalRecords、横断しおり・マークの変更発見はcanonicalAnnotationHeadsを使い、変更したレーンの本文だけを最大32文書ずつbatchGetします。一覧と本文のコミット、世代、全ページを確認して永続保存してからチェックポイントを進めます。marksの永続化前にindexを受領済みにしません。

運用と復旧

旧版の新規接続を止めても、受信済み操作の処理、旧データの投影、tombstone、重複送信の受領記録は残します。イベント配送の再試行と未処理操作の回復を用意し、処理失敗を監視してください。下限の変更だけでは旧形式の保存先や互換処理を削除しません。旧アプリの排除は端末証明やDRMを提供するものではありません。

受け入れ試験では、対応番号2の認証成功、旧proofの426、偽造署名の拒否、lease失効、再送・重複・逆順・オフライン削除、再起動後の差分取得、marks/indexの順序を確認します。秘密鍵・JWT・本の本文をログへ出さず、隔離した公開サンプルを使ってください。

3. 認証サービスは短期のFirebase資格を発行する

Content-Typeは application/json、応答はJSON objectです。入口では Authorization: Bearer GOOGLE_ID_TOKEN のGoogle発行IDトークンを検証し、許可した呼び出し主体だけを通します。Cloud RunではIAM認証を使います。独自ホストでは、同等の署名、issuer、audience、有効期限、主体の検証が必要です。入口トークンを確認しただけで、後段の署名検証を省略しないでください。

入口トークンの取得

アプリはGoogle token endpointへ application/x-www-form-urlencoded で grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer と assertion を送ります。assertionのheaderはalg=RS256/typ=JWT/kid、claimsはiss=email、aud=https://oauth2.googleapis.com/token、iat=現在のUnix秒、exp=iat+3600、target_audience=設定endpoint。Google応答のid_tokenを入口Bearerに使います。Drive用access_tokenやFirebase用idTokenとは別の資格です。

POST /challenge

{"email":"reader@example-drive.iam.gserviceaccount.com"}

emailを許可リストで確認して応答します。時刻はUnix秒の整数です。

{"nonce":"BASE64URL_OF_32_RANDOM_BYTES","issuedAt":1800000000,"expiresAt":1800000120,"audience":"https://sync.example.com","responseVersion":1,"syncProtocol":2,"minimumClientProtocol":2}

nonceは暗号学的乱数32バイトをpaddingなしbase64urlにした43文字。expiresAtはissuedAt+120。nonceのハッシュと発行時刻を永続保存し、利用者ごとに未消費チャレンジを一つだけ持ちます。発行回数の制限例は分単位の窓で6回です。上限超過時は429を返します。

POST /token

{"proof":"BASE64URL_HEADER.BASE64URL_CLAIMS.BASE64URL_SIGNATURE"}

proofは利用者のサービスアカウント鍵でRS256署名したJWTです。

header = {"alg":"RS256","typ":"JWT","kid":"40桁の小文字16進key ID"}
claims = {"iss":"サービスアカウントemail","aud":"https://sync.example.com",
          "iat":1800000000,"exp":1800000120,"nonce":"challengeのnonce","syncProtocol":2}

上記以外のheader/claimは拒否します。固定の https://www.googleapis.com/service_accounts/v1/metadata/x509/{URLエンコードしたemail} からkidに対応する証明書を取得して署名を検証し、issの許可リスト、audの完全一致、exp=iat+120、有効期限、保存済みnonceを確認します。iatはchallengeのissuedAtと一致させ、サーバー現在時刻の120秒前から5秒後までです。IAMでアカウントのemail・不変uniqueId・disabledを再確認し、nonceをトランザクションで一度だけ消費します。

{"customToken":"FIREBASE_CUSTOM_TOKEN","uid":"sa_123456789012345678901","leaseExpiresAt":1800000300,"responseVersion":1,"syncProtocol":2,"minimumClientProtocol":2}

UIDは sa_ + サービスアカウントの不変ID(client_id)。Firebase custom claimsに readerLeaseとして発行時刻+300秒を設定します。leaseExpiresAtは同じ値。クライアント指定UIDを信用しないでください。カスタムトークン生成に失敗した場合も消費済みnonceは復活させません。

失敗応答と上限

{"error":"認証できません。鍵・利用資格を確認して再試行してください。","status":401}

成功200、更新要求426(UPGRADE_REQUIRED)、JSON不正400、認証不正401、入口の許可拒否403、不明なmethod/path404、本文超過413、Content-Type不正415、回数超過429、依存サービス障害503。リクエスト本文の上限は12KiB、proofは8,192文字です。認証応答は Cache-Control: no-store。リダイレクトはアプリが拒否するため使用できません。資格・鍵・JWT・本文をログへ出さないでください。

4. payload形式と旧保存形式の参照

現行のFirestore同期の送信・取得先は前述の正本方式です。以下のdevices・recordHeadsは旧形式の説明であり、現行の書込み先として新規実装しないでください。payloadの画像・文章・履歴の意味は現行方式でも維持します。

アプリは accounts:signInWithCustomToken?key=API_KEY にtoken/returnSecureTokenを送り、返されたidTokenで accounts:lookup を呼びます。localIdが期待UIDと一致することを確認し、セッションはメモリだけに保持します。約240秒で再認証し、refresh tokenは保存・使用しません。

GET https://firestore.googleapis.com/v1/projects/PROJECT_ID/databases/(default)/documents/readers/UID/books/BOOK_HASH/devices?pageSize=100
POST https://firestore.googleapis.com/v1/projects/PROJECT_ID/databases/(default)/documents:commit

BOOK_HASHは、同期単位となるbookID文字列のUTF-8 SHA-256(小文字16進)です。固定ページの本では、bookIDは sha256: + 書籍全体のSHA-256です。文章EPUBでは、後述する論理レーンのIDをbookIDとしてハッシュします。端末文書IDは小文字UUIDです。一覧はpageSize=100でページトークンをたどり、一つのbookIDにつき1,000端末文書まで読み込みます。

Firestore文書の外側は常にv1

{"schemaVersion":1,"sequence":1,"payload":"JSON文字列","updatedAt":"サーバー時刻"}

これはFirestore文書の論理スキーマです。外側のschemaVersionは、現行実装では常に1です。payload文字列の中には、固定ページ用のschemaVersion 1、または文章EPUB用のschemaVersion 2が入ります。二つのschemaVersionを混同しないでください。

Firestore REST上はintegerValue/stringValue等の型付きfieldsを使います。commitでは、更新時に currentDocument.updateTime、初回に currentDocument.exists=false を付けます。updatedAtはREQUEST_TIME変換で設定し、sequenceは同じ端末文書内で単調に増やします。競合時は再取得して統合し、古い状態で無条件に上書きしません。

固定ページの本はpayload schema 1

{
  "schemaVersion": 1,
  "bookID": "sha256:FILE_SHA256_HEX",
  "pageCount": 24,
  "positions": [],
  "bookmarks": [],
  "clock": {
    "counters": {}
  }
}

PDF、ZIP、CBZ、画像主体のEPUBはschema 1を使います。payloadは上記JSONの文字列で、UTF-8で128KiBまでです。positionsの各要素は id,device,locator,furthestPage,updatedAt,clock です。updatedAtは省略できます。bookmarksは id,name,locator,clock,deleted、locatorは {page,entry} です。pageは元ページの0始まり、entryはPDFなら pdf:0 など、ZIPやEPUBなら画像エントリー名です。clockは {counters:{端末ID:整数}}、idはUUIDです。

bookIDはUTF-8で512バイト以内、pageCountは1〜10,000,000、positionsは最大256、bookmarksは最大10,000、clockは最大1,000端末です。端末IDは空でなくUTF-8で128バイト以内、counterは1以上10の12乗未満。entryは空でなく4,096バイト以内、しおり名は最大200文字です。pageとfurthestPageはページ数の範囲内に収めます。通信上の128KiB制限が、件数上限より先に効く場合があります。

同じbookID・ページ数の状態だけを統合します。vector clockで因果関係を比較し、同時更新の位置は複数保持します。しおり削除はdeletedのtombstoneとして残し、削除済みUUIDを再利用しません。サーバー時刻だけによるlast-write-winsや、tombstoneの独自削除は互換性を壊します。

文章EPUBはpayload schema 2

{
  "schemaVersion": 2,
  "bookID": "epub-flow-v2:sha256:FILE_SHA256_HEX/position",
  "pageCount": 0,
  "positions": [],
  "bookmarks": [],
  "clock": {
    "counters": {}
  },
  "flow": {
    "records": [
      {
        "id": "DEVICE_UUID",
        "kind": "position",
        "start": {
          "version": 1,
          "href": "OPS/chapter.xhtml",
          "path": [
            0,
            3
          ],
          "offset": 42
        },
        "label": "",
        "clock": {
          "counters": {
            "DEVICE_UUID": 1
          }
        }
      }
    ],
    "generations": {}
  }
}

文章EPUBは、ルートID epub-flow-v2:sha256:FILE_SHA256_HEX の下を、位置、索引、マーカーの論理レーンに分けます。各レーンのIDは、そのままFirestoreのbookIDとして扱い、レーンごとにBOOK_HASHを計算します。

schema 2ではpageCountを0、従来のpositionsとbookmarksを空配列にします。flowは {records,generations} です。位置レーンとマーカーレーンではgenerationsを空objectにし、索引レーンではrecordsを空配列にします。位置、索引、変更されたマーカーレーンだけを個別に取得・更新できるため、大きな本でも読書位置の同期に全注釈を含める必要がありません。

recordsのkindは position、bookmark、highlight のいずれかです。各recordはUUIDのid、start、label、clockを持ち、必要に応じてend、comment、color、order、deletedを持ちます。highlightにはstartと同じhref内でstartより後ろを指すendが必要です。colorはyellow、green、blue、pink、purpleのいずれかです。labelはUTF-8で1,600バイト以内、commentは800バイト以内で改行不可です。

文章位置は {version:1,href,path,offset} で表します。hrefは空でなくUTF-8で4,096バイト以内です。pathは最大128要素で、各値は0〜10,000,000。offsetは0〜100,000,000です。削除はdeletedのtombstoneとして残し、vector clockとバケット世代を保ったまま統合します。

既読・履歴のpayloadと旧形式の参照

Firestoreの現行送信先はcanonicalOperations、取得先はcanonicalRecordsです。以下の端末別recordHeads・devicesの直接更新は旧方式の参照です。一般GoogleログインのDrive appDataFolder方式は別経路であり、今回の認証下限変更の対象ではありません。

記録v1の拡張仕様(2026-10-05)。Android・iOS・iPadOS・Mac・Windowsの既読・履歴・横断しおり一覧に使う契約です。旧版アプリの位置・しおりは従来の保存先を維持します。記録機能を利用するには、対応版アプリと下記のサーバーRulesが必要です。

保存先は readers/{uid}/recordHeads/{sha256(bookID)}_{deviceUUID}。bookIDは本の内容全体の sha256:FILE_SHA256_HEX、deviceUUIDは小文字のインストールUUIDです。端末ごとの文書に分けるため、古いオフライン端末が他端末の更新を上書きしません。文書の外側は従来と同じschemaVersion、sequence、payload、updatedAtの四項目で、payloadは128 KiB以下です。

{
  "schemaVersion": 1,
  "bookID": "sha256:FILE_SHA256_HEX",
  "visits": [
    {
      "title": "サンプル.epub",
      "mode": "image",
      "progress": 0.25,
      "image": {
        "page": 5,
        "entry": "OPS/images/page006.jpg"
      },
      "driveFileID": "DRIVE_FILE_ID",
      "lastReadAt": 1790900000000,
      "deleted": false,
      "clock": {
        "counters": {
          "00000000-0000-0000-0000-000000000001": 1
        }
      }
    }
  ],
  "completions": [
    {
      "read": false,
      "clock": {
        "counters": {
          "00000000-0000-0000-0000-000000000001": 2
        }
      }
    }
  ],
  "clock": {
    "counters": {
      "00000000-0000-0000-0000-000000000001": 2
    }
  }
}

payloadのschemaVersionは1。visitsとcompletionsは配列で、未設定なら空配列です。各配列は最大256件。visitsは並行した履歴更新を保持し、各要素にtitle、mode、progress、lastReadAt、deleted、clockを保存します。modeはimageまたはflow、progressは現在位置の概算(0〜1)です。imageモードはimageのページ・エントリー名、flowモードはflowの文章アンカーを持ち、両方を同時に持ちません。文章アンカーの形式は前節と同じです。titleは空でないUTF-8文字列で1,600バイト以内。lastReadAtは0以上のUnixミリ秒です。

Driveから取得した本は任意のdriveFileIDで再取得元を識別します。値は英数字・ハイフン・アンダースコアのみ、1〜256文字です。端末内の絶対パス、秘密鍵、アクセストークンは保存しません。ファイル名が同じでも内容ハッシュが違う本は別の版として扱い、古いアンカーへ自動移動しません。

completionsの各要素はreadとclockを持ち、現在位置や履歴削除とは独立します。最後のページを表示しただけでは既読にしません。履歴一覧では本ごとに一件の代表を表示します。履歴のdeletedは履歴一覧からの削除を意味し、本・現在位置・しおり・マークの削除ではありません。

変更を確認してからの再読・既読変更を優先する

変更の前後関係は端末ごとの更新回数を持つvector clockで判断します。確認済みの変更より後の操作が優先されます。前後関係を判定できない同時変更では、履歴の削除、既読の解除を優先します。削除を確認した後に本を再び読んだ場合は履歴を再作成できます。削除済みUUIDを復活させないしおりの規則を、履歴へ流用しないでください。

両方が削除済み、または両方が表示対象の並行履歴ならlastReadAtの大きい方を一覧用の代表とします。同時刻ならキー順をそろえたJSONのUTF-8比較で決めます。lastReadAtは一覧の並び順にも使いますが、因果関係の判定には使いません。統合では因果的に後の要素がある古い要素だけを除き、並行要素は残します。表示用の代表に他の更新のclockを合成しないでください。これを行うと三端末の合流順で結果が変わります。同じclockに異なる内容があれば競合として停止します。並行する履歴の削除が一件でも残る間は非表示、並行する既読解除が一件でも残る間は未読です。clockは最大1,000端末、各キーは小文字UUID、回数は1以上1兆未満。payload全体のclockは各項目のclockをすべて観測済みにします。

クラウドの記録payloadには注釈を重複保存しない

Firestore RESTでrecordHeadsを一覧取得します。pageSize=10でnextPageTokenがあれば続きを取得し、全ページの検証後に統合します。端末のキャッシュは取得を待たずに表示します。GETだけでは新しい履歴や既読を作りません。更新は端末文書ごとのsequenceとupdateTimeを使った条件付きcommitです。成功応答を失った場合は同じsequenceとpayloadを照合して受領済みにし、通信中の新しいローカル更新を消しません。

横断しおり・マークの本体は従来の画像しおり、文章EPUBのindex・marksレーンから取得します。記録payloadへ全注釈を複製しません。Google Driveの一般ログイン用appDataFolderでは、readerRecords=1、book、device、schemaVersion=1のappPropertiesを持つ独立したリビジョンファイルへ保存します。リビジョン本文はschemaVersion=1、device、sequence、snapshotで、snapshotが上記payloadです。readerSync=1の位置・注釈ファイルとは混ぜません。アップロード後に読み戻して確認し、同じ端末の古いリビジョンだけを整理します。直前のリビジョンと並行変更は残します。Firestore用Rulesを変更しても、Google Drive側の同期は切り替わりません。

サーバー導入はGoogle参考構成のRulesとインデックス設定を先に適用します。devicesとrecordHeadsの両方でpayloadの検索インデックスを除外してください。認証サービスのchallenge/token契約、読者UID、300秒leaseは変更しません。

しおり一覧は端末に保存し、変更された本だけ同期する

注釈一覧のレプリカ(表示・検索用の複製)を端末へ永続保存します。起動時はこの一覧を表示し、裏で更新を確認します。ネットワーク応答に合わせて一冊ずつ一覧を作り直しません。キャッシュはプロフィール・同期アカウント別に分離し、通信失敗時も保存済みの一覧を残します。初回だけは一覧を作るための本文取得が必要です。

Firestoreでは documents/readers/{uid}:runQuery に本人のdevices子孫コレクションを指定します。structuredQueryのfromはcollectionId=devices、allDescendants=true、selectはsequenceのみ、orderByは__name__の昇順、limitは250です。返された文書名・sequence・updateTimeを更新情報として使い、続きは最後の文書名をstartAtのreferenceValueに渡します(before=false)。この一覧取得を許可する本人限定の再帰listルールが必要です。グローバルなコレクショングループ照会、他UIDへの照会、期限切れleaseは許可しません。書き込みの条件は従来どおりです。

Google DriveではappDataFolder内のreaderSync=1ファイルについて、id・version・appPropertiesをpageSize=1000でまとめて取得します。incompleteSearchやページトークンの循環は同期失敗として扱います。両方式とも全ページの検証が済むまで新しい更新情報を受領済みにしません。一回の更新情報取得は最大50,000件です。

変更された注釈の本文もまとめて取得する

Firestoreでは、検証済みの更新一覧から必要な文書名を選び、documents:batchGet にdocuments配列を渡します。一回は最大32文書。応答の順序には依存せず、要求した文書名と照合します。本文は一文書128 KiB以下で、schemaVersion・sequence・本の内容IDも検証します。文書の欠落、重複、要求外の文書、別の版、不完全な応答は同期失敗です。欠落を「しおりなし」と解釈せず、失敗した本の更新情報を受領済みにしません。Firebase ID tokenを使い、従来の本人限定getルールと300秒leaseを維持します。この一括取得のための権限追加は不要です。

Android・iOS・Macは変更がある本を四冊ずつ処理し、Windowsも本をまたいで最大32文書をまとめて取得します。画像しおりと文章のindexを先に取得し、indexの世代と端末の保存内容を比べて、変更されたmarks区画だけを追加取得します。本文の検証・保存が完了してからindexを受領済みにします。一段階の本文取得は合計32 MiB以下です。変更のない本文は保存済みデータを使い、再起動のたびに取り直しません。クラウドに対応する同期文書がなく、端末にも未送信の編集がなければ、本文は取得しません。

通常のGoogleログインで使うDrive同期では、Google Drive APIが本文ダウンロードの一括リクエストをサポートしていません。検証済みの更新一覧を再利用し、必要なリビジョンを最大四経路で並行取得します。本ごとのfiles.listを再実行しません。書き込み前の確認と、アップロード後の読み戻しは従来どおり行います。Drive本棚のJSON鍵を使う同期は、上記のFirestore経路です。

端末内では、プロセス開始後の初回に保存ファイルの一覧を確認し、その後は保存処理から変更したファイルだけを更新します。未送信の編集があるかどうかも、保存内容が変わった本だけ再確認します。複数ウィンドウの保存は同じ変更一覧へ反映します。再起動時は保存ファイルから一覧を再構成するため、保存途中で終了しても未送信データを見落としません。表示用キャッシュは同期の原本や送信待ちデータの代わりにはしません。

画像しおりの本レーンと文章EPUBのindexレーンを照合し、更新情報が変わった本、未確認の本、端末に未送信の編集がある本だけ注釈を同期します。文章の位置レーンは横断しおり一覧のためには取得しません。変更されたmarks区画をまとめて送信し、その保存後にindexを公開する既存方式を維持します。すべての本の注釈を一つの巨大なpayloadへ集約する方式ではありません。通信中の新しい編集と削除記録は保持し、失敗した本は次回に再試行します。

旧方式のsnapshot形式は維持しますが、現行方式は前述の正本・操作受信箱を使います。下限2の認証サービスでは未対応の旧アプリは接続できません。更新一覧用のRulesが導入済みなら、今回の本文一括取得に伴うRules変更は不要です。一括取得はHTTP往復を減らしますが、Firestoreの文書読み取り料金がゼロになるわけではありません。

5. 保存先を閉じてから受け入れ確認する

Firestore RulesはUID一致、有効なreaderLease、四つだけの文書フィールド、外側のschemaVersion=1、正の増加sequence、payload上限、サーバー更新時刻を検証します。クライアントによる文書deleteは禁止し、その他のパスは拒否します。管理者の削除手続きは別途設けてください。サービスアカウントOAuthでFirestoreへ直接書くとRulesを迂回するため、アプリの保存はFirebase IDトークンで行います。

現行の接続対象はGoogle Drive、Firebase Authentication、Firestoreです。独自実装の認証サービスは、上記の通信契約と受け入れ条件を満たす必要があります。