SLOPSHOPPER

android-project-lens

Android project facts for the model: a nav_graph route index, Compose stability reports on demand, and the real manifest SDKs and permissions.

guardcommandprompttool
A shopper browsing a rack in a slop shop
README

claude-android-mods

Four Claude Code mods for Android and Kotlin Multiplatform work. A mod is a plugin of function hooks: it changes what Claude Code shows and does, by drawing a band or pane, adding tools the model can call, or rewriting the model's tool calls before they run.

ModWhat it gives you
build-output-distillerGradle output becomes a short digest: Kotlin, KSP, Hilt/Dagger, Room, AAPT, R8 and test failures as file:line lists with fix hints. Repeat failures are flagged. /build opens the errors in a pane.
android-logcatBare adb logcat calls get a bound (-d -t 800, or -t 200 --pid=<pid>) so they can't hang. Secrets are redacted from adb output. A pid-scoped buffer feeds a logcat_grep tool and a /logcat pane.
android-device-consoleA band of live toggles above the prompt: dark mode, font scale, density, RTL, layout bounds, animations. /device adds Doze, standby bucket and scheduled jobs with Run buttons. Tools: set_device_state, set_doze, run_worker. The system prompt states the current device state.
android-project-lensA nav_graph tool that indexes @Serializable routes and where nav hosts register them. /stability gives Compose stability reports on demand. The system prompt gets the real targetSdk, minSdk and permissions. Claude asks before adding Play-declaration permissions.

The mods don't overlap: only build-output-distiller hooks Gradle commands, and only android-logcat hooks adb logcat.

Requirements

  • Claude Code 2.1.299 or newer. Function-hook mods are early access, and older builds reject some of the events these mods use. Check with claude --version.
  • The Android SDK's adb on your PATH, or ANDROID_HOME / ANDROID_SDK_ROOT set, for the three mods that talk to a device.
  • An Android or KMP project as the session's working directory.

Install

Install from a terminal session. Each mod installs on its own, so install only the ones you want.

At the Claude Code prompt:

/plugin install android-device-console --marketplace NathanWA/claude-android-mods
/plugin install android-logcat --marketplace NathanWA/claude-android-mods
/plugin install build-output-distiller --marketplace NathanWA/claude-android-mods
/plugin install android-project-lens --marketplace NathanWA/claude-android-mods

The first install asks Add marketplace?: answer y. Then choose a scope; the user scope is first and is the usual choice. The mod is active in that session right away and in every session you start after.

From a shell:

claude plugin install android-device-console --marketplace NathanWA/claude-android-mods

In the Claude desktop app, /plugin doesn't run in the Code tab. Click + next to the prompt box, then Plugins, then Add marketplace with NathanWA/claude-android-mods, then Add plugin. A mod you install from a terminal at user scope also loads in the desktop app's local sessions.

To update later:

claude plugin update android-device-console

Run

There's nothing to start. Open Claude Code in your Android project and the mods work on their own:

  • build-output-distiller: ask Claude to build or test (./gradlew assembleDebug, testDebugUnitTest). The model reads a digest instead of the raw log. Run /build to see the last build's errors in a pane.
  • android-logcat: when Claude runs adb logcat, the call is bounded and redacted. The model can search the buffer with logcat_grep. Run /logcat to see the buffer. The buffer follows the app when it restarts and gets a new pid, and logcat_grep warns if that pid looks stale.
  • android-device-console: with a device or emulator connected, run /device to open the console. After the first read, the band of toggles stays above the prompt; press 1–6 once the band has focus. Ask Claude things like "screenshot the settings screen in dark mode at font scale 1.3": it calls set_device_state itself. /device reset restores the defaults.
  • android-project-lens: ask about navigation ("which routes take an id?") and the model uses nav_graph. Run /stability to compile once with Compose compiler reports, or /stability read to read existing reports.

Permission prompts in the desktop app

Several mods run adb through Claude Code's $.process, which is documented as CLI-only. Where it isn't available, as in the desktop app, they fall back to the Bash tool, and each call asks for permission. To stop the prompts, add this to permissions.allow in ~/.claude/settings.json:

"Bash(adb *)"

The mods never poll through the Bash tool. In the desktop app, device state is re-read only when you run a command, press a button, or the model calls a tool.

Choosing a device

android-device-console picks its target in this order: ANDROID_SERIAL, then the device you picked in /device, then the only connected device. When several devices are connected and none is chosen, it asks rather than guesses.

Options

android-device-console has two options, which you can set in /config or under pluginConfigs in settings:

  • package: the applicationId. When empty, it is read from app/build.gradle(.kts).
  • pollSeconds: how often device state is refreshed when $.process works. The default is 15; 0 turns refreshing off.

Status

These mods are early. Each passes claude plugin validate and its own claude plugin test suite, but they have had little use on real projects and devices. Known gaps:

  • dumpsys output differs between API levels. The parsers read it defensively, but untested API levels may show ?.
  • Forced RTL uses a per-app Hebrew locale on API 33+ and needs the app's package. Below API 33 it falls back to debug.force_rtl, which may need a reboot. Layout bounds use a service call that some system images ignore.
  • android-project-lens's /stability Gradle init script has not yet been run against every AGP and Kotlin version.

Issues and pull requests are welcome.

Develop

Clone the repository and load a mod from disk for one session:

claude --plugin-dir plugins/android-device-console

Check and test a mod:

claude plugin validate plugins/android-device-console
claude plugin test plugins/android-device-console

Each mod's tsconfig.json extends the types Claude Code writes into .claude-plugin/types/ when it loads the mod. After one load, tsc -p plugins/<mod> type-checks it.

License

MIT

