設定画面のホストは何も変えていないのに、アプリから手元の Ollama につながらない。
そういう報告が来たとき、まず疑うのは自分の設定ミスです。IP を打ち直し、ポートを確認し、ブラウザからは普通に応答が返ってくることを見て、それでも繋がらない。ログには例外すら残っていない。ただ、レスポンスが空で返ってきているだけ。
新しい API レベルを対象にしたアプリは、ユーザーの明示的な許可なしにローカルネットワークへアクセスできなくなります。厄介なのは、この失敗が例外ではなく「何もなかったこと」として返ってくる形をとりやすい点です。探索は 0 件で成功し、ソケットはタイムアウトし、Cast の一覧は空のまま。どれも、それ単体では正常な結果と見分けがつきません。
個人開発でアプリを保守していると、この種の変更はいつも「動かなくなってから気づく」やり方で処理しがちです。私自身、Android 16 の edge-to-edge の強制でそれをやりました。今回は先に棚卸しをしておきたい、と思って手を動かした記録です。
何が変わるのか、そして何を先に決めるべきか
対象は、新しい API レベルをターゲットにしたアプリです。ローカルネットワーク(同一 LAN 上の他の端末)への通信に、明示的なユーザー許可が要るようになります。インターネット越しの通信は従来どおりで、影響を受けるのは「家庭内や社内のネットワークの中で完結する通信」だけです。
手元の環境では android.permission.LOCAL_NETWORK_ACCESS という名前で扱われています。ただし名称も挙動もプラットフォームの世代で動きうるところなので、最終的な確認は Android Developers のバージョン別リリースノート で取ってください。この記事で固定したいのは権限の名前ではなく、自分のアプリのどこが LAN に触っているかを、記憶ではなく機械で答えられる状態にすること です。
影響範囲の当たりをつけるために、まず「LAN に出る経路」を書き出しました。
経路 コードに現れる形 権限なしでどう見えるか
私的アドレス直打ち 192.168.x.x / 172.16-31.x.x / 10.x.x.x接続タイムアウト
mDNS 名前解決 ollama.local など名前が解決しない
NSD によるサービス探索 NsdManager.discoverServices0 件で成功する
マルチキャスト受信 createMulticastLockロックは取れるが何も届かない
Cast / MediaRouter addCallback の能動スキャン端末一覧が空のまま
UDP ブロードキャスト 255.255.255.255 宛の送信応答が来ない
設定値のホスト baseUrl を実行時に受け取るユーザーの入力次第で変わる
WebView LAN の URL を loadUrl 読み込み失敗
この表を作った時点で、気になったことがひとつありました。右の列の大半が「エラーではない」のです。0 件、空リスト、タイムアウト。どれも、ネットワークが遅いときや相手が起動していないときと同じ顔をしています。つまり、権限が下りていないことをアプリ側は知らないまま、ユーザーには「見つかりませんでした」とだけ表示し続けることになる。
grep が見つけたのは、11 経路のうち 2 つでした
棚卸しの最初の一手は、当然 grep '192.168' です。手元のアプリで実際に使っている LAN 通信の書き方を 11 種類ぶん、検証用のツリーに並べて試しました。ファイルは 11 個、経路は 11 種類、すべてに「ここが LAN に出る」という意図を持たせてあります。
$ grep -rn "192\.168" --include= "*.kt" --include= "*.xml" .
./app/src/main/res/xml/network_security_config.xml:3: < domain includeSubdomains="true" > 192.168.0.0 < /domai n >
./app/src/main/java/net/example/app/Defaults.kt:4: const val OLLAMA_FALLBACK = "http://192.168.11.8:11434"
コメント行を除いて 2 件。11 経路のうち 2 つ、率にして 18% です。
範囲を広げても大きくは変わりませんでした。私的アドレスの全レンジを書いた正規表現でも 5 件どまり、45% です。
$ grep -rnE "(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|169\.254\.)[0-9]" \
--include= "*.kt" --include= "*.xml" . | wc -l
5
残りの 6 経路には、そもそも IP が書かれていません。NsdManager で探索するコードにも、MediaRouter で Cast 端末を探すコードにも、設定画面で入れたホストを使う OkHttp のラッパにも、数字の並びは一つも出てきません。LAN に出るかどうかはコードの見た目ではなく、実行時にどこへ向かうか で決まる。grep はそこを見られない。
見つからないだけならまだ救いがあります。困るのは、grep で 2 件見つかったときに「2 箇所直せば終わり」という感触が残ることです。手元の感覚として、これはかなり危うい。数字が出てしまうと、それが全体だと思ってしまう。
経路の種類で拾う — lan_scan.py
そこで、IP リテラルではなく「LAN に出る経路の種類」で拾う検出器を書きました。確度を 3 段階に分けています。certain は宛先が LAN だとコードから読み取れるもの、likely は実行時の値次第で LAN になり得るもの、review は設定ファイル側の痕跡です。
確度を分けたのは、likely を潰しきれないと分かっていたからです。Socket(host, port) の host が LAN を指すかは、コードを読むだけでは決まらない。それでも「決まらない」ことを一覧に残しておけば、後で人間が見に行けます。落とすのではなく、判断を保留したまま渡す。
#!/usr/bin/env python3
"""lan_scan.py — ローカルネットワーク権限が要る呼び出し箇所を洗い出す。"""
import json
import re
import sys
from pathlib import Path
PRIVATE_V4 = (
r " (?: 10 \. (?:\d {1,3} \. ) {2} \d {1,3} "
r " | 192 \. 168 \. \d {1,3} \. \d {1,3} "
r " | 172 \. (?: 1 [ 6-9 ] | 2 \d | 3 [ 01 ]) \. \d {1,3} \. \d {1,3} "
r " | 169 \. 254 \. \d {1,3} \. \d {1,3} )"
)
# (確度, 規則名, 正規表現, なぜ LAN へ出るか)
RULES = [
( "certain" , "private-ipv4-literal" , rf "(?<![\d.]) { PRIVATE_V4 } (?![\d.])" ,
"プライベート/リンクローカルのIPリテラル" ),
( "certain" , "mdns-local-host" , r " [ A-Za-z0-9_- ] + \. local (?! [ A-Za-z0-9_- ] ) " ,
"mDNS の .local ホスト名" ),
( "certain" , "broadcast-address" , r "255 \. 255 \. 255 \. 255" , "UDPブロードキャスト" ),
( "certain" , "nsd-discovery" ,
r " \b NsdManager \b | \b discoverServices \s * \( | \b resolveService \s * \( " ,
"NSD/mDNS によるサービス探索" ),
( "certain" , "multicast-lock" ,
r " \b createMulticastLock \s * \( | CHANGE_WIFI_MULTICAST_STATE" ,
"マルチキャストの受信" ),
( "certain" , "media-router-scan" ,
r " \b CastContext \b | CALLBACK_FLAG_PERFORM_ACTIVE_SCAN"
r " | \b MediaRouter \b\s * \. \s * addCallback" ,
"Cast/MediaRouter の能動スキャン" ),
( "likely" , "raw-socket" ,
r " \b(?: Socket | DatagramSocket | ServerSocket | MulticastSocket )\s * \( " ,
"ホストが実行時に決まる生ソケット" ),
( "likely" , "inet-resolve" ,
r "InetAddress \s * \. \s * (?: getByName | getAllByName )\s * \( " ,
"実行時解決。LAN 宛になり得る" ),
( "likely" , "configurable-base-url" ,
r " \b(?: baseUrl | BASE_URL | host | HOST | endpoint | ENDPOINT | serverUrl | SERVER_URL )\b\s * [ := ] " ,
"設定画面やリモート設定で差し替わるホスト" ),
( "likely" , "emulator-host" , r " (?<! [\d . ] ) 10 \. 0 \. 2 \. 2 (?! [\d . ] ) " ,
"エミュレータのホストループバック" ),
( "likely" , "webview-load" , r " \b loadUrl \s * \( | \b loadDataWithBaseURL \s * \( " ,
"WebView が LAN の URL を読む可能性" ),
( "review" , "cleartext-domain-config" ,
r "cleartextTrafficPermitted \s * = \s * [ \" ' ] true [ \" ' ] " ,
"平文許可。LAN 宛の名残であることが多い" ),
]
SKIP_DIRS = { "build" , ".git" , ".gradle" , "node_modules" }
EXTS = { ".kt" , ".java" , ".xml" , ".json" , ".properties" }
# コメント除去は「URL の // を食う」事故が起きやすいので、
# スキームの直後だけは除去対象から外す。
_LINE_COMMENT = re.compile( r " (?<! [ :/ ] ) // . * $ | <!-- . *? (?: --> | $) |(?<! \S ) # . * $ " )
def strip_comment (line: str ) -> str :
return _LINE_COMMENT .sub( "" , line)
def is_import (line: str ) -> bool :
return bool (re.match( r " \s * import \s " , line))
def scan (root: Path):
hits = {} # (file, line) -> finding
for path in sorted (root.rglob( "*" )):
if not path.is_file() or path.suffix not in EXTS :
continue
if SKIP_DIRS & set (path.parts):
continue
rel = str (path.relative_to(root))
for lineno, raw in enumerate (path.read_text( errors = "replace" ).splitlines(), 1 ):
if is_import(raw):
continue
line = strip_comment(raw)
if not line.strip():
continue
matched = [(c, n, w) for c, n, pat, w in RULES if re.search(pat, line)]
if not matched:
continue
# 1行に複数当たったら、最も強い確度で1件に畳む
order = { "certain" : 0 , "likely" : 1 , "review" : 2 }
matched.sort( key =lambda m: order[m[ 0 ]])
hits[(rel, lineno)] = {
"file" : rel,
"line" : lineno,
"confidence" : matched[ 0 ][ 0 ],
"rules" : [m[ 1 ] for m in matched],
"why" : matched[ 0 ][ 2 ],
"text" : raw.strip()[: 100 ],
}
return [hits[k] for k in sorted (hits)]
def manifest_has_permission (root: Path) -> bool :
return any (
"android.permission.LOCAL_NETWORK_ACCESS" in m.read_text( errors = "replace" )
for m in root.rglob( "AndroidManifest.xml" )
)
def main ():
root = Path(sys.argv[ 1 ] if len (sys.argv) > 1 else "." ).resolve()
findings = scan(root)
counts = { "certain" : 0 , "likely" : 0 , "review" : 0 }
for f in findings:
counts[f[ "confidence" ]] += 1
report = {
"root" : str (root),
"declared_permission" : manifest_has_permission(root),
"counts" : counts,
"total" : len (findings),
"files_touched" : len ({f[ "file" ] for f in findings}),
"findings" : findings,
}
print (json.dumps(report, ensure_ascii = False , indent = 2 ))
# 権限未宣言なのに certain がある = 出荷後に無反応として現れる
if counts[ "certain" ] and not report[ "declared_permission" ]:
print ( f " \n FAIL: certain { counts[ 'certain' ] } 件に対し "
"LOCAL_NETWORK_ACCESS が未宣言です" , file = sys.stderr)
return 1
return 0
if __name__ == "__main__" :
sys.exit(main())
同じ 11 ファイルのツリーに当てた結果です。
$ python3 lan_scan.py .
total: 19 | files: 11 | counts: {'certain': 15, 'likely': 3, 'review': 1}
FAIL: certain 15 件に対し LOCAL_NETWORK_ACCESS が未宣言です
$ echo $?
1
11 ファイルすべてに触れました。ファイル別に畳むとこうなります。
ファイル 当たった規則
Beacon.ktbroadcast-address, inet-resolve, multicast-lock, raw-socket
CastRoute.ktmedia-router-scan
Defaults.ktemulator-host, private-ipv4-literal
Discovery.ktnsd-discovery
HelpWeb.ktmdns-local-host, webview-load
LinkLocal.ktinet-resolve, private-ipv4-literal
LlmClient.ktconfigurable-base-url
Nas.ktconfigurable-base-url, private-ipv4-literal
PhotoImport.ktconfigurable-base-url, raw-socket
network_security_config.xmlcleartext-domain-config, mdns-local-host, private-ipv4-literal
AndroidManifest.xmlmulticast-lock
grep の 2 ファイルに対して 11 ファイル。差は 9 ファイルぶんですが、内訳を見ると差の性質が分かります。増えた 9 件のうち、IP リテラルを含むものは 1 件(Nas.kt の 172.20.3.14)だけ。残る 8 件は、どれだけ IP の正規表現を丁寧に書いても届かない場所にありました。
Defaults.kt の 10.0.2.2 が private-ipv4-literal にも当たっているのは、10/8 の範囲に含まれるからです。実際にはエミュレータのホストループバックで、LAN 宛ではありません。ここは検出器が過剰に拾っている箇所で、後段の人間の判断に委ねる部分です。過剰検出を減らすより、見落としを減らす側に寄せました。
コメント除去が URL を食べていた
最初に書いた版は、もっと単純にコメントを落としていました。// を見つけたらそこから先を捨てる、という 3 行です。
def strip_comment (line: str ) -> str : # ← 最初の版。これは壊れています
for marker in ( "//" , "<!--" , "#" ):
i = line.find(marker)
if i >= 0 :
line = line[:i]
return line
これを走らせると total: 26 / certain: 16 と出て、FAIL もきちんと返ってきました。数字が出ていて、終了コードも 1。一見、動いています。
けれど Defaults.kt の 4 行目が結果に入っていませんでした。
const val OLLAMA_FALLBACK = "http://192.168.11.8:11434"
http:// の // をコメントの開始と見なして、行の後半を丸ごと捨てていたのです。**grep ですら見つけられた 1 件を、検出器が落としていた。**しかも見落としは静かで、レポートの体裁は最後まで整ったままでした。
修正は、直前が : か / ならコメントと見なさない、という否定後読みを足すだけです。
_LINE_COMMENT = re.compile( r " (?<! [ :/ ] ) // . * $ | <!-- . *? (?: --> | $) |(?<! \S ) # . * $ " )
小さな話ではあるのですが、私はこの手のものを軽く見ないようにしています。検出器そのものが黙って取りこぼす構図は、この記事で扱っている「権限がなくても 0 件で成功する」のと同じ形をしているからです。棚卸しの道具が棚卸しから漏れる。だから検出器を書いたら、必ず「見つかるはずの 1 件」を仕込んで、それが出ることを確かめる ようにしています。今回でいえば grep で見つかった 2 件が、そのまま検出器の最低ラインになります。
「Android 17 対応して」を、確認できる 11 個に畳む
ここからが本題に近い部分です。Antigravity のエージェントに Android 17 のローカルネットワーク権限に対応して と投げると、何が返ってくるか。手元では、マニフェストに <uses-permission> を 1 行足して「対応しました」と返ってきました。
責める気にはなれません。指示が曖昧なら、モデルは検索できる範囲で辻褄の合う最小の変更を選びます。エージェントも grep で見える箇所から探すので、私が最初に踏んだのと同じ穴に落ちる。違いは、エージェントのほうが自信を持って完了を宣言する点だけです。
なので、指示そのものを変えました。「対応して」ではなく、JSON で 11 個のタスクを渡し、各タスクに答えるべき問いを紐づける 。
#!/usr/bin/env python3
"""lan_tasks.py — lan_scan.py の JSON を、エージェントに渡せる作業単位へ畳む。"""
import json
import sys
QUESTION = {
"private-ipv4-literal" : "この宛先は同一 LAN 上か。ループバック/エミュレータなら権限は不要" ,
"emulator-host" : "デバッグビルド限定か。リリースに残るなら本番の宛先を確認" ,
"mdns-local-host" : "名前解決が mDNS 経由なら、探索そのものに権限が要る" ,
"broadcast-address" : "ブロードキャストは権限なしで無反応になる。縮退表示を用意" ,
"nsd-discovery" : "探索が0件で返る。ユーザーに理由を出せているか" ,
"multicast-lock" : "ロック取得は通るが受信が来ない。タイムアウトの扱いを確認" ,
"media-router-scan" : "Cast 端末が一覧に出ない。空リストの文言を用意" ,
"raw-socket" : "接続先が LAN になり得るか。なるなら失敗時の例外を握り潰していないか" ,
"inet-resolve" : "解決結果が私的アドレスになり得るか" ,
"configurable-base-url" : "ユーザーが LAN のホストを入れられる導線があるか" ,
"webview-load" : "読み込む URL が LAN を指し得るか。エラーページを出せるか" ,
"cleartext-domain-config" : "平文許可の対象が LAN 宛か。不要なら削除" ,
}
RANK = { "certain" : 0 , "likely" : 1 , "review" : 2 }
def main ():
report = json.load( open (sys.argv[ 1 ]))
per_file = {}
for f in report[ "findings" ]:
e = per_file.setdefault(
f[ "file" ], { "lines" : [], "rules" : set (), "confidence" : "review" }
)
e[ "lines" ].append(f[ "line" ])
e[ "rules" ].update(f[ "rules" ])
if RANK [f[ "confidence" ]] < RANK [e[ "confidence" ]]:
e[ "confidence" ] = f[ "confidence" ]
tasks = []
for path in sorted (per_file, key =lambda p: ( RANK [per_file[p][ "confidence" ]], p)):
e = per_file[path]
tasks.append({
"file" : path,
"lines" : sorted ( set (e[ "lines" ])),
"confidence" : e[ "confidence" ],
"checks" : sorted ({ QUESTION [r] for r in e[ "rules" ] if r in QUESTION }),
"status" : "todo" ,
})
print (json.dumps(
{ "permission_declared" : report[ "declared_permission" ], "tasks" : tasks},
ensure_ascii = False , indent = 2 ,
))
# 人が読む形は stderr へ。パイプの邪魔をしない
for t in tasks:
marks = "," .join( str (x) for x in t[ "lines" ])
print ( f " \n [ { t[ 'confidence' ] } ] { t[ 'file' ] } (L { marks } )" , file = sys.stderr)
for q in t[ "checks" ]:
print ( f " - [ ] { q } " , file = sys.stderr)
if __name__ == "__main__" :
main()
実行するとこうなります。
$ python3 lan_scan.py . > report.json 2>/dev/null
$ python3 lan_tasks.py report.json > tasks.json
[certain] app/src/main/java/net/example/app/Beacon.kt (L9,10,13)
- [ ] ブロードキャストは権限なしで無反応になる。縮退表示を用意
- [ ] ロック取得は通るが受信が来ない。タイムアウトの扱いを確認
- [ ] 接続先が LAN になり得るか。なるなら失敗時の例外を握り潰していないか
- [ ] 解決結果が私的アドレスになり得るか
[certain] app/src/main/java/net/example/app/Discovery.kt (L6,7,8,10)
- [ ] 探索が0件で返る。ユーザーに理由を出せているか
[likely] app/src/main/java/net/example/app/LlmClient.kt (L6)
- [ ] ユーザーが LAN のホストを入れられる導線があるか
タスクは 11 個。エージェントに渡すのはこの tasks.json で、指示は「status を done にするには、checks の各項目に対する答えを diff の中で示すこと」に変わります。
この形にして効いたのは、完了の定義が私の側に残る 点でした。「対応して」だと完了を宣言するのはエージェントです。11 個のタスクを渡すと、完了は「11 個すべてが done かどうか」という私が数えられる形になります。同じ考え方はエージェントの完了条件を契約として書く でも扱いましたが、今回はチェック項目を検出器が自動で生成しているぶん、書き漏らしが起きにくくなっています。
権限が下りなかったときに、何を見せるか
棚卸しが終わっても、まだ半分です。ユーザーが権限を拒否したときにアプリがどう振る舞うか、を決めないといけない。
そして拒否は普通に起こります。「ローカルネットワーク上の端末を探す許可」というダイアログは、写真やマイクほど目的が想像しやすくありません。私自身、自分がユーザーなら一度は断ると思います。
Discovery.kt の checks にある「探索が0件で返る。ユーザーに理由を出せているか」がここに効いてきます。権限なしの探索は 0 件で成功するので、素直に実装すると「デバイスが見つかりませんでした」と出ます。ユーザーは Wi-Fi を疑い、Ollama を再起動し、ルーターを見に行く。原因は端末の権限設定にあるのに、そこへ誘導する文言がどこにもない。
なので、探索が 0 件だったときに権限の状態を見に行って、文言を分岐させます。
sealed interface DiscoveryOutcome {
data class Found ( val services: List < NsdServiceInfo >) : DiscoveryOutcome
/** 権限がないため探索そのものが成立していない */
data object BlockedByPermission : DiscoveryOutcome
/** 権限はあるが本当に見つからなかった */
data object EmptyNetwork : DiscoveryOutcome
}
class DiscoveryReporter ( private val context: Context ) {
private fun hasLocalNetworkAccess (): Boolean =
ContextCompat. checkSelfPermission (context, LOCAL_NETWORK_ACCESS) ==
PackageManager.PERMISSION_GRANTED
/**
* 0 件は「見つからなかった」と「探せなかった」の両方を意味する。
* 権限の状態で分岐しないと、ユーザーは無関係な場所を疑い続けることになる。
*/
fun classify (services: List < NsdServiceInfo >): DiscoveryOutcome = when {
services. isNotEmpty () -> DiscoveryOutcome. Found (services)
! hasLocalNetworkAccess () -> DiscoveryOutcome.BlockedByPermission
else -> DiscoveryOutcome.EmptyNetwork
}
companion object {
// 名称はプラットフォームの世代で変わり得るため、参照は1箇所に寄せる
const val LOCAL_NETWORK_ACCESS = "android.permission.LOCAL_NETWORK_ACCESS"
}
}
when の並び順に意味を持たせています。まず成功、次に権限、最後に「本当に空」。権限の判定を最後に置くと、空リストを先に返してしまう分岐が混ざり込みやすい。順序で守れるものは順序で守っておきたいところです。
権限文字列を companion object に寄せているのは、名称が動きうると分かっているからです。動くと分かっているものを 11 ファイルに散らすと、次の変更で同じ棚卸しをもう一度やることになります。
BlockedByPermission のときに何を表示するかも、決めておく必要があります。私は「アプリの設定を開く」導線を添えた一文にしました。再度パーミッションを要求するのではなく、設定画面へ送る。一度断った相手に同じダイアログを出すのは、あまり気持ちのいいやり方ではありません。
なお、この分岐を入れると LlmClient のような configurable-base-url 側にも同じ判断が要ります。ユーザーが入れたホストが 192.168.11.8 なら権限が要り、api.example.com なら要らない。実行時に宛先を見て決める、という素朴な判定を 1 箇所置いておくと収まりが良いです。この辺りの構成はローカル LLM を Ollama / LM Studio でつなぐ実装 で書いた接続層に、そのまま足せる形になっています。
移行チェックリスト
手元で回した順番です。上から順に、それぞれ機械で確かめられる形になっています。
手順 やること 確認方法
1 grep でベースラインを取る 見つかった件数を控える(これが検出器の最低ライン)
2 lan_scan.py を当てる1 の件数を必ず含んでいること。含まなければ検出器のバグ
3 certain を精査するループバック/エミュレータ宛の過剰検出を人間が落とす
4 likely を判断する実行時に LAN を指し得るかを 1 件ずつ。落とすなら理由を残す
5 lan_tasks.py でタスク化タスク数がファイル数と一致すること
6 エージェントに tasks.json を渡す status: done の数が 5 のタスク数と一致すること
7 権限を宣言して再実行 lan_scan.py の終了コードが 0 になること
8 拒否時の縮退を確認 権限を切った実機で、0 件と拒否が別の文言になること
7 を実際に確かめたときの出力です。マニフェストに 1 行足しただけで、ゲートは黙ります。
$ python3 lan_scan.py . > /dev/null; echo "exit=$?"
exit=0
**この 1 行が通ってしまうことこそが、この記事の出発点でした。**終了コードは、権限を宣言したかどうかしか見ていません。11 箇所が正しく縮退するかは見ていない。ゲートは「宣言忘れ」を止めるだけの道具で、checks に答えたかどうかは 6 の側でしか担保できない。ここを混同すると、緑のゲートを見て安心したまま出荷することになります。
CI に組み込むなら、7 だけを自動化して 6 は人間が見る、という切り分けをお勧めします。ビルドが緑であることと、動くことが別だという話はデプロイは緑なのに古いビルドが動いていた でも書いたとおりで、今回もその形をしています。
この道具の限界
正直に書いておくと、lan_scan.py が拾えないものがあります。
文字列で組み立てられたホスト
リフレクションや文字列連結でホストを組み立てている箇所には無力です。"http://" + prefix + "." + suffix のような書き方は、どの規則にも当たりません。ネイティブライブラリの中で通信しているものも見えません。サードパーティ SDK が内部で mDNS を叩いていれば、こちらのコードには痕跡が残らない。
依存ライブラリの中の通信
依存ライブラリの側は、結局のところマニフェストのマージ結果を見るしかありません。app/build/outputs/logs/manifest-merger-*-report.txt に、どのライブラリがどの権限を要求したかが出ます。ここに LOCAL_NETWORK_ACCESS が現れたら、自分は書いていないのに LAN に出る何かが入っている、ということです。
静的な棚卸しであること
これは静的な棚卸しにすぎません。「LAN に出る可能性のある箇所」を並べただけで、「実際に出ている」ことは示していない。本当のところは実機で権限を切って動かすまで分かりません。8 番目のチェックを外せないのはそのためです。
道具でどこまでやって、どこから先は自分の目で見るか。その線をはっきりさせておくほうが、道具を過信するより結果的に速く着く、というのが今のところの実感です。
次にやること
もし手元に Android アプリがあるなら、まず grep -rnE "(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|169\.254\.)[0-9]" を一度だけ走らせてみてください。
0 件でも安心はできません。むしろ 0 件だったときこそ、NsdManager と MediaRouter と、設定画面で入れられるホストの 3 つを見に行く価値があります。私のツリーでは、その 3 つが grep の外側にありました。
期限が来てから直すか、来る前に棚卸しを済ませておくか。どちらでも最終的にやることは同じですが、後者のほうが「無反応の原因を探す時間」を払わずに済みます。私はその時間を、あまり気持ちよく過ごせた記憶がありません。
お読みいただきありがとうございました。手元の棚卸しの足がかりになれば嬉しいです。