Android 17 Went Canary-Only, So I Gave My Agent the Silence Rules First
Android 17 dropped Developer Previews in favour of rolling Canary builds, which moved the verification deadline onto my side of the table. Here is how I handed the tracking job to an Antigravity agent by designing when it stays quiet, not when it reports.
When I read that Android 17 was dropping the Developer Preview track in favour of rolling Canary builds, the first thing I thought about was not a feature. It was my calendar.
Previews came with numbers, and numbers came with dates. DP1 lands, you take a look. DP3 lands, you start fixing things properly. As an indie developer maintaining apps on the side, those externally imposed markers quietly doubled as my work plan.
Rolling delivery removes them. Something moves every week, and none of it is final. The deadline stopped arriving from outside, and in its place I inherited a new job: deciding when to look.
For the Android side of my wallpaper apps, that turned out to be a heavier change than it sounded.
Before automating the tracking, I decided what not to track
My first instinct was the obvious one. Point an Antigravity agent at the Canary changelog, have it read every drop, and send me a summary. On paper this is exactly the kind of chore agents are good at.
Then I pictured actually living with it, and stopped. If a summary lands several times a week saying "here is what changed," I will stop opening them by week three. The one message that matters will sit in the same undifferentiated column as everything else. That is not a failure of automation. That is what happens when automation succeeds a little too thoroughly.
So I inverted the design. Instead of deciding what the agent should report, I decided what it must stay silent about, no matter what.
Put differently, the agent is not given "a job of following updates." It is given "a job of checking whether the conditions for staying quiet still hold." It only reaches me when it can no longer stay quiet.
Moving "relevant to me" from intuition into a machine check
To write down silence conditions, "relevant to me" has to become something a machine can evaluate.
The useful side of that comparison turned out to be my own code, not the release notes. The surface area a wallpaper app actually touches is small: import statements, manifest permissions, and foreground service types. List those three and you have described most of your exposure.
Extraction fits in a shell script.
#!/usr/bin/env bash# Print every Android surface this project actually touches, one per line.# Output: kind<TAB>symbol (kind = import / permission / service-type)set -euo pipefailROOT="${1:?usage: inventory.sh <project-root>}"# 1) android.* / androidx.* imports in Kotlin and Java sourcesgrep -rhoE '^import +(android|androidx)[A-Za-z0-9_.]*' "$ROOT" \ --include='*.kt' --include='*.java' 2>/dev/null \ | sed -E 's/^import +//' | sort -u | sed 's/^/import\t/'# 2) manifest permissions, kept fully qualifiedgrep -rhoE 'android\.permission\.[A-Z_]+' "$ROOT" --include='AndroidManifest.xml' 2>/dev/null \ | sort -u | sed 's/^/permission\t/'# 3) foreground service types, tracked individually because requirements differ per typegrep -rhoE 'android:foregroundServiceType="[a-zA-Z|]+"' "$ROOT" --include='AndroidManifest.xml' 2>/dev/null \ | sed -E 's/.*"([a-zA-Z|]+)".*/\1/' | tr '|' '\n' | sort -u | sed 's/^/service-type\t/'
Run against a small project, it produces something like this.
That list is the watch list. Anything changing outside of it is something I do not need to read this week.
✦
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
✦You will be able to design the silence conditions of an update-watching agent before its reporting conditions, so the alerts stay worth reading
✦You will be able to inventory the APIs, permissions and service types your own code actually touches, and receive only the changes that intersect them
✦You will be able to avoid the trap where a relevance filter matches ordinary English words and quietly destroys its own credibility
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.
Keep only the intersecting lines, and express silence as an exit code
The next step compares a list of changes against that inventory. The important part is that the conclusion travels in the exit code, not in the prose. The agent does not have to interpret a paragraph to decide what to do next.
#!/usr/bin/env python3"""Keep only the change lines that touch this project. python3 relevance_filter.py inventory.tsv changes.txtExit codes (the agent branches on these): 0 ... nothing relevant changed (do not report) 10 ... something relevant changed (report) 2 ... input unreadable (a misconfiguration; never silently return 0)"""import reimport sysfrom pathlib import Path# Fragments that collide with ordinary English are never used as standalone keys.# This was the single largest source of false positives.GENERIC = { "internet", "wake_lock", "vibrate", "camera", "storage", "notification", "notifications", "service", "activity", "manager",}def keys_for(symbol: str) -> set: """android.app.WallpaperManager -> full / app.wallpapermanager / wallpapermanager""" parts = [p for p in re.split(r"[.\s]", symbol) if p] out = {symbol.lower()} if parts: last = parts[-1].lower() if last not in GENERIC and len(last) > 3: out.add(last) if len(parts) >= 2: out.add(f"{parts[-2]}.{parts[-1]}".lower()) return outdef load_inventory(path: Path) -> dict: table = {} for line in path.read_text(encoding="utf-8").splitlines(): if not line.strip(): continue kind, _, symbol = line.partition("\t") if not symbol: continue for key in keys_for(symbol): if len(key) > 3: table.setdefault(key, f"{kind}:{symbol}") return tabledef main() -> int: if len(sys.argv) != 3: print(__doc__, file=sys.stderr) return 2 inv_path, changes_path = Path(sys.argv[1]), Path(sys.argv[2]) if not inv_path.is_file() or not changes_path.is_file(): print(f"cannot read input: {inv_path} / {changes_path}", file=sys.stderr) return 2 table = load_inventory(inv_path) if not table: print("inventory is empty; the extractor is probably broken.", file=sys.stderr) return 2 hits = [] for lineno, raw in enumerate(changes_path.read_text(encoding="utf-8").splitlines(), 1): low = raw.lower() why = sorted({w for key, w in table.items() if key in low}) if why: hits.append((lineno, raw.strip(), why)) if not hits: print("Nothing relevant changed.") return 0 for lineno, text, why in hits: print(f"L{lineno}: {text}") for w in why: print(f" <- {w}") print(f"\n{len(hits)} matched / {len(table)} watched keys") return 10if __name__ == "__main__": sys.exit(main())
Feeding it a five-line change list left exactly two lines standing.
L1: Behavior change: WallpaperManager.setBitmap now throws when the caller is in the background.
<- import:android.app.WallpaperManager
L4: Foreground services with type dataSync are limited to 6 hours per day.
<- service-type:dataSync
2 matched / 18 watched keys
The other three were about Credential Manager, Camera2 and Compose. All genuinely important changes, none of which my current code touches. The decision not to read them became mechanical.
An ordinary English word broke the silence
The first version had no GENERIC set. It used the trailing identifier of every symbol as a key, full stop.
Running that version produced matches like these.
L1: Chrome now blocks mixed content over the internet.
<- permission:android.permission.INTERNET
L2: Android 17 tightens INTERNET-less loopback access.
<- permission:android.permission.INTERNET
Because android.permission.INTERNET contributed internet as a key, every sentence containing the ordinary English word matched. A permission that almost every app declares had handed the filter a key that almost every paragraph contains. As filter design goes, that is about as bad as the pairing gets.
In raw numbers the fix was small: 19 watched keys became 18. But that one key was the thing breaking the silence. False positives look like the harmless failure mode, the one where you merely receive some extra notifications. In practice they cause a different failure: the notifications stop being believed. Once that happens, the correct ones go unread too.
Until I made that change I had been worrying almost entirely about the other direction, about missing something that mattered. The dangerous side was the opposite one.
Kind of change
Report?
Reasoning
Line intersecting an inventory key
Yes
It touches a surface the code actually uses
New API only, no effect on existing surfaces
No
Adoption can wait for the quarterly checkpoint
Deprecation notice with removal a release away
No
Reading it weekly changes no behaviour
Foreground service type requirement change
Yes
Can fail both review and runtime
Extractor returned zero entries
Yes
Silence and breakage must be distinguishable
That last row was added later. An absence of reports looks identical from the outside whether things are calm or broken. Deciding that an empty inventory exits with code 2 and makes noise is what made the quiet trustworthy.
The instructions to the agent came out to three steps
What actually goes to Antigravity is surprisingly short. Pushing the judgement into the script left very little for the prompt to carry.
1. Run `./inventory.sh .` and save the output as inventory.tsv2. Fetch the change list being watched as changes.txt3. Run `python3 relevance_filter.py inventory.tsv changes.txt` - exit 0 ... do nothing, leave no report - exit 10 ... report with the output attached verbatim; do not summarise or paraphrase - exit 2 ... report that the watch itself is broken
The "do not summarise" clause earned its place in practice. Summaries read nicely, but they tend to drop the qualifiers that carry the actual risk, such as which API level the behaviour starts at or which service type it is limited to. If only two lines matched, handing me those two lines is both faster and safer.
Summarising pays off when the volume is high. If the filter is doing its job, the volume is low, and low volume does not need summarising. The two mechanisms turned out to be substitutes rather than complements.
Three things I look at myself, once a quarter
Handing the weekly judgement to a machine meant I had to decide where my own attention goes. Since the external markers are gone, I have to place my own.
The diff of the inventory itself. Which APIs, permissions or service types appeared since last time? A newly touched surface is a newly watched one.
Deprecations parked earlier. Everything silenced weekly gets reviewed together here.
A minimal on-device check. Install a Canary build and confirm just two paths: applying a wallpaper, and receiving a notification.
Three items, deliberately. A longer list does not get executed, and an operating rule that is not executed is indistinguishable from one that was never written.
Build the safety net for what the filter misses
This filter will miss things. Behaviour changes that never appear in release notes cannot be caught in principle, and ones phrased differently will slip past the string match.
So I spend roughly as much effort on the net underneath as on the accuracy of the detector. For me that net is staged rollout in the store: ship to a small percentage first, watch crash-free users and ANR before widening. That practice predates Android 17, but with the external verification markers gone, it now carries more of the weight.
Before writing any filter, write down five changes you can confidently say you do not need to hear about. If you can name five, that standard can become code. If you cannot, the condition you write will end up as something like "anything that looks relevant," and an ordinary English word will break your silence within the first week.
That detour is exactly the one I took, and it is the part I would skip if I were starting again.
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.