Source 7 files
hooks/register.ts 252 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LensManifest, LensNavIndex, LensStability } from '../types'
5
6import { addedPermissions, describeManifest, playDeclaration } from './manifest'
7import { describeGraph, routeKey, unregisteredRoutes } from './nav'
8import { absolute, gatherManifest, indexNav, join, loadStability, refreshNav, relative, reportIndexPath, scanProject } from './project'
9import type { Project } from './project'
10import { EMPTY_STABILITY, describeStability, factsForEdit } from './stability'
11
12const PLUGIN = 'android-project-lens'
13
14const rootA = atom({ plugin: 'android-project-lens', key: 'root' } as const, '')
15const navA = atom({ plugin: 'android-project-lens', key: 'nav' } as const, { files: {}, indexedAt: 0 } as LensNavIndex)
16const dirtyA = atom({ plugin: 'android-project-lens', key: 'dirtyKt' } as const, [] as string[])
17const reportedA = atom({ plugin: 'android-project-lens', key: 'reportedUnregistered' } as const, [] as string[])
18const stabilityA = atom({ plugin: 'android-project-lens', key: 'stability' } as const, EMPTY_STABILITY as LensStability)
19const manifestA = atom({ plugin: 'android-project-lens', key: 'manifest' } as const, null as LensManifest | null)
20
21function shellQuote(s: string): string {
22  return `'${s.replace(/'/g, `'\\''`)}'`
23}
24
25// The walk's result, for re-reading manifests; session.start runs again on a reload.
26let project: Project | null = null
27
28/** The route index with every .kt file edited since the last read parsed again. */
29async function freshNav($: EngineInterface): Promise<LensNavIndex> {
30  const root = await read($, rootA)
31  const dirty = await read($, dirtyA)
32  const nav = await read($, navA)
33  if (dirty.length === 0) return nav
34  const next = await refreshNav($, root, nav, dirty, await $.clock.now())
35  await update($, navA, () => next)
36  await update($, dirtyA, list => list.filter(p => !dirty.includes(p)))
37  return next
38}
39
40async function refreshManifest($: EngineInterface): Promise<void> {
41  const root = await read($, rootA)
42  if (root.length === 0) return
43  project = project ?? (await scanProject($, root))
44  const facts = await gatherManifest($, project)
45  await update($, manifestA, () => facts)
46}
47
48export const register: Register = on => {
49  on('session.start', async ($, e, next) => {
50    const started = await next(e)
51    const root = e.cwd
52    const settings = await Promise.all(
53      ['settings.gradle.kts', 'settings.gradle'].map(n => $.fs.exists(join(root, n)).catch(() => false)),
54    )
55    if (!settings.some(Boolean)) return started
56
57    await update($, rootA, () => root)
58    try {
59      await $.tool.register({
60        name: 'nav_graph',
61        description:
62          "This Android project's navigation routes, read from the source: every @Serializable route type, its constructor arguments, " +
63          'where it is declared and where a nav host registers it (Navigation 2 composable<X>/dialog<X>/navigation<X>, Navigation 3 entry<X>). ' +
64          'Use it instead of grepping for routes. Pass `route` (any part of a route name) for one route; leave it out for the whole graph, ' +
65          'which also flags routes no nav host registers.',
66        inputSchema: {
67          type: 'object',
68          properties: { route: { type: 'string', description: 'Part of a route name, such as "Detail" or "Route.Settings".' } },
69        },
70        isDeferred: false,
71      })
72    } catch (err) {
73      $.ui.log(`${PLUGIN}: nav_graph tool not registered: ${String(err)}`, { to: 'debug' })
74    }
75    try {
76      await $.command.register({
77        name: 'stability',
78        description: 'Compile once with Compose compiler reports and list composables with unstable parameters',
79        argumentHint: '[gradle task, default compileDebugKotlin | read]',
80      })
81    } catch (err) {
82      $.ui.log(`${PLUGIN}: /stability not registered: ${String(err)}`, { to: 'debug' })
83    }
84
85    const now = await $.clock.now()
86    project = await scanProject($, root)
87    const nav = await indexNav($, project, now)
88    await update($, navA, () => nav)
89    await update($, dirtyA, () => [])
90    // Routes already unregistered are the baseline: a turn reports only new ones.
91    await update($, reportedA, () => unregisteredRoutes(nav).map(routeKey))
92    const facts = await gatherManifest($, project)
93    await update($, manifestA, () => facts)
94    const { table } = await loadStability($, root, await read($, stabilityA))
95    await update($, stabilityA, () => table)
96    return started
97  })
98
99  // ---- Nav --------------------------------------------------------------
100
101  on('tool.call', { tool: 'mcp__android-project-lens__nav_graph' }, async ($, e) => {
102    const nav = await freshNav($)
103    const route = typeof e['route'] === 'string' && e['route'].length > 0 ? e['route'] : undefined
104    return { result: describeGraph(nav, route) }
105  }).catch(() => ({ result: `${PLUGIN}: the route index could not be read this time; search the sources for @Serializable routes instead.` }))
106
107  on('turn.complete', async ($, e, next) => {
108    const done = await next(e)
109    if (e.agentId !== undefined) return done
110    const root = await read($, rootA)
111    if (root.length === 0) return done
112
113    const nav = await freshNav($)
114    const unregistered = unregisteredRoutes(nav)
115    const before = await read($, reportedA)
116    const fresh = unregistered.filter(r => !before.includes(routeKey(r)))
117    await update($, reportedA, () => unregistered.map(routeKey))
118    await refreshManifest($)
119
120    if (fresh.length > 0) {
121      const text =
122        `${PLUGIN}: ${fresh.length === 1 ? 'a route is' : `${fresh.length} routes are`} declared but no nav host registers ` +
123        `${fresh.length === 1 ? 'it' : 'them'} (no composable<X>, dialog<X>, navigation<X> or entry<X>): ` +
124        `${fresh.map(r => `${r.qualified} (${r.file}:${r.line})`).join(', ')}. Navigating to an unregistered route crashes at runtime.`
125      $.ui.log(text)
126      try {
127        await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
128      } catch {
129        // the log line above still tells the person
130      }
131    }
132    return done
133  })
134
135  // ---- Edits of Kotlin files: nav refresh and stability facts ------------
136
137  on('tool.call', { tool: ['Edit', 'Write'], file_path: /\.kt$/ }, async ($, e, next) => {
138    const ran = await next(e)
139    if (ran.deny !== undefined || ran.isError === true) return ran
140    const root = await read($, rootA)
141    if (root.length === 0) return ran
142
143    const path = relative(root, absolute(root, e.file_path))
144    await update($, dirtyA, list => (list.includes(path) ? list : [...list, path]))
145
146    let table = await read($, stabilityA)
147    try {
148      const st = await $.fs.stat(reportIndexPath(root))
149      if (st.mtimeMs !== table.indexMtime) {
150        table = (await loadStability($, root, table)).table
151        await update($, stabilityA, () => table)
152      }
153    } catch {
154      // no /stability run in this project yet
155    }
156    const facts = factsForEdit(table, e.tool === 'Edit' ? e.new_string : e.content)
157    if (facts.length === 0) return ran
158    return { ...ran, context: [...(ran.context ?? []), ...facts] }
159  }).catch(($, e, next) => next(e))
160
161  // ---- Stability --------------------------------------------------------
162
163  on('command.run', { command: 'stability' }, async ($, e) => {
164    const root = await read($, rootA)
165    if (root.length === 0) return { text: 'android-project-lens: this session is not in a Gradle project.' }
166    const args = e.args.trim()
167    const notes: string[] = []
168
169    if (args !== 'read') {
170      const tasks = args.length > 0 ? args : 'compileDebugKotlin'
171      if (!/^[\w:.\- ]+$/.test(tasks)) return { text: `Not a Gradle task list: ${tasks}` }
172      const init = `${$.plugin.root}/gradle/compose-reports.init.gradle`
173      const command =
174        `cd ${shellQuote(root)} && ./gradlew ${tasks} --init-script ${shellQuote(init)} ` +
175        '-Pkotlin.incremental=false -Pkotlin.incremental.multiplatform=false --no-configuration-cache --console=plain'
176      let ran
177      try {
178        ran = await $.tool.call({ tool: 'Bash', command, description: 'Compile with Compose compiler reports (/stability)', timeout: 600_000 })
179      } catch (err) {
180        ran = { deny: String(err) }
181      }
182      if (ran.deny !== undefined) {
183        return { text: `The build did not run (${ran.deny}). Run it yourself, then /stability read:\n${command}` }
184      }
185      if (ran.isError === true) notes.push('The build reported an error; the reports cover what compiled.')
186    }
187
188    const { table, reportFiles } = await loadStability($, root, await read($, stabilityA))
189    await update($, stabilityA, () => table)
190    if (reportFiles === 0) {
191      return {
192        text:
193          'No Compose compiler reports found. /stability needs the Kotlin Compose compiler Gradle plugin (Kotlin 2.0+) ' +
194          'and a task that compiles Compose code; pass one, such as `/stability :app:compileDebugKotlin` or `compileDebugKotlinAndroid` for KMP.',
195      }
196    }
197    return { text: [...notes, describeStability(table)].join('\n') }
198  })
199
200  // ---- Manifest ---------------------------------------------------------
201
202  on('prompt.compose', async ($, e, next) => {
203    const composed = await next(e)
204    const facts = await read($, manifestA)
205    if (facts === null) return composed
206    return {
207      sections: [...composed.sections, { id: `${PLUGIN}:manifest`, text: describeManifest(facts), scope: 'session' as const }],
208    }
209  })
210
211  // Judged before next: a deny after the edit ran would undo nothing.
212  on('tool.call', { tool: ['Edit', 'Write'], file_path: /(^|\/)AndroidManifest\.xml$/ }, async ($, e, next) => {
213    let before = ''
214    let after: string
215    if (e.tool === 'Write') {
216      before = await $.fs.read(e.file_path).then(t => (typeof t === 'string' ? t : ''), () => '')
217      after = e.content
218    } else {
219      const text = await $.fs.read(e.file_path).then(t => (typeof t === 'string' ? t : null), () => null)
220      if (text === null || !text.includes(e.old_string)) return next(e) // the Edit fails on its own
221      before = text
222      after = e.replace_all === true ? text.split(e.old_string).join(e.new_string) : text.replace(e.old_string, () => e.new_string)
223    }
224
225    const needsDeclaration = addedPermissions(before, after).filter(p => playDeclaration(p) !== undefined)
226    if (needsDeclaration.length > 0) {
227      const names = needsDeclaration.map(p => p.replace(/^android\.permission\./, ''))
228      const why = needsDeclaration.map(p => `${p.replace(/^android\.permission\./, '')}: ${playDeclaration(p)}`).join('; ')
229      let answer: string
230      try {
231        answer = await $.ui.ask(
232          `This edit adds ${names.join(', ')} to ${relative(await read($, rootA), e.file_path)}. Google Play needs a Play Console declaration for ${names.length === 1 ? 'it' : 'them'} (${why}). Add ${names.length === 1 ? 'it' : 'them'}?`,
233          { options: ['Add it', 'Leave it out'], header: 'Permission' },
234        )
235      } catch {
236        return { deny: `${PLUGIN}: adding ${names.join(', ')} needs a Play Console declaration and the question was dismissed; ask the person before adding it.` }
237      }
238      if (answer !== 'Add it') {
239        return {
240          deny: `${PLUGIN}: the person chose not to add ${names.join(', ')} (answer: ${answer}). Find an approach that does not need ${names.length === 1 ? 'it' : 'them'}.`,
241        }
242      }
243    }
244
245    const ran = await next(e)
246    if (ran.deny === undefined && ran.isError !== true) await refreshManifest($)
247    return ran
248  }).catch(($, e, next) =>
249    next.called ? next(e) : { deny: `${PLUGIN}: its manifest permission check failed, so this manifest edit was held back. Try again, or edit it by hand.` },
250  )
251}
252
hooks/manifest.ts 197 lines
1// AndroidManifest.xml without an XML parser: the sandbox has none, and a
2// manifest is regular enough for a hand tokenizer.
3
4import type { LensManifest, LensPermission } from '../types'
5
6export type XmlElement = { name: string; attrs: Record<string, string>; line: number }
7
8const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
9
10function decode(value: string): string {
11  return value.replace(/&(#x[0-9a-fA-F]+|#\d+|\w+);/g, (whole, ref: string) => {
12    if (ref.startsWith('#x')) return String.fromCodePoint(parseInt(ref.slice(2), 16))
13    if (ref.startsWith('#')) return String.fromCodePoint(parseInt(ref.slice(1), 10))
14    return ENTITIES[ref] ?? whole
15  })
16}
17
18/** Every start (or empty) element of the document, in order, with its attributes. */
19export function tokenizeXml(src: string): XmlElement[] {
20  const elements: XmlElement[] = []
21  let i = 0
22  let line = 1
23  const advance = (to: number) => {
24    for (let k = i; k < to && k < src.length; k++) if (src[k] === '\n') line++
25    i = to
26  }
27  while (i < src.length) {
28    const lt = src.indexOf('<', i)
29    if (lt === -1) break
30    advance(lt)
31    if (src.startsWith('<!--', i)) {
32      const end = src.indexOf('-->', i + 4)
33      advance(end === -1 ? src.length : end + 3)
34      continue
35    }
36    if (src.startsWith('<![CDATA[', i)) {
37      const end = src.indexOf(']]>', i)
38      advance(end === -1 ? src.length : end + 3)
39      continue
40    }
41    if (src.startsWith('<?', i) || src.startsWith('<!', i) || src.startsWith('</', i)) {
42      const end = src.indexOf('>', i)
43      advance(end === -1 ? src.length : end + 1)
44      continue
45    }
46    // A start tag: find its end outside quotes.
47    let j = i + 1
48    let quote: string | null = null
49    while (j < src.length) {
50      const c = src[j]
51      if (quote !== null) {
52        if (c === quote) quote = null
53      } else if (c === '"' || c === "'") quote = c
54      else if (c === '>') break
55      j++
56    }
57    const body = src.slice(i + 1, j).replace(/\/\s*$/, '')
58    const nameMatch = /^([\w:.-]+)/.exec(body)
59    if (nameMatch !== null) {
60      const attrs: Record<string, string> = {}
61      for (const a of body.slice(nameMatch[0].length).matchAll(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
62        attrs[a[1] as string] = decode(a[2] ?? a[3] ?? '')
63      }
64      elements.push({ name: nameMatch[1] as string, attrs, line })
65    }
66    advance(j + 1)
67  }
68  return elements
69}
70
71const PERMISSION_TAGS = new Set(['uses-permission', 'uses-permission-sdk-23', 'uses-permission-sdk-m'])
72
73/** The permissions a manifest asks for, and those it removes with tools:node="remove". */
74export function permissionsOf(xml: string): { declared: string[]; removed: string[] } {
75  const declared = new Set<string>()
76  const removed = new Set<string>()
77  for (const el of tokenizeXml(xml)) {
78    if (!PERMISSION_TAGS.has(el.name)) continue
79    const name = el.attrs['android:name']
80    if (name === undefined || name.length === 0) continue
81    if (el.attrs['tools:node'] === 'remove' || el.attrs['tools:node'] === 'removeAll') removed.add(name)
82    else declared.add(name)
83  }
84  return { declared: [...declared], removed: [...removed] }
85}
86
87/** `<uses-sdk>` of a (merged) manifest. */
88export function usesSdk(xml: string): { minSdk?: string; targetSdk?: string } {
89  const el = tokenizeXml(xml).find(e => e.name === 'uses-sdk')
90  if (el === undefined) return {}
91  const out: { minSdk?: string; targetSdk?: string } = {}
92  const min = el.attrs['android:minSdkVersion']
93  const target = el.attrs['android:targetSdkVersion']
94  if (min !== undefined) out.minSdk = min
95  if (target !== undefined) out.targetSdk = target
96  return out
97}
98
99/** Permissions the edit adds: in `after` and not in `before`. */
100export function addedPermissions(before: string, after: string): string[] {
101  const was = new Set(permissionsOf(before).declared)
102  return permissionsOf(after).declared.filter(p => !was.has(p))
103}
104
105/**
106 * Permissions Google Play asks a declaration for in the Play Console before
107 * the app can ship with them, and why.
108 */
109const PLAY_DECLARATIONS: Record<string, string> = {
110  'android.permission.ACCESS_BACKGROUND_LOCATION': 'background location: Play Console location declaration and a video of the feature',
111  'android.permission.READ_SMS': 'SMS group: Play only allows it for default SMS handlers or listed exceptions',
112  'android.permission.SEND_SMS': 'SMS group: Play only allows it for default SMS handlers or listed exceptions',
113  'android.permission.RECEIVE_SMS': 'SMS group: Play only allows it for default SMS handlers or listed exceptions',
114  'android.permission.RECEIVE_MMS': 'SMS group: Play only allows it for default SMS handlers or listed exceptions',
115  'android.permission.RECEIVE_WAP_PUSH': 'SMS group: Play only allows it for default SMS handlers or listed exceptions',
116  'android.permission.READ_CALL_LOG': 'Call Log group: Play only allows it for default phone handlers or listed exceptions',
117  'android.permission.WRITE_CALL_LOG': 'Call Log group: Play only allows it for default phone handlers or listed exceptions',
118  'android.permission.PROCESS_OUTGOING_CALLS': 'Call Log group: Play only allows it for default phone handlers or listed exceptions',
119  'android.permission.MANAGE_EXTERNAL_STORAGE': 'All files access: Play declaration; most apps must use MediaStore or SAF instead',
120  'android.permission.QUERY_ALL_PACKAGES': 'package visibility: Play declaration; a <queries> element usually suffices',
121  'android.permission.REQUEST_INSTALL_PACKAGES': 'installing packages: Play declaration of the core feature that needs it',
122  'android.permission.READ_MEDIA_IMAGES': 'photo and video permissions: Play declaration; one-time access through the photo picker needs none',
123  'android.permission.READ_MEDIA_VIDEO': 'photo and video permissions: Play declaration; one-time access through the photo picker needs none',
124  'android.permission.USE_EXACT_ALARM': 'exact alarms: Play only allows it for alarm and calendar apps; SCHEDULE_EXACT_ALARM or inexact alarms otherwise',
125  'android.permission.USE_FULL_SCREEN_INTENT': 'full-screen intents: Play declaration for apps targeting Android 14+, limited to calls and alarms',
126  'android.permission.BIND_ACCESSIBILITY_SERVICE': 'accessibility API: Play declaration and prominent disclosure',
127  'android.permission.BIND_VPN_SERVICE': 'VpnService: Play declaration of the VPN core feature',
128}
129
130/** Why Play needs a declaration for this permission, or undefined when it needs none. */
131export function playDeclaration(permission: string): string | undefined {
132  const known = PLAY_DECLARATIONS[permission]
133  if (known !== undefined) return known
134  if (permission.startsWith('android.permission.FOREGROUND_SERVICE_')) {
135    return 'foreground service type: Play Console foreground service declaration for apps targeting Android 14+'
136  }
137  if (permission.startsWith('android.permission.health.')) return 'Health Connect data: Play Console health permissions declaration'
138  return undefined
139}
140
141/**
142 * Attributions from a manifest-merger report: permission -> the library
143 * coordinates (or file paths) it was merged from. Unofficial AGP format, so
144 * anything unrecognised is left out rather than guessed.
145 */
146export function parseMergerReport(text: string): Record<string, string[]> {
147  const out: Record<string, string[]> = {}
148  let current: string | null = null
149  for (const line of text.split('\n')) {
150    const head = /^uses-permission(?:-sdk-23|-sdk-m)?#(\S+)/.exec(line)
151    if (head !== null) {
152      current = head[1] as string
153      out[current] = out[current] ?? []
154      continue
155    }
156    if (/^\S/.test(line) && !/^(ADDED|MERGED|INJECTED|IMPLIED|REJECTED)\s/.test(line)) {
157      current = null
158      continue
159    }
160    if (current === null) continue
161    const from = /^(?:ADDED|MERGED|IMPLIED)\s+from\s+(?:\[([^\]]+)\]|(\S+))/.exec(line.trim())
162    if (from === null) continue
163    const origin = from[1] ?? from[2] ?? ''
164    const list = out[current] as string[]
165    if (origin.length > 0 && !list.includes(origin)) list.push(origin)
166  }
167  return out
168}
169
170function short(permission: string): string {
171  return permission.replace(/^android\.permission\./, '')
172}
173
174function group(list: readonly LensPermission[]): string {
175  const byOrigin = new Map<string, string[]>()
176  for (const p of list) byOrigin.set(p.origin, [...(byOrigin.get(p.origin) ?? []), short(p.name)])
177  return [...byOrigin].map(([origin, names]) => `${names.sort().join(', ')} (${origin})`).join('; ')
178}
179
180/** The system prompt section. */
181export function describeManifest(m: LensManifest): string {
182  const lines = ['Android project facts (android-project-lens; permission names drop the android.permission. prefix):']
183  const sdk: string[] = []
184  if (m.targetSdk !== undefined) sdk.push(`targetSdk ${m.targetSdk} (${m.targetSdkFrom ?? 'unknown source'})`)
185  if (m.minSdk !== undefined) sdk.push(`minSdk ${m.minSdk} (${m.minSdkFrom ?? 'unknown source'})`)
186  lines.push(sdk.length > 0 ? `- ${sdk.join(', ')}` : '- targetSdk and minSdk: not found (no merged manifest yet and no literal in the build files)')
187  lines.push(`- Permissions the app module declares: ${m.app.length > 0 ? group(m.app) : 'none'}`)
188  if (m.modules.length > 0) lines.push(`- Permissions other project modules declare: ${group(m.modules)}`)
189  if (m.merged !== undefined) {
190    lines.push(`- Permissions merged in from libraries: ${m.libraries.length > 0 ? group(m.libraries) : 'none'} (from ${m.merged.path})`)
191  } else {
192    lines.push('- Library permissions: unknown until the app is built once (no merged manifest under build/intermediates).')
193  }
194  if (m.removed.length > 0) lines.push(`- Removed with tools:node="remove": ${m.removed.map(short).sort().join(', ')}`)
195  return lines.join('\n')
196}
197
hooks/nav.ts 224 lines
1// The route index: @Serializable route types and the nav-host calls that
2// register them, Navigation 2 (`composable<X>`) and Navigation 3 (`entry<X>`).
3
4import type { LensNavFile, LensNavIndex, LensRegistration, LensRoute, LensRouteArg } from '../types'
5
6import { blankCommentsAndStrings, closeOf, lineAt, lineStarts, splitTopLevel } from './kotlin'
7
8/** Text worth parsing: anything without these markers holds no route. */
9export const NAV_MARKERS = /@(?:kotlinx\.serialization\.)?Serializable|\b(?:composable|dialog|navigation|entry|bottomSheet)\s*<|NavEntry\s*\(/
10
11const ROUTE_NAME = /(Route|Routes|Screen|Screens|Destination|Destinations|Dest|Graph|NavKey|Nav)$/
12const MODIFIERS = new Set(['data', 'sealed', 'internal', 'private', 'public', 'protected', 'open', 'abstract', 'value', 'inline', 'enum', 'expect', 'actual'])
13
14const TOKEN = /\b(class|interface|object)\b(?:\s+([A-Za-z_]\w*))?|\b(fun|val|var)\b|[(){}]/g
15const REGISTRATION = /\b(composable|dialog|navigation|entry|bottomSheet)\s*<\s*([A-Za-z_][\w.]*)\s*>/g
16const NAV3_WHEN = /\bis\s+([A-Za-z_][\w.]*)\s*->\s*NavEntry\b/g
17
18/** Parses one Kotlin file's routes and registrations. */
19export function parseNavFile(path: string, src: string): LensNavFile {
20  const text = blankCommentsAndStrings(src)
21  const starts = lineStarts(src)
22  const routes: LensRoute[] = []
23  const registrations: LensRegistration[] = []
24
25  // Containers: a stack of the declarations whose bodies we are inside.
26  const stack: string[] = []
27  let pending: string | null = null
28  let parens = 0
29  let boundary = 0
30
31  for (const m of text.matchAll(TOKEN)) {
32    const at = m.index ?? 0
33    const tok = m[0]
34    if (tok === '(') {
35      parens++
36      continue
37    }
38    if (tok === ')') {
39      parens = Math.max(0, parens - 1)
40      if (parens === 0) boundary = at + 1
41      continue
42    }
43    if (tok === '{') {
44      stack.push(pending ?? '')
45      pending = null
46      boundary = at + 1
47      continue
48    }
49    if (tok === '}') {
50      stack.pop()
51      pending = null
52      boundary = at + 1
53      continue
54    }
55    if (parens > 0) continue
56    if (m[3] !== undefined) {
57      pending = ''
58      continue
59    }
60    const kind = m[1] as 'class' | 'interface' | 'object'
61    const name = m[2]
62    pending = name ?? ''
63    const before = text.slice(boundary, at)
64    boundary = at + tok.length
65    if (name === undefined || kind === 'interface') continue
66    if (!/@(?:kotlinx\.serialization\.)?Serializable\b/.test(leadingModifiers(before))) continue
67
68    // The primary constructor and supertypes.
69    let cursor = at + tok.length
70    while (text[cursor] === ' ' || text[cursor] === '\t') cursor++
71    if (text[cursor] === '<') cursor = Math.max(cursor, closeOf(text, cursor))
72    while (text[cursor] === ' ' || text[cursor] === '\t') cursor++
73    let args: LensRouteArg[] = []
74    if (kind === 'class' && text[cursor] === '(') {
75      const end = closeOf(text, cursor)
76      if (end !== -1) {
77        args = parseArgs(src.slice(cursor + 1, end - 1), text.slice(cursor + 1, end - 1))
78        cursor = end
79      }
80    }
81    const tail = /^\s*:\s*([^{;\n]*)/.exec(text.slice(cursor, cursor + 400))
82    const supertypes = tail?.[1] ?? ''
83    const containers = stack.filter(s => s.length > 0)
84    const isRoute =
85      ROUTE_NAME.test(name) ||
86      containers.some(c => ROUTE_NAME.test(c)) ||
87      /\bNavKey\b/.test(supertypes) ||
88      splitTopLevel(supertypes).some(s => ROUTE_NAME.test(s.replace(/\(.*$/s, '').replace(/<.*$/s, '').trim()))
89    if (!isRoute) continue
90    routes.push({
91      name,
92      qualified: [...containers, name].join('.'),
93      kind: kind === 'object' ? 'object' : 'class',
94      args,
95      file: path,
96      line: lineAt(starts, at),
97    })
98  }
99
100  for (const m of text.matchAll(REGISTRATION)) {
101    const target = m[2] as string
102    if (/^[A-Z]$/.test(target)) continue // a generic helper's own type parameter
103    registrations.push({ target, kind: m[1] as string, file: path, line: lineAt(starts, m.index ?? 0) })
104  }
105  if (/\bNavEntry\s*\(/.test(text)) {
106    for (const m of text.matchAll(NAV3_WHEN)) {
107      registrations.push({ target: m[1] as string, kind: 'entry', file: path, line: lineAt(starts, m.index ?? 0) })
108    }
109  }
110  return { routes, registrations }
111}
112
113const TRAILING_MODIFIER = new RegExp(`(?:@[\\w.]+(?:\\s*\\([^()]*\\))?|\\b(?:${[...MODIFIERS].join('|')}))\\s*$`)
114
115/** The annotations and modifiers written directly before a declaration keyword. */
116function leadingModifiers(before: string): string {
117  let rest = before.trimEnd()
118  let taken = ''
119  for (let m = TRAILING_MODIFIER.exec(rest); m !== null && m[0].length > 0; m = TRAILING_MODIFIER.exec(rest)) {
120    taken = m[0] + ' ' + taken
121    rest = rest.slice(0, m.index).trimEnd()
122  }
123  return taken
124}
125
126function parseArgs(original: string, blanked: string): LensRouteArg[] {
127  const args: LensRouteArg[] = []
128  // Split the blanked text so commas in strings do not split; slice the original.
129  let from = 0
130  const pieces: [number, number][] = []
131  let depth = 0
132  for (let i = 0; i <= blanked.length; i++) {
133    const c = blanked[i]
134    if (c === '(' || c === '[' || c === '{' || c === '<') depth++
135    else if (c === ')' || c === ']' || c === '}' || (c === '>' && blanked[i - 1] !== '-')) depth = Math.max(0, depth - 1)
136    if ((c === ',' && depth === 0) || i === blanked.length) {
137      pieces.push([from, i])
138      from = i + 1
139    }
140  }
141  for (const [a, b] of pieces) {
142    const piece = original.slice(a, b).replace(/@[\w.]+(\s*\([^)]*\))?/g, ' ').trim()
143    const m = /^(?:(?:private|internal|public|protected|override)\s+)*(?:val|var)?\s*([A-Za-z_]\w*)\s*:\s*([\s\S]+?)(?:\s*=\s*([\s\S]+))?$/.exec(piece)
144    if (m === null) continue
145    const arg: LensRouteArg = { name: m[1] as string, type: (m[2] as string).replace(/\s+/g, ' ').trim() }
146    if (m[3] !== undefined) arg.default = m[3].replace(/\s+/g, ' ').trim()
147    args.push(arg)
148  }
149  return args
150}
151
152/** Whether a registration's type argument names this route. */
153export function registers(target: string, route: LensRoute): boolean {
154  return (
155    target === route.qualified ||
156    target === route.name ||
157    route.qualified.endsWith('.' + target) ||
158    target.endsWith('.' + route.qualified)
159  )
160}
161
162export function allRoutes(index: LensNavIndex): LensRoute[] {
163  return Object.values(index.files).flatMap(f => f.routes)
164}
165
166export function allRegistrations(index: LensNavIndex): LensRegistration[] {
167  return Object.values(index.files).flatMap(f => f.registrations)
168}
169
170/** Routes no nav host registers, as `qualified@file`. */
171export function unregisteredRoutes(index: LensNavIndex): LensRoute[] {
172  const regs = allRegistrations(index)
173  return allRoutes(index).filter(r => !regs.some(g => registers(g.target, r)))
174}
175
176export function routeKey(route: LensRoute): string {
177  return `${route.qualified}@${route.file}`
178}
179
180function describeArgs(route: LensRoute): string {
181  if (route.kind === 'object') return ''
182  return `(${route.args.map(a => `${a.name}: ${a.type}${a.default !== undefined ? ` = ${a.default}` : ''}`).join(', ')})`
183}
184
185/** The nav_graph tool's answer, for one route query or the whole graph. */
186export function describeGraph(index: LensNavIndex, query?: string): string {
187  const routes = allRoutes(index)
188  const regs = allRegistrations(index)
189  const flavour = regs.some(r => r.kind === 'entry')
190    ? regs.some(r => r.kind !== 'entry')
191      ? 'Navigation 2 and Navigation 3'
192      : 'Navigation 3'
193    : regs.length > 0
194      ? 'Navigation 2'
195      : 'no registrations found'
196
197  const q = query?.trim().toLowerCase()
198  const picked = q ? routes.filter(r => r.qualified.toLowerCase().includes(q)) : routes
199  if (routes.length === 0) {
200    return 'No @Serializable route types found in this project (searched .kt files outside build/ and test source sets).'
201  }
202  if (picked.length === 0) {
203    return `No route matches "${query}". Routes: ${routes.map(r => r.qualified).join(', ')}.`
204  }
205
206  const lines: string[] = []
207  if (!q) lines.push(`${routes.length} routes, ${regs.length} registrations (${flavour}).`)
208  for (const r of picked.sort((a, b) => a.qualified.localeCompare(b.qualified))) {
209    const where = regs.filter(g => registers(g.target, r))
210    const head = `${r.qualified}${describeArgs(r)} [${r.kind}] declared ${r.file}:${r.line}`
211    const reg = where.length === 0
212      ? 'NOT REGISTERED with any nav host'
213      : where.map(g => `${g.kind}<${g.target}> at ${g.file}:${g.line}`).join('; ')
214    lines.push(q ? `${head}\n  registered: ${reg}` : `- ${head}; registered: ${reg}`)
215  }
216  if (!q) {
217    const orphans = regs.filter(g => !routes.some(r => registers(g.target, r)))
218    if (orphans.length > 0) {
219      lines.push(`Registrations naming no route found here: ${orphans.map(g => `${g.kind}<${g.target}> at ${g.file}:${g.line}`).join('; ')}.`)
220    }
221  }
222  return lines.join('\n')
223}
224
hooks/project.ts 329 lines
1// Reading the project through $.fs: $.process is documented CLI-only, and the
2// desktop app must work, so nothing here shells out.
3
4import type { EngineInterface } from 'claude-code'
5
6import type { LensManifest, LensNavFile, LensNavIndex, LensPermission, LensStability } from '../types'
7
8import { NAV_MARKERS, parseNavFile } from './nav'
9import { parseMergerReport, permissionsOf, usesSdk } from './manifest'
10import { mergeComposables, parseComposables, parseUnstableClasses } from './stability'
11
12type FileRef = { path: string; size: number; mtimeMs: number }
13
14export type Project = {
15  root: string
16  kt: FileRef[]
17  /** src/<set>/AndroidManifest.xml files */
18  manifests: string[]
19  /** directories holding a build.gradle(.kts), with that file */
20  modules: { dir: string; buildFile: string }[]
21}
22
23const SKIP_DIRS = new Set(['build', 'out', 'node_modules', 'Pods', 'DerivedData', 'captures', 'buildSrc-cache'])
24const MAX_DIRS = 20_000
25const MAX_KT = 8_000
26const MAX_KT_BYTES = 1_000_000
27const BATCH = 16
28
29export function join(dir: string, name: string): string {
30  return dir.endsWith('/') ? dir + name : `${dir}/${name}`
31}
32
33export function relative(root: string, path: string): string {
34  const base = root.endsWith('/') ? root : root + '/'
35  return path.startsWith(base) ? path.slice(base.length) : path
36}
37
38export function absolute(root: string, path: string): string {
39  return path.startsWith('/') ? path : join(root, path)
40}
41
42/** The Gradle path of a module directory (`feature/scan` -> `:feature:scan`). */
43export function gradlePath(root: string, dir: string): string {
44  const rel = relative(root, dir)
45  return rel === dir && dir === root ? ':' : ':' + rel.split('/').filter(s => s.length > 0).join(':')
46}
47
48async function inBatches<T, R>(items: readonly T[], fn: (item: T) => Promise<R>): Promise<R[]> {
49  const out: R[] = []
50  for (let i = 0; i < items.length; i += BATCH) {
51    out.push(...(await Promise.all(items.slice(i, i + BATCH).map(fn))))
52  }
53  return out
54}
55
56/** Walks the project once: Kotlin files, source manifests and modules. */
57export async function scanProject($: EngineInterface, root: string): Promise<Project> {
58  const project: Project = { root, kt: [], manifests: [], modules: [] }
59  let frontier = [root]
60  let seen = 0
61  while (frontier.length > 0 && seen < MAX_DIRS) {
62    const next: string[] = []
63    const listings = await inBatches(frontier, async dir => {
64      try {
65        return { dir, entries: await $.fs.list(dir) }
66      } catch {
67        return { dir, entries: [] }
68      }
69    })
70    for (const { dir, entries } of listings) {
71      seen++
72      const parent = dir.slice(dir.lastIndexOf('/') + 1)
73      for (const entry of entries) {
74        const path = join(dir, entry.name)
75        if (entry.kind === 'dir') {
76          if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue
77          // Test source sets hold no routes worth indexing.
78          if (parent === 'src' && /(^test$|Test$)/.test(entry.name)) continue
79          next.push(path)
80        } else if (entry.kind === 'file') {
81          if (entry.name.endsWith('.kt') && project.kt.length < MAX_KT) {
82            project.kt.push({ path, size: entry.size, mtimeMs: entry.mtimeMs })
83          } else if (entry.name === 'AndroidManifest.xml' && /\/src\/[^/]+$/.test(dir)) {
84            project.manifests.push(path)
85          } else if (entry.name === 'build.gradle.kts' || entry.name === 'build.gradle') {
86            project.modules.push({ dir, buildFile: path })
87          }
88        }
89      }
90    }
91    frontier = next
92  }
93  return project
94}
95
96async function readOr($: EngineInterface, path: string, fallback: string): Promise<string> {
97  try {
98    const text = await $.fs.read(path)
99    return typeof text === 'string' ? text : fallback
100  } catch {
101    return fallback
102  }
103}
104
105/** Parses one file into the index's shape, or null when it holds nothing of nav. */
106async function navFile($: EngineInterface, root: string, path: string): Promise<LensNavFile | null> {
107  const src = await readOr($, path, '')
108  if (!NAV_MARKERS.test(src)) return null
109  const parsed = parseNavFile(relative(root, path), src)
110  return parsed.routes.length > 0 || parsed.registrations.length > 0 ? parsed : null
111}
112
113/** Builds the route index from the walk's Kotlin files. */
114export async function indexNav($: EngineInterface, project: Project, now: number): Promise<LensNavIndex> {
115  const files: Record<string, LensNavFile> = {}
116  const small = project.kt.filter(f => f.size <= MAX_KT_BYTES)
117  const parsed = await inBatches(small, async f => [f.path, await navFile($, project.root, f.path)] as const)
118  for (const [path, file] of parsed) if (file !== null) files[relative(project.root, path)] = file
119  return { files, indexedAt: now }
120}
121
122/** Re-reads the given files into a copy of the index. */
123export async function refreshNav($: EngineInterface, root: string, index: LensNavIndex, paths: readonly string[], now: number): Promise<LensNavIndex> {
124  const files = { ...index.files }
125  for (const path of paths) {
126    const file = await navFile($, root, absolute(root, path))
127    const key = relative(root, absolute(root, path))
128    if (file === null) delete files[key]
129    else files[key] = file
130  }
131  return { files, indexedAt: now }
132}
133
134// ---- Manifest ------------------------------------------------------------
135
136const APP_PLUGIN = /com\.android\.application|android[.\-_]?application/i
137
138async function newestMergedManifest($: EngineInterface, moduleDir: string): Promise<{ path: string; mtimeMs: number; variant: string } | undefined> {
139  const intermediates = join(moduleDir, 'build/intermediates')
140  let roots: string[]
141  try {
142    roots = (await $.fs.list(intermediates)).filter(e => e.kind === 'dir' && /^merged_manifests?$/.test(e.name)).map(e => join(intermediates, e.name))
143  } catch {
144    return undefined
145  }
146  let best: { path: string; mtimeMs: number; variant: string } | undefined
147  const consider = async (path: string, variant: string) => {
148    try {
149      const st = await $.fs.stat(path)
150      if (st.kind === 'file' && (best === undefined || st.mtimeMs > best.mtimeMs)) best = { path, mtimeMs: st.mtimeMs, variant }
151    } catch {
152      // not there in this AGP version's layout
153    }
154  }
155  for (const r of roots) {
156    // AGP 4-7: merged_manifests/<v>/AndroidManifest.xml; 8.0-8.2: merged_manifest/<v>/AndroidManifest.xml;
157    // 8.3+: merged_manifest/<v>/process<V>MainManifest/AndroidManifest.xml.
158    const variants = await $.fs.list(r).catch(() => [])
159    for (const v of variants.filter(e => e.kind === 'dir')) {
160      const vdir = join(r, v.name)
161      const inside = await $.fs.list(vdir).catch(() => [])
162      for (const e of inside) {
163        if (e.kind === 'file' && e.name === 'AndroidManifest.xml') await consider(join(vdir, e.name), v.name)
164        if (e.kind === 'dir') await consider(join(join(vdir, e.name), 'AndroidManifest.xml'), v.name)
165      }
166    }
167  }
168  return best
169}
170
171async function mergerAttribution($: EngineInterface, moduleDir: string, variant: string): Promise<Record<string, string[]>> {
172  const logs = join(moduleDir, 'build/outputs/logs')
173  const want = variant.toLowerCase().replace(/[^a-z0-9]/g, '')
174  const entries = await $.fs.list(logs).catch(() => [])
175  const report = entries
176    .filter(e => e.kind === 'file' && e.name.startsWith('manifest-merger-') && e.name.toLowerCase().replace(/[^a-z0-9]/g, '').includes(want))
177    .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
178  if (report === undefined) return {}
179  return parseMergerReport(await readOr($, join(logs, report.name), ''))
180}
181
182function sdkFromText(text: string, key: 'targetSdk' | 'minSdk'): string | undefined {
183  const m = new RegExp(`\\b(?:android[-_.]?)?${key}(?:Version)?\\w*\\s*[=(]\\s*"?(\\d{2})"?`, 'i').exec(text)
184  return m?.[1]
185}
186
187function libraryName(origin: string): string {
188  // A path under the Gradle caches names the artifact's folder; a coordinate is kept.
189  const cache = /transforms[^/]*\/[^/]+\/transformed\/([^/]+)\//.exec(origin)
190  return cache?.[1] ?? origin
191}
192
193/** The manifest facts for the system prompt, or null for a project with no manifest. */
194export async function gatherManifest($: EngineInterface, project: Project): Promise<LensManifest | null> {
195  if (project.manifests.length === 0) return null
196  const { root } = project
197  const moduleOf = (path: string) =>
198    project.modules
199      .filter(m => path.startsWith(m.dir + '/'))
200      .sort((a, b) => b.dir.length - a.dir.length)[0]
201
202  const buildTexts = new Map<string, string>()
203  for (const m of project.modules) buildTexts.set(m.dir, await readOr($, m.buildFile, ''))
204  let appDirs = project.modules.filter(m => m.dir !== root && APP_PLUGIN.test(buildTexts.get(m.dir) ?? '')).map(m => m.dir)
205  if (appDirs.length === 0) appDirs = project.modules.filter(m => /\/(app|androidApp)$/.test(m.dir)).map(m => m.dir)
206
207  const facts: LensManifest = { app: [], modules: [], libraries: [], removed: [] }
208  const projectNames = new Set<string>()
209  for (const path of project.manifests) {
210    const mod = moduleOf(path)
211    const modDir = mod?.dir ?? root
212    const set = /\/src\/([^/]+)\/AndroidManifest\.xml$/.exec(path)?.[1] ?? 'main'
213    const { declared, removed } = permissionsOf(await readOr($, path, ''))
214    const isApp = appDirs.includes(modDir)
215    for (const name of declared) {
216      projectNames.add(name)
217      const origin = `${gradlePath(root, modDir)}${set === 'main' ? '' : ` src/${set}`}`
218      const list = isApp ? facts.app : facts.modules
219      if (!list.some(p => p.name === name && p.origin === origin)) list.push({ name, origin })
220    }
221    for (const name of removed) if (!facts.removed.includes(name)) facts.removed.push(name)
222  }
223
224  // The merged manifest: the real SDK levels and what libraries bring in.
225  let merged: { path: string; mtimeMs: number; variant: string; dir: string } | undefined
226  for (const dir of appDirs) {
227    const found = await newestMergedManifest($, dir)
228    if (found !== undefined && (merged === undefined || found.mtimeMs > merged.mtimeMs)) merged = { ...found, dir }
229  }
230  if (merged !== undefined) {
231    const xml = await readOr($, merged.path, '')
232    const sdk = usesSdk(xml)
233    const where = `merged manifest, ${gradlePath(root, merged.dir)} ${merged.variant}`
234    if (sdk.targetSdk !== undefined) {
235      facts.targetSdk = sdk.targetSdk
236      facts.targetSdkFrom = where
237    }
238    if (sdk.minSdk !== undefined) {
239      facts.minSdk = sdk.minSdk
240      facts.minSdkFrom = where
241    }
242    const attribution = await mergerAttribution($, merged.dir, merged.variant)
243    const libraries: LensPermission[] = []
244    for (const name of permissionsOf(xml).declared) {
245      if (projectNames.has(name)) continue
246      const from = (attribution[name] ?? []).filter(o => !o.startsWith(root)).map(libraryName)
247      libraries.push({ name, origin: from.length > 0 ? from.join(', ') : 'a library' })
248    }
249    facts.libraries = libraries
250    facts.merged = { path: relative(root, merged.path), mtimeMs: merged.mtimeMs }
251  }
252
253  // Fall back to literals in the build files, the version catalog, then build-logic.
254  if (facts.targetSdk === undefined || facts.minSdk === undefined) {
255    const sources: [string, string][] = []
256    for (const m of project.modules.filter(x => appDirs.includes(x.dir))) {
257      sources.push([relative(root, m.buildFile), buildTexts.get(m.dir) ?? ''])
258    }
259    const catalog = join(root, 'gradle/libs.versions.toml')
260    sources.push(['gradle/libs.versions.toml', await readOr($, catalog, '')])
261    for (const f of project.kt.filter(k => /\/build-logic\//.test(k.path)).slice(0, 40)) {
262      sources.push([relative(root, f.path), await readOr($, f.path, '')])
263    }
264    for (const [from, text] of sources) {
265      if (facts.targetSdk === undefined) {
266        const v = sdkFromText(text, 'targetSdk')
267        if (v !== undefined) {
268          facts.targetSdk = v
269          facts.targetSdkFrom = from
270        }
271      }
272      if (facts.minSdk === undefined) {
273        const v = sdkFromText(text, 'minSdk')
274        if (v !== undefined) {
275          facts.minSdk = v
276          facts.minSdkFrom = from
277        }
278      }
279    }
280  }
281  return facts
282}
283
284// ---- Compose compiler reports -------------------------------------------
285
286/** Where the /stability init script writes its module index. */
287export function reportIndexPath(root: string): string {
288  return join(root, 'build/android-project-lens/compose-modules.txt')
289}
290
291const MAX_REPORT_BYTES = 4 * 1024 * 1024
292
293/**
294 * Reads the Compose compiler reports the init script's index names, parsing
295 * only files changed since their last parse and merging them into the table.
296 */
297export async function loadStability($: EngineInterface, root: string, table: LensStability): Promise<{ table: LensStability; reportFiles: number }> {
298  const indexPath = reportIndexPath(root)
299  let indexStat
300  try {
301    indexStat = await $.fs.stat(indexPath)
302  } catch {
303    return { table, reportFiles: 0 }
304  }
305  const lines = (await readOr($, indexPath, '')).split('\n').map(l => l.trim()).filter(l => l.includes('='))
306  let next: LensStability = { ...table, parsed: { ...table.parsed }, unstableClasses: { ...table.unstableClasses }, indexMtime: indexStat.mtimeMs }
307  let reportFiles = 0
308  for (const line of lines) {
309    const eq = line.indexOf('=')
310    const module = line.slice(0, eq)
311    const dir = line.slice(eq + 1)
312    const entries = await $.fs.list(dir).catch(() => [])
313    for (const e of entries) {
314      if (e.kind !== 'file' || e.size > MAX_REPORT_BYTES) continue
315      const isComposables = e.name.endsWith('-composables.txt')
316      const isClasses = e.name.endsWith('-classes.txt')
317      if (!isComposables && !isClasses) continue
318      reportFiles++
319      const path = join(dir, e.name)
320      if (next.parsed[path] === e.mtimeMs) continue
321      const text = await readOr($, path, '')
322      if (isComposables) next = mergeComposables(next, parseComposables(text, module))
323      else Object.assign(next.unstableClasses, parseUnstableClasses(text))
324      next.parsed[path] = e.mtimeMs
325    }
326  }
327  return { table: next, reportFiles }
328}
329
hooks/stability.ts 121 lines
1// Compose compiler reports: *-composables.txt and *-classes.txt.
2//
3// Strong skipping is the default from Compose compiler 2.0.20, so "skippable"
4// says nearly nothing: only the unstable-parameter signal is kept.
5
6import type { LensComposable, LensStability } from '../types'
7
8export const EMPTY_STABILITY: LensStability = { composables: {}, unstableClasses: {}, parsed: {}, indexMtime: 0 }
9
10const FUN_HEAD = /\bfun\s+(?:<[^>]*>\s*)?(?:[\w.<>?, ]+\.)?([A-Za-z_]\w*)\s*\(/
11const PARAM = /^\s*(unstable|stable|runtime|@static|@dynamic)?\s*([A-Za-z_$][\w$]*)\s*:\s*(.+?)(?:\s*=\s*.*)?$/
12
13/**
14 * Parses a `<module>_<variant>-composables.txt` report: every composable it
15 * lists, with its unstable parameters (empty when none are).
16 */
17export function parseComposables(text: string, module: string): LensComposable[] {
18  const found: LensComposable[] = []
19  let current: LensComposable | null = null
20  for (const line of text.split('\n')) {
21    const head = FUN_HEAD.exec(line)
22    if (head !== null && !/^\s+(unstable|stable|runtime)\s/.test(line)) {
23      current = { name: head[1] as string, module, unstable: [] }
24      found.push(current)
25      if (/\)\s*$/.test(line)) current = null
26      continue
27    }
28    if (current === null) continue
29    if (/^\s*\)\s*$/.test(line)) {
30      current = null
31      continue
32    }
33    const p = PARAM.exec(line)
34    if (p !== null && p[1] === 'unstable') current.unstable.push({ name: p[2] as string, type: (p[3] as string).trim() })
35  }
36  return found
37}
38
39/**
40 * Parses a `<module>_<variant>-classes.txt` report: each unstable class and
41 * the members that make it so.
42 */
43export function parseUnstableClasses(text: string): Record<string, string> {
44  const found: Record<string, string> = {}
45  let current: string | null = null
46  let members: string[] = []
47  for (const line of text.split('\n')) {
48    const head = /^\s*(unstable|stable|runtime)\s+class\s+([A-Za-z_][\w.]*)/.exec(line)
49    if (head !== null) {
50      current = head[1] === 'unstable' ? (head[2] as string) : null
51      members = []
52      continue
53    }
54    if (current === null) continue
55    const member = /^\s*unstable\s+(val|var)\s+(\w+)\s*:\s*(.+)$/.exec(line)
56    if (member !== null) members.push(`${member[1]} ${member[2]}: ${(member[3] as string).trim()}`)
57    if (/^\s*}\s*$/.test(line)) {
58      found[current] = members.length > 0 ? `unstable members ${members.join(', ')}` : 'inferred unstable'
59      current = null
60    }
61  }
62  return found
63}
64
65/** Merges one composables report into the table: composables it lists are replaced, others kept. */
66export function mergeComposables(table: LensStability, parsed: LensComposable[]): LensStability {
67  const composables = { ...table.composables }
68  for (const c of parsed) {
69    if (c.unstable.length > 0) composables[c.name] = c
70    else delete composables[c.name]
71  }
72  return { ...table, composables }
73}
74
75/** Composables and classes the edited Kotlin text defines that the reports call unstable. */
76export function factsForEdit(table: LensStability, text: string): string[] {
77  const facts: string[] = []
78  const seen = new Set<string>()
79  for (const m of text.matchAll(new RegExp(FUN_HEAD.source, 'g'))) {
80    const name = m[1] as string
81    const c = table.composables[name]
82    if (c === undefined || seen.has(name)) continue
83    seen.add(name)
84    facts.push(
85      `${name} (${c.module}) has unstable parameters: ${c.unstable.map(u => `${u.name}: ${u.type}`).join(', ')}.`,
86    )
87  }
88  for (const m of text.matchAll(/\bclass\s+([A-Za-z_]\w*)/g)) {
89    const name = m[1] as string
90    const hit = Object.entries(table.unstableClasses).find(([k]) => k === name || k.endsWith('.' + name))
91    if (hit === undefined || seen.has(name)) continue
92    seen.add(name)
93    facts.push(`class ${hit[0]} is unstable (${hit[1]}).`)
94  }
95  if (facts.length === 0) return []
96  const shown = facts.slice(0, 4)
97  return [
98    `Compose compiler report (android-project-lens /stability): ${shown.join(' ')} ` +
99      'With strong skipping these still skip when the same instances are passed again; they recompose only when a new instance arrives each time, such as a list or lambda-holding object built during composition.',
100  ]
101}
102
103/** The /stability summary of the table. */
104export function describeStability(table: LensStability): string {
105  const composables = Object.values(table.composables).sort((a, b) => b.unstable.length - a.unstable.length)
106  const classes = Object.entries(table.unstableClasses)
107  if (composables.length === 0 && classes.length === 0) {
108    return 'The Compose compiler reports list no composable with unstable parameters.'
109  }
110  const lines = [`${composables.length} composables have unstable parameters; ${classes.length} classes are unstable.`]
111  for (const c of composables.slice(0, 15)) {
112    lines.push(`- ${c.name} (${c.module}): ${c.unstable.map(u => `${u.name}: ${u.type}`).join(', ')}`)
113  }
114  if (composables.length > 15) lines.push(`- and ${composables.length - 15} more`)
115  if (classes.length > 0) {
116    lines.push(`Unstable classes: ${classes.slice(0, 10).map(([k, why]) => `${k} (${why})`).join('; ')}${classes.length > 10 ? `; and ${classes.length - 10} more` : ''}.`)
117  }
118  lines.push('Strong skipping is on by default, so these matter only where a new instance is created on each recomposition.')
119  return lines.join('\n')
120}
121
hooks/kotlin.ts 106 lines
1// Small helpers for reading Kotlin source without a parser.
2
3/**
4 * Blanks comments and string contents with spaces, keeping every newline and
5 * offset, so structural regexes do not match inside them and line numbers and
6 * slices of the original still line up.
7 */
8export function blankCommentsAndStrings(src: string): string {
9  const out = src.split('')
10  const blank = (from: number, to: number) => {
11    for (let i = from; i < to && i < out.length; i++) {
12      if (out[i] !== '\n') out[i] = ' '
13    }
14  }
15  let i = 0
16  while (i < src.length) {
17    const c = src[i]
18    const d = src[i + 1]
19    if (c === '/' && d === '/') {
20      const end = src.indexOf('\n', i)
21      const stop = end === -1 ? src.length : end
22      blank(i, stop)
23      i = stop
24    } else if (c === '/' && d === '*') {
25      const end = src.indexOf('*/', i + 2)
26      const stop = end === -1 ? src.length : end + 2
27      blank(i, stop)
28      i = stop
29    } else if (c === '"' && src.startsWith('"""', i)) {
30      const end = src.indexOf('"""', i + 3)
31      const stop = end === -1 ? src.length : end + 3
32      blank(i + 3, stop - 3)
33      i = stop
34    } else if (c === '"') {
35      let j = i + 1
36      while (j < src.length && src[j] !== '"' && src[j] !== '\n') j += src[j] === '\\' ? 2 : 1
37      blank(i + 1, j)
38      i = j + 1
39    } else if (c === "'") {
40      let j = i + 1
41      while (j < src.length && src[j] !== "'" && src[j] !== '\n' && j - i < 12) j += src[j] === '\\' ? 2 : 1
42      blank(i + 1, j)
43      i = j + 1
44    } else {
45      i++
46    }
47  }
48  return out.join('')
49}
50
51/** The offsets at which each line starts, for offset-to-line lookups. */
52export function lineStarts(src: string): number[] {
53  const starts = [0]
54  for (let i = 0; i < src.length; i++) if (src[i] === '\n') starts.push(i + 1)
55  return starts
56}
57
58/** The 1-based line holding `offset`. */
59export function lineAt(starts: readonly number[], offset: number): number {
60  let lo = 0
61  let hi = starts.length - 1
62  while (lo < hi) {
63    const mid = (lo + hi + 1) >> 1
64    if ((starts[mid] ?? 0) <= offset) lo = mid
65    else hi = mid - 1
66  }
67  return lo + 1
68}
69
70/** The index just past the bracket that closes the one at `open`, or -1. */
71export function closeOf(text: string, open: number): number {
72  const pairs: Record<string, string> = { '(': ')', '<': '>', '[': ']', '{': '}' }
73  const stack: string[] = []
74  for (let i = open; i < text.length; i++) {
75    const c = text[i] as string
76    if (c in pairs) {
77      // `->` and comparison `<` inside default values are not brackets.
78      if (c === '<' && stack.length > 0 && stack[stack.length - 1] !== '>' && text[i + 1] === '=') continue
79      stack.push(pairs[c] as string)
80    } else if (c === stack[stack.length - 1]) {
81      if (c === '>' && text[i - 1] === '-') continue
82      stack.pop()
83      if (stack.length === 0) return i + 1
84    }
85  }
86  return -1
87}
88
89/** Splits `text` at commas that sit outside every bracket. */
90export function splitTopLevel(text: string): string[] {
91  const parts: string[] = []
92  let depth = 0
93  let from = 0
94  for (let i = 0; i < text.length; i++) {
95    const c = text[i]
96    if (c === '(' || c === '[' || c === '{' || c === '<') depth++
97    else if (c === ')' || c === ']' || c === '}' || (c === '>' && text[i - 1] !== '-')) depth = Math.max(0, depth - 1)
98    else if (c === ',' && depth === 0) {
99      parts.push(text.slice(from, i))
100      from = i + 1
101    }
102  }
103  parts.push(text.slice(from))
104  return parts.map(p => p.trim()).filter(p => p.length > 0)
105}
106
types/index.d.ts 83 lines
1/** One argument of a route's primary constructor. */
2export type LensRouteArg = { name: string; type: string; default?: string }
3
4/** A `@Serializable` route class or object found in the sources. */
5export type LensRoute = {
6  /** Simple name (`Detail`). */
7  name: string
8  /** Name qualified by its enclosing declarations (`Route.Detail`). */
9  qualified: string
10  kind: 'object' | 'class'
11  args: LensRouteArg[]
12  file: string
13  line: number
14}
15
16/** A place that registers a route with a nav host. */
17export type LensRegistration = {
18  /** The type argument as written (`Route.Detail`). */
19  target: string
20  /** `composable`, `dialog`, `navigation` (Navigation 2) or `entry` (Navigation 3). */
21  kind: string
22  file: string
23  line: number
24}
25
26/** Routes and registrations of one source file. */
27export type LensNavFile = { routes: LensRoute[]; registrations: LensRegistration[] }
28
29/** The whole route index, by project-relative path. */
30export type LensNavIndex = { files: Record<string, LensNavFile>; indexedAt: number }
31
32/** One composable from a Compose compiler report that has unstable parameters. */
33export type LensComposable = {
34  name: string
35  module: string
36  unstable: { name: string; type: string }[]
37}
38
39/** What the Compose compiler reports said, merged across runs. */
40export type LensStability = {
41  composables: Record<string, LensComposable>
42  unstableClasses: Record<string, string>
43  /** report file path -> mtimeMs at its last parse */
44  parsed: Record<string, number>
45  /** mtimeMs of the module index the init script wrote, at the last read */
46  indexMtime: number
47}
48
49/** One permission and where it comes from. */
50export type LensPermission = { name: string; origin: string }
51
52/** Manifest facts for the system prompt. */
53export type LensManifest = {
54  targetSdk?: string
55  targetSdkFrom?: string
56  minSdk?: string
57  minSdkFrom?: string
58  /** Declared in an application module's own source manifests. */
59  app: LensPermission[]
60  /** Declared in other project modules' source manifests. */
61  modules: LensPermission[]
62  /** Present only in the merged manifest: brought in by libraries. */
63  libraries: LensPermission[]
64  /** Removed with tools:node="remove" in a source manifest. */
65  removed: string[]
66  merged?: { path: string; mtimeMs: number }
67}
68
69declare module 'claude-code' {
70  interface PluginState {
71    'android-project-lens': {
72      root: string
73      nav: LensNavIndex
74      /** .kt files edited since the index last read them */
75      dirtyKt: string[]
76      /** unregistered routes already reported, so a turn reports only new ones */
77      reportedUnregistered: string[]
78      stability: LensStability
79      manifest: LensManifest | null
80    }
81  }
82}
83