テスト端末を並べて確かめていた夕方のことです。アプリは入っているのに、メールに貼った自分のサイトのリンクを押すと、ブラウザが開きました。アプリ内から Linking.openURL を呼べば目的の画面まできちんと着きます。外から入ってきたときだけ、アプリを素通りしてしまうのです。
個人開発でアプリをいくつも抱えていると、こういう「片側だけ動く」不具合にいちばん時間を取られます。動いている経路があるぶん、壊れている側を探す取っ掛かりが少ないのです。
先に結論をお伝えします。私の場合、assetlinks.json の置き場所も JSON の構造も合っていて、間違っていたのはその中に書いた指紋のほうでした。手元でビルドした版の署名鍵から取った指紋を貼っていて、ストアから配信されている版の署名とは別物だったのです。
Android 12 から、検証されていないリンクは既定で開きません
Android App Links は「この URL を開く権利をこのアプリに委ねます」という宣言を、サイト側とアプリ側の両方に置いて突き合わせる仕組みです。サイト側に置くのが https://あなたのドメイン/.well-known/assetlinks.json、アプリ側に置くのが autoVerify を付けた intent-filter になります。
ここで押さえておきたいのが、Android 12(API レベル 31)以降の既定の挙動です。検証が通っていないリンクは、ユーザーが設定画面で手動により有効化しないかぎり、アプリでは開きません。ブラウザへ流れます。
つまり検証に失敗しても、エラーは何も出ません。クラッシュもせず、ログにも赤い行は残らず、ただ「リンクを押したらブラウザが開いた」という結果だけが手元に残ります。私自身、最初はアプリ側のルーティングを疑って半日ほど無駄にしました。外から入ってくる導線が壊れたときは、アプリの中ではなく、アプリとサイトのあいだを先に見ます。
手順は3つで、詰まるのはたいてい3つめです
Rork や Expo で作ったプロジェクトなら、アプリ側の宣言は app.json(あるいは app.config.ts)に書きます。
{
"expo": {
"android": {
"package": "net.example.myapp",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{ "scheme": "https", "host": "example.com", "pathPrefix": "/app" }
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}autoVerify を true にすると、アプリのインストール時に端末が https://example.com/.well-known/assetlinks.json を取りに行き、そこに自分の署名の指紋があるかどうかを確かめます。
サイト側に置くファイルは次の形です。
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "net.example.myapp",
"sha256_cert_fingerprints": ["ここに指紋"]
}
}
]配信の条件は素っ気ないほど単純で、HTTPS であること、リダイレクトを挟まないこと、application/json で返すことの3点です。Lab のサイトは Cloudflare、読み物のブログは WordPress で運用しておりますが、静的ファイルを素直に置ける前者に対して、後者ではパーマリンクの設定が .well-known へのアクセスを飲み込んでしまうことがあります。置いたつもりのファイルが 404 を返していないか、まずブラウザで直接開いて確かめておくと落ち着きます。
そして3つめが指紋です。ここが本題になります。
keytool が出す形を、そのまま貼ります
指紋は署名鍵の証明書から取ります。手元の keystore なら keytool で読めます。実際にサンプルの keystore を作って実行した出力が、こちらです。
$ keytool -list -v -keystore upload.jks -storepass changeit
Alias name: upload
Certificate fingerprints:
SHA256: 6E:66:8F:80:8A:86:71:93:42:09:66:DB:3B:7F:AB:AE:4B:52:35:EC:E2:C8:76:EA:D9:F8:C5:9B:E8:E6:51:F7
Signature algorithm name: SHA256withRSASHA256: の右にある文字列を、コロンごと、大文字のまま sha256_cert_fingerprints に入れます。手元で数えたところ、コロンを含めて 95 文字、16 進の桁だけなら 64 桁でした。整形しようとしてコロンを外したり、小文字に直したりする必要はありません。むしろ触らないほうが安全です。
openssl しか手元にないときは、同じ値を次の形で取り出せます。
$ openssl x509 -in cert.pem -noout -fingerprint -sha256
sha256 Fingerprint=AF:B8:01:5B:D8:47:87:2A:59:A5:3F:F1:ED:61:2C:E3:14:D8:EE:AF:1F:BA:68:ED:A8:9A:31:FF:3C:67:7B:8D手元の版と、配信されている版は別の鍵で署名されています
ここが私のつまずいた場所です。Google Play のアプリ署名を使っている場合、開発者が持っているのはアップロード鍵で、ユーザーの端末に届く APK に実際に載る署名は、Play 側が保管しているアプリ署名鍵になります。この2つは別の証明書ですから、指紋も当然に違います。
サンプルの keystore を2つ作って、指紋を並べてみました。
upload = 6E:66:8F:80:8A:86:71:93:42:09:66:DB:3B:7F:AB:AE:4B:52:35:EC:E2:C8:76:EA:D9:F8:C5:9B:E8:E6:51:F7
appsigning = AE:4E:BC:69:E7:8E:51:0F:DC:BA:C2:38:CC:7A:2E:37:0A:E7:57:2C:5C:11:9E:67:35:AB:04:43:E7:11:07:4B
一致するか = no
手元のビルドではリンクが開くのに、ストアから入れ直すと開かない——という現象は、この食い違いで説明がつきます。エラーが出ないぶん疑う順番を間違えていたのだと気づいたのは、配信版を入れ直したあとでした。手元の版はアップロード鍵で署名されていますから、アップロード鍵の指紋しか書いていない assetlinks.json とは一致します。配信版は別の鍵ですから、一致しません。
対処はごく簡単で、両方の指紋を並べて書くだけです。sha256_cert_fingerprints は配列ですから、2つ以上入れられます。アプリ署名鍵の指紋は Play Console の「アプリの完全性」(アプリ署名)のページに表示されていますので、そこからコピーします。
| 鍵 | どこで使われるか | 指紋の入手先 |
|---|---|---|
| アップロード鍵 | 手元のビルド、Play への提出 | 手元の keystore を keytool -list -v |
| アプリ署名鍵 | ユーザーの端末に届く版 | Play Console のアプリ署名ページ |
署名鍵まわりで提出そのものが止まる場合は、Rorkアプリを Google Play にアップロードできない署名キーエラーの直し方のほうが近い話題になります。
照合を目視でやめて、小さな検査に任せます
64 桁の 16 進を目で比べるのは、私には無理でした。ファイルを読んで機械的に突き合わせるだけの短いスクリプトを置いてあります。
import json, re, sys
REL = "delegate_permission/common.handle_all_urls"
FP_RE = re.compile(r"^(?:[0-9A-F]{2}:){31}[0-9A-F]{2}$")
def check(path, package, expected):
problems = []
try:
doc = json.loads(open(path, encoding="utf-8").read())
except json.JSONDecodeError as e:
return [f"JSON として読めません: {e}"]
if not isinstance(doc, list):
problems.append("最上位が配列ではありません(1件でも [] で囲みます)")
doc = [doc]
hit = False
for i, st in enumerate(doc):
tgt = st.get("target", {})
if tgt.get("namespace") != "android_app":
continue
if tgt.get("package_name") != package:
problems.append(f"[{i}] package_name が {tgt.get('package_name')!r}")
continue
hit = True
if REL not in st.get("relation", []):
problems.append(f"[{i}] relation に {REL} がありません")
fps = tgt.get("sha256_cert_fingerprints", [])
for fp in fps:
if not FP_RE.match(fp):
problems.append(f"[{i}] 指紋の書式が違います: {fp[:20]}...")
# 比較のときだけコロンと大小文字を無視します
norm = {fp.replace(":", "").upper() for fp in fps}
if expected.replace(":", "").upper() not in norm:
problems.append(f"[{i}] 照合したい署名鍵の指紋が含まれていません")
if not hit:
problems.append(f"{package} を対象にした android_app の文が1件もありません")
return problems
if __name__ == "__main__":
path, package, expected = sys.argv[1], sys.argv[2], sys.argv[3]
found = check(path, package, expected)
print(f"--- {path}")
print(" 問題は見つかりませんでした" if not found else "")
for p in found:
print(f" NG: {p}")崩し方を変えた4つのファイルに通した実際の出力が、次のものです。
--- samples/ok.json
問題は見つかりませんでした
--- samples/obj.json
NG: 最上位が配列ではありません(1件でも [] で囲みます)
--- samples/nocolon.json
NG: [0] 指紋の書式が違います: 6E668F808A8671934209...
--- samples/badrel.json
NG: [0] relation に delegate_permission/common.handle_all_urls がありません
そして本題の食い違いを再現したのが、こちらになります。アップロード鍵の指紋だけを書いたファイルを、配信版の署名(アプリ署名鍵)で照合しています。
--- samples/uploadonly.json
NG: [0] 照合したい署名鍵の指紋が含まれていません
--- samples/both.json
問題は見つかりませんでした
両方を並べて書いた both.json は、アプリ署名鍵で照合しても、アップロード鍵で照合しても通りました。デプロイ前にこの2回を回しておけば、少なくとも指紋を理由に無言でブラウザへ流れることはなくなります。
書式の検査では大文字とコロンを厳しく見て、指紋の一致を見るところではコロンと大小文字を無視している点だけ、意図をお伝えしておきます。書き込むときは公式の例に揃えておきたい一方で、比較のときに書式の差で見落とすのは本末転倒だと考えたためです。
端末側の判定を、最後に見に行きます
ファイルが正しくても、端末が検証を終えていなければリンクは開きません。実機で状態を確かめられます。
# 現在の検証状態を確認します
adb shell pm get-app-links net.example.myapp
# 検証をやり直させます
adb shell pm verify-app-links --re-verify net.example.myapp出力の各ドメインに付く状態が verified になっていれば通っています。none や legacy_failure のままなら、assetlinks.json の配信か指紋のどちらかがまだ噛み合っていません。端末はインストール時に一度取りに行くだけですから、ファイルを直したあとはアプリを入れ直すか、上の --re-verify を明示的に走らせる必要があります。
インストール直後の着地先の設計まで含めて考えたい場合は、インストール直後に「目的の画面」へ着地させる — Rork アプリの遅延ディープリンク設計に別の角度でまとめてあります。
次に手を動かすなら
assetlinks.json を開いて、sha256_cert_fingerprints の中身が1つだけになっていないかを見てください。1つしかないなら、それが手元の鍵なのか Play が持つ鍵なのかを確かめるところが出発点になります。多くの場合、そこにもう1行足すだけで済みます。
私も配信版で試すまで気づけませんでした。同じところで止まっている方の、遠回りが少し短くなれば嬉しく思います。