Android 17's Local Network Permission: Inventory Every LAN Call Site Before You Hand the Fix to an Agent
Apps targeting the new API level need explicit permission to reach the local network. A grep for private IPs found 2 of my 11 LAN paths. Here is the detector that found all 11, and how I turned its output into eleven checkable tasks for an agent.
The host in settings had not changed, yet the app could no longer reach my local Ollama instance.
When that report came in, my first suspect was my own configuration. I retyped the IP. I checked the port. A browser on the same machine answered the endpoint without complaint. Still nothing from the app — and no exception in the log either. Just an empty response, returned politely.
Apps targeting the new API level can no longer reach the local network without an explicit grant from the user. What makes this awkward is the shape of the failure: it arrives not as an error but as an absence. Discovery succeeds with zero results. Sockets time out. The Cast device list stays empty. None of those, on their own, look any different from a slow network or a server that simply is not running.
Maintaining apps as an indie developer, I usually handle changes like this the lazy way — I notice them after something breaks. That is exactly how Android 16's edge-to-edge enforcement went for me. This time I wanted the inventory first, so I sat down and did the work.
What changes, and what to decide first
The scope is apps targeting the new API level. Traffic to other devices on the same LAN now requires an explicit user grant. Internet traffic is untouched; only communication that stays inside a home or office network is affected.
On my machine the permission goes by android.permission.LOCAL_NETWORK_ACCESS. Both the name and the behaviour can shift between platform generations, so treat the Android Developers release notes as the authority. What I want to pin down here is not the permission string but something more durable: being able to answer "where does my app touch the LAN?" from a machine rather than from memory.
To size the blast radius, I first wrote out every way an app leaves for the LAN.
Path
How it appears in code
What you see without permission
Private address literal
192.168.x.x / 172.16-31.x.x / 10.x.x.x
Connection timeout
mDNS name resolution
ollama.local and friends
Name never resolves
NSD service discovery
NsdManager.discoverServices
Succeeds with zero results
Multicast receive
createMulticastLock
Lock acquires, nothing arrives
Cast / MediaRouter
Active scan via addCallback
Device list stays empty
UDP broadcast
Sends to 255.255.255.255
No replies
Host from settings
baseUrl supplied at runtime
Depends on what the user typed
WebView
loadUrl against a LAN URL
Load failure
One thing struck me the moment the table was finished: almost nothing in that right-hand column is an error. Zero results. Empty list. Timeout. Every one of them wears the same face as a slow network or an offline peer. The app never learns that permission was withheld, and the user keeps seeing "nothing found" forever.
Grep found 2 of my 11 paths
The obvious first move is grep '192.168'. I laid out the eleven ways my own apps actually talk to the LAN in a test tree — eleven files, eleven distinct paths, each one deliberately reaching the local network.
The remaining six paths contain no IP address at all. Not in the NsdManager discovery code, not in the MediaRouter scan, not in the OkHttp wrapper that uses whatever host the settings screen handed it. Whether a call leaves for the LAN is decided by where it points at runtime, not by how it looks on the page. Grep cannot see that.
Missing them would be bad enough. The worse part is the feeling those two hits leave behind — that two edits will do it. Once a number appears, you tend to treat it as the whole picture.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A grep for '192.168' caught 2 of 11 LAN paths — mDNS, Cast, and configurable hosts carry no IP literals at all
✦Full source for lan_scan.py: route-shaped detection, three confidence tiers, exit 1 when the permission is undeclared
✦lan_tasks.py turns 'make it Android 17 ready' into eleven tasks an agent has to answer one by one
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
So I wrote a detector that looks for route shapes instead of IP literals, sorted into three confidence tiers. certain means the destination is readable as LAN from the code alone. likely means it could become LAN depending on a runtime value. review covers traces left in configuration files.
The tiers exist because I knew from the start that likely would never fully collapse. Whether the host in Socket(host, port) points at a LAN address cannot be settled by reading the source. But recording that it cannot be settled keeps it on a list a human will revisit. Rather than dropping the ambiguity, hand it forward unresolved.
#!/usr/bin/env python3"""lan_scan.py — find every call site that needs local network permission."""import jsonimport reimport sysfrom pathlib import PathPRIVATE_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})")# (confidence, rule name, pattern, why it leaves for the LAN)RULES = [ ("certain", "private-ipv4-literal", rf"(?<![\d.]){PRIVATE_V4}(?![\d.])", "private or link-local IPv4 literal"), ("certain", "mdns-local-host", r"[A-Za-z0-9_-]+\.local(?![A-Za-z0-9_-])", "mDNS .local hostname"), ("certain", "broadcast-address", r"255\.255\.255\.255", "UDP broadcast"), ("certain", "nsd-discovery", r"\bNsdManager\b|\bdiscoverServices\s*\(|\bresolveService\s*\(", "service discovery over NSD/mDNS"), ("certain", "multicast-lock", r"\bcreateMulticastLock\s*\(|CHANGE_WIFI_MULTICAST_STATE", "multicast reception"), ("certain", "media-router-scan", r"\bCastContext\b|CALLBACK_FLAG_PERFORM_ACTIVE_SCAN" r"|\bMediaRouter\b\s*\.\s*addCallback", "active Cast/MediaRouter scan"), ("likely", "raw-socket", r"\b(?:Socket|DatagramSocket|ServerSocket|MulticastSocket)\s*\(", "raw socket whose host is decided at runtime"), ("likely", "inet-resolve", r"InetAddress\s*\.\s*(?:getByName|getAllByName)\s*\(", "runtime resolution that may land on a private address"), ("likely", "configurable-base-url", r"\b(?:baseUrl|BASE_URL|host|HOST|endpoint|ENDPOINT|serverUrl|SERVER_URL)\b\s*[:=]", "host swappable via settings or remote config"), ("likely", "emulator-host", r"(?<![\d.])10\.0\.2\.2(?![\d.])", "emulator host loopback"), ("likely", "webview-load", r"\bloadUrl\s*\(|\bloadDataWithBaseURL\s*\(", "WebView may load a LAN URL"), ("review", "cleartext-domain-config", r"cleartextTrafficPermitted\s*=\s*[\"']true[\"']", "cleartext allowance, usually a leftover from LAN traffic"),]SKIP_DIRS = {"build", ".git", ".gradle", "node_modules"}EXTS = {".kt", ".java", ".xml", ".json", ".properties"}# Naive comment stripping eats the // in URLs, so never treat a slash pair# that directly follows a scheme as the start of a comment._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 # Several rules can hit one line. Keep the strongest confidence. 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 hits with no declaration means silence in the field. if counts["certain"] and not report["declared_permission"]: print(f"\nFAIL: {counts['certain']} certain hits, " "LOCAL_NETWORK_ACCESS not declared", file=sys.stderr) return 1 return 0if __name__ == "__main__": sys.exit(main())
Eleven files against grep's two. The gap is nine files, and the breakdown tells you what kind of gap it is: exactly one of those nine contains an IP literal at all (Nas.kt, with 172.20.3.14). The other eight sit somewhere no amount of careful address regex will ever reach.
Defaults.kt's 10.0.2.2 firing private-ipv4-literal is the detector overreaching — it falls inside 10/8, but it is emulator host loopback, not LAN. I left it that way on purpose. I would rather push a false positive at a human than tune away a real one.
The comment stripper was eating my URLs
My first version stripped comments the obvious way: find //, drop everything after it. Three lines.
def strip_comment(line: str) -> str: # first version — this is broken for marker in ("//", "<!--", "#"): i = line.find(marker) if i >= 0: line = line[:i] return line
It ran, printed total: 26 / certain: 16, and returned FAIL on cue. Numbers on screen, exit code 1. It looked alive.
Except line 4 of Defaults.kt was missing from the results.
const val OLLAMA_FALLBACK = "http://192.168.11.8:11434"
The // in http:// was read as the start of a comment, and the rest of the line went in the bin. The detector was dropping a hit that plain grep had already found — and it dropped it quietly, with the report looking perfectly well-formed all the way through.
The fix is a negative lookbehind: do not treat a slash pair as a comment if a colon or slash precedes it.
A small bug, but I try not to shrug at this particular shape. A tool that silently loses items is the same failure mode as discovery returning zero results without permission — the very thing the article is about. The instrument fell out of its own inventory. So now, whenever I write a detector, I plant a hit I know it must find and confirm it appears. Here, grep's two hits became the detector's floor.
Turning "make it Android 17 ready" into eleven checkable things
Here is the part I actually care about. What happens when you tell an Antigravity agent add support for Android 17's local network permission?
On my machine it added one <uses-permission> line to the manifest and reported success.
I cannot really fault it. Given a vague instruction, a model picks the smallest change that makes the request coherent within what it can find. The agent greps too, so it falls into the same hole I did. The difference is that it announces completion with more confidence than I would have.
So I changed the instruction. Not "add support," but eleven tasks in JSON, each carrying the questions that have to be answered.
#!/usr/bin/env python3"""lan_tasks.py — fold lan_scan.py output into units an agent can work through."""import jsonimport sysQUESTION = { "private-ipv4-literal": "Is this destination on the same LAN? Loopback/emulator needs no permission", "emulator-host": "Debug builds only? If it ships, confirm the production destination", "mdns-local-host": "If the name resolves via mDNS, discovery itself needs the grant", "broadcast-address": "Broadcast goes silent without permission. Add a degraded state", "nsd-discovery": "Discovery returns zero. Can the user see why?", "multicast-lock": "The lock acquires but nothing arrives. Check the timeout handling", "media-router-scan": "Cast devices never list. Write the empty-list copy", "raw-socket": "Can this reach a LAN host? If so, is the failure being swallowed?", "inet-resolve": "Can the resolved address be private?", "configurable-base-url": "Is there a path for the user to enter a LAN host?", "webview-load": "Can the loaded URL point at the LAN? Can it show an error page?", "cleartext-domain-config": "Is the cleartext allowance for LAN traffic? Drop it if not",}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, )) # Human-readable form goes to stderr so it never pollutes the pipe. 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()
Running it:
$ 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)
- [ ] Broadcast goes silent without permission. Add a degraded state
- [ ] Can the resolved address be private?
- [ ] Can this reach a LAN host? If so, is the failure being swallowed?
- [ ] The lock acquires but nothing arrives. Check the timeout handling
[certain] app/src/main/java/net/example/app/Discovery.kt (L6,7,8,10)
- [ ] Discovery returns zero. Can the user see why?
[likely] app/src/main/java/net/example/app/LlmClient.kt (L6)
- [ ] Is there a path for the user to enter a LAN host?
Eleven tasks. What goes to the agent is tasks.json, and the instruction becomes: to move a status to done, show the answer to each item in checks inside the diff.
The thing that made the difference: the definition of done stayed on my side. With "add support," the agent decides when it is finished. With eleven tasks, done is "eleven entries marked done" — something I can count. I covered the same idea in teaching agents to stop self-certifying completion, but here the checklist items come out of the detector automatically, so there is much less room for me to forget one.
What to show when the grant never comes
The inventory is only half the job. You still have to decide how the app behaves when the user declines.
And they will decline. "Allow this app to find devices on your local network" does not carry the obvious purpose that camera or microphone prompts do. If I were the user, I would refuse at least once.
That is where Discovery.kt's check — "discovery returns zero, can the user see why?" — earns its place. Discovery without permission succeeds with zero results, so the naive implementation prints "no devices found." The user suspects Wi-Fi, restarts Ollama, walks over to the router. The actual cause is a toggle in system settings, and nothing on screen points there.
So branch the copy on permission state when discovery comes back empty.
sealed interface DiscoveryOutcome { data class Found(val services: List<NsdServiceInfo>) : DiscoveryOutcome /** Permission is missing, so discovery never really ran. */ data object BlockedByPermission : DiscoveryOutcome /** Permission is granted and there is genuinely nothing out there. */ data object EmptyNetwork : DiscoveryOutcome}class DiscoveryReporter(private val context: Context) { private fun hasLocalNetworkAccess(): Boolean = ContextCompat.checkSelfPermission(context, LOCAL_NETWORK_ACCESS) == PackageManager.PERMISSION_GRANTED /** * Zero results means both "nothing found" and "could not look". * Without this branch, the user keeps suspecting the wrong thing. */ fun classify(services: List<NsdServiceInfo>): DiscoveryOutcome = when { services.isNotEmpty() -> DiscoveryOutcome.Found(services) !hasLocalNetworkAccess() -> DiscoveryOutcome.BlockedByPermission else -> DiscoveryOutcome.EmptyNetwork } companion object { // The string can move between platform generations. Keep one reference. const val LOCAL_NETWORK_ACCESS = "android.permission.LOCAL_NETWORK_ACCESS" }}
The order of the when branches carries meaning. Success first, permission second, genuinely-empty last. Put the permission check at the bottom and it becomes easy to slip in a branch that returns the empty list before you ever get there. Whatever ordering can protect, let ordering protect.
The permission string lives in a companion object because I already know it may move. Scattering a value you expect to change across eleven files buys you the same inventory exercise all over again next release.
You also have to decide what BlockedByPermission actually renders. I went with a single line plus a link into app settings, rather than re-prompting. Showing the same dialog to someone who already said no is not a pleasant way to treat them.
One more consequence: the configurable-base-url sites like LlmClient need the same judgement. A user-entered 192.168.11.8 needs the grant; api.example.com does not. A small runtime check on the destination, kept in one place, handles it. It drops cleanly onto the connection layer I described in wiring local LLMs through Ollama and LM Studio.
The migration checklist
The order I actually ran, with a machine-checkable outcome at every step.
Step
Do this
How you know
1
Take a grep baseline
Record the count. This becomes the detector's floor
2
Run lan_scan.py
Its output must contain every hit from step 1. If not, the detector is buggy
3
Review certain
A human drops loopback/emulator false positives
4
Judge likely
One at a time. If you drop one, record why
5
Build tasks with lan_tasks.py
Task count matches file count
6
Hand tasks.json to the agent
Count of status: done matches step 5
7
Declare the permission, rerun
lan_scan.py exits 0
8
Verify the degraded path
On a real device with permission off, zero-results and denied read differently
That this single line passes is precisely why I wrote all of the above. The exit code only knows whether the permission was declared. It knows nothing about whether eleven call sites degrade correctly. The gate exists to catch a missing declaration and nothing more; whether the checks were answered is guaranteed only by step 6. Confuse the two and you ship on the strength of a green light that was never looking.
If you wire this into CI, automate step 7 and keep step 6 human. Green builds and working software being different things is the same lesson as when the deploy was green but users had the old build, and this has the same shape.
Where the tool gives up
To be straight about it, lan_scan.py misses things.
Hosts assembled from strings
It is helpless against reflection and string concatenation. "http://" + prefix + "." + suffix matches no rule I wrote. It cannot see into native libraries. If a third-party SDK speaks mDNS internally, nothing shows up in your source at all.
Traffic inside your dependencies
For dependencies, the merged manifest is the only honest answer. app/build/outputs/logs/manifest-merger-*-report.txt lists which library requested which permission. If LOCAL_NETWORK_ACCESS appears there and you never wrote it, something in your dependency tree is reaching for the LAN.
It is only a static inventory
This is a static inventory. It enumerates places that could go to the local network; it never demonstrates that they do. Only a real device with the permission revoked settles that. Which is why step 8 is not optional.
Drawing the line between what the tool covers and what your own eyes have to cover gets you there faster than trusting the tool — that much I am fairly sure of by now.
What to do next
If you have an Android app in front of you, run grep -rnE "(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|169\.254\.)[0-9]" once.
Zero hits is not the all-clear. If anything, zero is the moment to go look at NsdManager, MediaRouter, and any host the settings screen lets a user type. In my tree, all three of those lived outside grep's reach.
You can fix this after the deadline or inventory before it. The work is identical either way — the second one just spares you the hours spent hunting for the cause of a silence.
Thanks for reading. I hope it gives your own inventory somewhere to start.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.