
Claude Android Ninja
- 228 installs
- 99 repo stars
- Updated August 2, 2026
- drjacky/claude-android-ninja
For development and infrastructure management.
About
claude-android-ninja is an AI coding tool that enhances development workflows. Builders use it for infrastructure, integration, and platform development within the catalog ecosystem.
- claude-android-ninja
- Development
Claude Android Ninja by the numbers
- 228 all-time installs (skills.sh)
- Ranked #1,691 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/drjacky/claude-android-ninja --skill claude-android-ninjaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 228 |
|---|---|
| repo stars | ★ 99 |
| Last updated | August 2, 2026 |
| Repository | drjacky/claude-android-ninja ↗ |
What it does
For development and infrastructure management.
Files
Android Kotlin Compose Development
Context ladder (smaller load first; full references stay complete):
1. This file (SKILL.md) - routing, stop rules, examples. 2. references/*-quick.md when listed below - required/forbidden + section links (~40 lines). 3. One target section in the full references/*.md - code samples and checklists only. 4. INDEX-sections.md - anchor dump only when quick routing is insufficient.
Forbidden: load INDEX-sections.md or an entire multi-thousand-line reference when one section or a quick file covers the task.
Route tasks through the Quick Reference table. When no row matches, or the task needs greenfield bootstrap: workflows.md. Full file list: INDEX.md.
Required:
- Existing project: read
settings.gradle.kts,gradle/libs.versions.toml, and theappmodule build file before copying fromassets/- dependencies.md, modularization.md. Stack migrations: migration.md. - Greenfield: workflows.md → "Creating a new project?"
- After module, DI, navigation, Room schema, or AGP/Kotlin/KSP changes:
./gradlew helpthen:app:assembleDebug(use the real app module name) - gradle-setup.md.
Outside-repo stop rules (do not substitute repo edits): Play upload, tracks, rollout, versionCode - android-ci-cd.md; Play Integrity prerequisites (Console/Cloud setup) - android-security-quick.md; production adb install / pm clear - testing.md.
Quick Reference
Rare or niche topics not listed here are in INDEX.md (complete file list).
| Task | Reference |
|---|---|
| Task not in table, greenfield bootstrap, multi-topic setup | workflows.md |
| Full index of all reference files | INDEX.md |
| Version catalog, pins, alpha policy, brownfield alignment | dependencies.md |
| Adding or updating dependencies (catalog aliases) | dependencies.md |
| Multi-module dependencies | dependencies.md |
| Project structure and modules | modularization.md |
| MVVM layers, repositories, DI | architecture.md |
| Retrofit / OkHttp, NetworkModule, nullable DTOs, AuthInterceptor | architecture.md; dependencies.md |
| DataStore (preferences, typed), Room vs DataStore rules | architecture.md |
Code formatting (Spotless, spotlessCheck / spotlessApply) | assets/convention/QUICK_REFERENCE.md; gradle-setup.md |
| Compose patterns, motion, animation, modifiers, stability | compose-patterns-quick.md |
Paging 3 + Room + network (RemoteMediator, remote keys, initialize) | compose-patterns.md |
| Accessibility, TalkBack, label copy, live regions, Espresso a11y | android-accessibility-quick.md |
| Notifications, foreground services, MediaStyle, PiP, sharesheet | android-notifications.md |
| Media: API 37 background playback, Media3, picking, FileProvider, sharesheet | android-media.md |
| Data sync and offline-first patterns | android-data-sync-quick.md |
| Material 3 theming, spacing tokens, dynamic colors | android-theming-quick.md |
| Navigation3, deep links, App Links, adaptive layouts | android-navigation-quick.md |
| Kotlin patterns, View lifecycle interop | kotlin-patterns.md |
Coroutine patterns (StateFlow, Channel, callbackFlow) | coroutines-patterns-quick.md |
| Gradle, product flavors, BuildConfig, build performance, R8 | gradle-setup.md |
| Code quality (Detekt convention plugin, CI) | code-quality.md |
| Testing approach (unit, instrumented, Compose UI) | testing-quick.md |
| Internationalization and localization | android-i18n.md |
| Runtime permissions, Photo Picker, API 37 location privacy | android-permissions.md |
| Kotlin delegation patterns | kotlin-delegation.md |
| Crash reporting (Firebase / Sentry interfaces, PII scrubbing) | crashlytics.md |
| Design patterns (GoF-style, Room FTS) | design-patterns-quick.md |
| Performance, Play Vitals, startup, recomposition, jank, APA, Perfetto | android-performance.md |
| Debugging, Logcat, ANR, Gradle errors, R8 mapping, memory leaks | android-debugging.md |
| Migrations (XML, RxJava, Navigation, Compose, Room 2→3, API 37, 16 KB native, Compose-XML interop) | migration.md; 16 KB page size; Compose-XML interop |
Examples
Greenfield Android app with convention plugins
User goal: new repo matching the skill stack.
Actions: copy assets/settings.gradle.kts.template, assets/libs.versions.toml.template, assets/convention/ into build-logic/ per assets/convention/QUICK_REFERENCE.md; wire includeBuild("build-logic"); read modularization.md and gradle-setup.md.
Result: root + app + core modules with version catalog and convention plugins applied.
New feature screen (Compose + ViewModel)
User goal: one new flow in a feature module.
Actions: modularization.md for module naming and dependency direction; compose-patterns-quick.md for Screen, state, effects; kotlin-patterns.md + coroutines-patterns-quick.md for StateFlow / events; architecture.md for domain vs data boundaries.
Result: feature module with Screen composable, ViewModel, UiState, and DI aligned to existing graphs.
Offline-first list with Room 3 and remote API
User goal: cached list + network refresh.
Actions: compose-patterns.md for Paging 3 + RemoteMediator; architecture.md for repository placement; Room 3 + SQLiteDriver per workflows.md (Working with databases) and migration.md if upgrading.
Result: single source of truth in Room, UI driven by PagingData or equivalent pattern from the guide.
Target SDK / compile SDK bump (e.g. API 37)
User goal: migrate toolchain and platform requirements.
Actions: walk migration.md; pin AGP/Kotlin/KSP using gradle-setup.md and dependencies.md; cross-check edge-to-edge, media, security per workflows.md (Migrating to target SDK 37).
Result: compileSdk / targetSdk raised with manifest, Gradle, and feature code adjusted per the migration doc.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Gradle sync fails, plugin not found, or version catalog errors | Missing google() / mavenCentral(), wrong plugin id, or catalog alias drift | gradle-setup.md; align with assets/libs.versions.toml.template when bootstrapping |
| KSP errors on Room, or Room 3 builder rejects missing driver | Room 3 expects setDriver(BundledSQLiteDriver()) (or project equivalent) | migration.md; modularization.md; architecture.md |
| Compose runtime warnings about unstable / skippable recompositions | Unstable parameter types or state held incorrectly | compose-patterns-quick.md; android-performance.md; kotlin-patterns.md |
Release build crashes, ClassNotFoundException, or missing R8 rules | Shrinking removed reflective or JNI entry points | android-debugging.md; gradle-setup.md |
| ANR or jank claims without evidence | Main-thread or measurement assumptions | android-performance.md or Perfetto before architecture changes |
github: DrjackyBug Description
A clear and concise description of what the issue is.
Location
Where in the SKILL did you find this issue?
File: references/[file-name].md Section: [section name or line numbers]
What's Wrong?
Describe the problem with the current guidance.
Examples:
- Outdated API usage (Android version changed)
- Incorrect pattern that doesn't work
- Deprecated library still being recommended
- Code example has syntax errors
- Conflicting guidance between files
Expected Behavior
What should the correct guidance be?
Include:
- Links to official Android documentation
- Updated API calls or patterns
- Correct version numbers
Suggested Fix
If you know how to fix this, please describe or provide the corrected code/guidance.
Feature Description
A clear and concise description of the best practice, pattern, or topic you'd like to see added.
Use Case
Describe the scenario or problem this would help solve.
Example: "I need guidance on implementing Paging 3 with LazyColumn in Compose following our modular architecture."
Related Topics
If this relates to existing references in the SKILL, mention them here.
Example: "This relates to references/architecture.md and would fit well in the data layer section."
Expected Outcome
What would you expect to see in the documentation?
Examples:
- Code examples showing the pattern
- Best practices and anti-patterns
- Integration with existing SKILL architecture
- Testing guidance
Additional Context
Add any other context, links to official documentation, or examples.
Priority
How important is this to your workflow?
- [ ] Critical - blocking my current work
- [ ] High - would significantly improve my workflow
- [ ] Medium - nice to have
- [ ] Low - just an idea
#!/usr/bin/env python3
"""Guardrails for agent ergonomics: slim INDEX/SKILL and quick companions for large refs."""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
SKILL = ROOT / "SKILL.md"
INDEX = ROOT / "references" / "INDEX.md"
REFERENCES = ROOT / "references"
MAX_SKILL_LINES = 150
MAX_INDEX_LINES = 80
MIN_LINES_FOR_QUICK = 1500
INDEX_FORBIDDEN_SECTION = "## Sections by file"
def line_count(path: Path) -> int:
return len(path.read_text(encoding="utf-8").splitlines())
def main() -> int:
errors: list[str] = []
if not SKILL.is_file():
errors.append(f"Missing {SKILL.relative_to(ROOT)}")
else:
n = line_count(SKILL)
if n > MAX_SKILL_LINES:
errors.append(
f"SKILL.md has {n} lines (max {MAX_SKILL_LINES}); "
"move routing detail to references/workflows.md or INDEX.md"
)
if not INDEX.is_file():
errors.append(f"Missing {INDEX.relative_to(ROOT)}")
else:
text = INDEX.read_text(encoding="utf-8")
n = line_count(INDEX)
if n > MAX_INDEX_LINES:
errors.append(
f"references/INDEX.md has {n} lines (max {MAX_INDEX_LINES}); "
"move section anchors to references/INDEX-sections.md"
)
if INDEX_FORBIDDEN_SECTION in text:
errors.append(
"references/INDEX.md must not contain "
f'"{INDEX_FORBIDDEN_SECTION}" (use INDEX-sections.md)'
)
for md in sorted(REFERENCES.glob("*.md")):
name = md.name
if name in ("INDEX.md", "INDEX-sections.md") or name.endswith("-quick.md"):
continue
lines = line_count(md)
if lines >= MIN_LINES_FOR_QUICK:
quick = REFERENCES / f"{md.stem}-quick.md"
if not quick.is_file():
errors.append(
f"{md.relative_to(ROOT)} has {lines} lines but "
f"missing {quick.relative_to(ROOT)}"
)
if errors:
print("Ergonomics check failed:", file=sys.stderr)
for err in errors:
print(f" - {err}", file=sys.stderr)
return 1
print(
f"OK: SKILL.md<={MAX_SKILL_LINES}, INDEX.md<={MAX_INDEX_LINES}, "
f"all references>={MIN_LINES_FOR_QUICK} lines have -quick.md"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Verify declared reference line counts in skill markdown match actual file sizes.
Matches:
- INDEX-sections headings: ### foo.md (1234 lines)
- INDEX-sections anchors: INDEX-sections.md#foomd-1234-lines
- Quick/full guide blurbs: [foo.md](foo.md) (~1230 lines) (nearest 10)
Scans references/**/*.md and assets/convention/*.md. Skips README.md and SKILL.md.
CI runs check-only. Refresh locally:
./.github/scripts/check-skill-index-line-counts.sh --fix
Rewrites files on disk; commit yourself (no auto-commit in Actions).
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
REFERENCES = ROOT / "references"
SCAN_GLOBS = (
"references/**/*.md",
"assets/convention/*.md",
)
SKIP_FILES = {
ROOT / "README.md",
ROOT / "SKILL.md",
}
HEADING_RE = re.compile(
r"^### (?P<file>[^\s()]+\.md) \((?P<count>\d+) lines\)$",
re.MULTILINE,
)
ANCHOR_RE = re.compile(
r"INDEX-sections\.md#(?P<slug>[\w-]+?md)-(?P<count>\d+)-lines"
)
APPROX_RE = re.compile(
r"\[(?P<file>[^\]]+\.md)\]\([^)]+\) \(~(?P<count>\d+) lines\)"
)
def line_count(path: Path) -> int:
return len(path.read_text(encoding="utf-8").splitlines())
def approximate_line_count(actual: int) -> int:
return round(actual / 10) * 10
def slug_to_filename(slug: str) -> str:
if slug.endswith("md"):
return f"{slug[:-2]}.md"
return f"{slug}.md"
def ref_path_for(filename: str) -> Path:
return REFERENCES / Path(filename).name
def collect_markdown_files() -> list[Path]:
files: set[Path] = set()
for pattern in SCAN_GLOBS:
files.update(path for path in ROOT.glob(pattern) if path.is_file())
return sorted(path for path in files if path not in SKIP_FILES)
def actual_for(filename: str, cache: dict[str, int]) -> int | None:
name = Path(filename).name
if name not in cache:
path = ref_path_for(name)
if not path.is_file():
cache[name] = -1
else:
cache[name] = line_count(path)
count = cache[name]
return None if count < 0 else count
def process_file(
md_path: Path,
*,
fix: bool,
cache: dict[str, int],
) -> tuple[list[str], int, bool]:
errors: list[str] = []
fixed = 0
rel = md_path.relative_to(ROOT)
text = md_path.read_text(encoding="utf-8")
changed = False
def fail(message: str) -> None:
errors.append(f"{rel}: {message}")
def replace_heading(match: re.Match[str]) -> str:
nonlocal fixed, changed
filename = match.group("file")
declared = int(match.group("count"))
actual = actual_for(filename, cache)
if actual is None:
fail(f"{filename} ({declared} lines) - missing {ref_path_for(filename).relative_to(ROOT)}")
return match.group(0)
if declared != actual:
if fix:
fixed += 1
changed = True
return f"### {filename} ({actual} lines)"
fail(f"{filename} declared {declared} lines, actual {actual}")
return match.group(0)
def replace_anchor(match: re.Match[str]) -> str:
nonlocal fixed, changed
slug = match.group("slug")
declared = int(match.group("count"))
filename = slug_to_filename(slug)
actual = actual_for(filename, cache)
if actual is None:
fail(
f"INDEX-sections.md#{slug}-{declared}-lines - "
f"missing {ref_path_for(filename).relative_to(ROOT)}"
)
return match.group(0)
if declared != actual:
if fix:
fixed += 1
changed = True
return f"INDEX-sections.md#{slug}-{actual}-lines"
fail(
f"INDEX-sections.md#{slug}-{declared}-lines - "
f"{filename} actual {actual} lines"
)
return match.group(0)
def replace_approx(match: re.Match[str]) -> str:
nonlocal fixed, changed
filename = match.group("file")
declared = int(match.group("count"))
actual = actual_for(filename, cache)
if actual is None:
fail(f"[{filename}](...) (~{declared} lines) - missing reference file")
return match.group(0)
expected = approximate_line_count(actual)
if declared != expected:
if fix:
fixed += 1
changed = True
link_target = match.group(0).split("](", 1)[1].split(")", 1)[0]
return f"[{filename}]({link_target}) (~{expected} lines)"
fail(
f"[{filename}](...) (~{declared} lines) - "
f"expected ~{expected} from actual {actual}"
)
return match.group(0)
new_text = HEADING_RE.sub(replace_heading, text)
new_text = ANCHOR_RE.sub(replace_anchor, new_text)
new_text = APPROX_RE.sub(replace_approx, new_text)
if fix and changed:
md_path.write_text(new_text, encoding="utf-8")
return errors, fixed, changed
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--fix",
action="store_true",
help="Rewrite stale counts in skill markdown (local use only)",
)
args = parser.parse_args()
cache: dict[str, int] = {}
all_errors: list[str] = []
total_fixed = 0
files_changed = 0
files_checked = 0
for md_path in collect_markdown_files():
files_checked += 1
errors, fixed, changed = process_file(md_path, fix=args.fix, cache=cache)
all_errors.extend(errors)
total_fixed += fixed
if changed:
files_changed += 1
if args.fix and files_changed:
print(f"Updated {total_fixed} line count(s) across {files_changed} file(s).")
if all_errors and not args.fix:
print("Reference line count check failed:", file=sys.stderr)
for err in all_errors:
print(f" - {err}", file=sys.stderr)
print(
"Run: ./.github/scripts/check-skill-index-line-counts.sh --fix",
file=sys.stderr,
)
return 1
if all_errors and args.fix:
print(
f"WARN: {len(all_errors)} issue(s) could not be auto-fixed.",
file=sys.stderr,
)
for err in all_errors:
print(f" - {err}", file=sys.stderr)
return 1
if args.fix and total_fixed:
print(
f"OK: {files_checked} markdown file(s) checked, "
f"{total_fixed} count(s) refreshed."
)
return 0
print(f"OK: {files_checked} markdown file(s), reference line counts match.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Verify internal markdown links in the skill package resolve to existing files."""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
LINK_RE = re.compile(r"\]\(([^)]+)\)")
FORBIDDEN_ABS_REF = "](/references/"
SCAN_GLOBS = (
"SKILL.md",
"references/**/*.md",
"assets/convention/*.md",
"README.md",
)
def is_external(url: str) -> bool:
lowered = url.lower()
return lowered.startswith(("http://", "https://", "mailto:", "javascript:"))
def resolve_target(source: Path, target: str) -> Path:
path_part, _, _ = target.partition("#")
path_part = path_part.strip()
if not path_part:
return source
if path_part.startswith("/"):
return (ROOT / path_part.lstrip("/")).resolve()
return (source.parent / path_part).resolve()
def collect_markdown_files() -> list[Path]:
files: list[Path] = []
for pattern in SCAN_GLOBS:
files.extend(ROOT.glob(pattern))
return sorted({path for path in files if path.is_file()})
def check_file(md_path: Path) -> list[str]:
errors: list[str] = []
text = md_path.read_text(encoding="utf-8")
if md_path.parent == ROOT / "references" and FORBIDDEN_ABS_REF in text:
errors.append(
f"{md_path.relative_to(ROOT)}: use relative links (foo.md), not {FORBIDDEN_ABS_REF}..."
)
for match in LINK_RE.finditer(text):
target = match.group(1).strip()
if not target or is_external(target):
continue
resolved = resolve_target(md_path, target)
if not resolved.exists():
rel_source = md_path.relative_to(ROOT)
errors.append(f"{rel_source}: broken link -> {target}")
return errors
def main() -> int:
all_errors: list[str] = []
for md_path in collect_markdown_files():
all_errors.extend(check_file(md_path))
if all_errors:
print("Broken internal markdown links:", file=sys.stderr)
for error in all_errors:
print(f" {error}", file=sys.stderr)
print(f"\n{len(all_errors)} broken link(s).", file=sys.stderr)
return 1
scanned = len(collect_markdown_files())
print(f"OK: {scanned} markdown file(s), all internal links resolve.")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Fail on non-ASCII typography in skill markdown (SKILL.md, references/, convention docs).
Use plain ASCII in prose:
- hyphen-minus `-` for dashes (not en/em dash or Unicode minus)
- ASCII `'` and `"`
- ASCII space (U+0020)
- three periods `...` (not ellipsis character)
- keyboard operators `*`, `/`, `<=`, `>=`, `!=` (not x, division, inequality symbols)
- `-` or `*` for list markers (not bullet glyphs)
Skips content inside fenced code blocks. Extend FORBIDDEN_CHARS when adding rules.
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
SCAN_GLOBS = (
"SKILL.md",
"references/**/*.md",
"assets/convention/*.md",
"README.md",
)
# (character, short label, ASCII replacement hint)
FORBIDDEN_CHARS: tuple[tuple[str, str, str], ...] = (
# Dashes (use ASCII hyphen-minus)
("\u2010", "HYPHEN", "-"),
("\u2011", "NON-BREAKING HYPHEN", "-"),
("\u2012", "FIGURE DASH", "-"),
("\u2013", "EN DASH", "-"),
("\u2014", "EM DASH", "-"),
("\u2015", "HORIZONTAL BAR", "-"),
("\u2212", "MINUS SIGN", "-"),
("\u2053", "SWUNG DASH", "-"),
("\u301c", "WAVE DASH", "-"),
("\u3030", "WAVY DASH", "-"),
("\u2e3a", "TWO-EM DASH", "-"),
("\u2e3b", "THREE-EM DASH", "-"),
("\u2e40", "DOUBLE HYPHEN", "-"),
# Apostrophe-like (use ASCII ')
("\u2018", "LEFT SINGLE QUOTATION MARK", "'"),
("\u2019", "RIGHT SINGLE QUOTATION MARK", "'"),
("\u02bb", "MODIFIER LETTER TURNED COMMA", "'"),
("\u02bc", "MODIFIER LETTER APOSTROPHE", "'"),
("\u00b4", "ACUTE ACCENT", "'"),
("\u02ca", "MODIFIER LETTER ACUTE ACCENT", "'"),
("\u02cb", "MODIFIER LETTER GRAVE ACCENT", "'"),
("\uff07", "FULLWIDTH APOSTROPHE", "'"),
("\u2032", "PRIME", "'"),
("\u2035", "REVERSED PRIME", "'"),
# Double quotes (use ASCII ")
("\u201c", "LEFT DOUBLE QUOTATION MARK", '"'),
("\u201d", "RIGHT DOUBLE QUOTATION MARK", '"'),
("\u201e", "DOUBLE LOW-9 QUOTATION MARK", '"'),
("\u201f", "DOUBLE HIGH-REVERSED-9 QUOTATION MARK", '"'),
("\u2033", "DOUBLE PRIME", '"'),
("\u2036", "REVERSED DOUBLE PRIME", '"'),
("\u301d", "REVERSED DOUBLE PRIME QUOTATION MARK", '"'),
("\u301e", "DOUBLE PRIME QUOTATION MARK", '"'),
("\u301f", "LOW DOUBLE PRIME QUOTATION MARK", '"'),
("\uff02", "FULLWIDTH QUOTATION MARK", '"'),
("\u3003", "DITTO MARK", '"'),
# Spaces (use ASCII space)
("\u00a0", "NO-BREAK SPACE", "space"),
("\u2000", "EN QUAD", "space"),
("\u2001", "EM QUAD", "space"),
("\u2002", "EN SPACE", "space"),
("\u2003", "EM SPACE", "space"),
("\u2004", "THREE-PER-EM SPACE", "space"),
("\u2005", "FOUR-PER-EM SPACE", "space"),
("\u2006", "SIX-PER-EM SPACE", "space"),
("\u2007", "FIGURE SPACE", "space"),
("\u2008", "PUNCTUATION SPACE", "space"),
("\u2009", "THIN SPACE", "space"),
("\u200a", "HAIR SPACE", "space"),
("\u202f", "NARROW NO-BREAK SPACE", "space"),
("\u205f", "MEDIUM MATHEMATICAL SPACE", "space"),
("\u3000", "IDEOGRAPHIC SPACE", "space"),
("\u2145", "DOUBLE-STRUCK ITALIC CAPITAL D", "space"),
# Ellipsis (use ...)
("\u2026", "HORIZONTAL ELLIPSIS", "..."),
# Mathematical operators
("\u00d7", "MULTIPLICATION SIGN", "*"),
("\u00f7", "DIVISION SIGN", "/"),
("\u2264", "LESS-THAN OR EQUAL", "<="),
("\u2265", "GREATER-THAN OR EQUAL", ">="),
("\u2260", "NOT EQUAL", "!="),
# List bullets
("\u2022", "BULLET", "- or *"),
("\u25e6", "WHITE BULLET", "- or *"),
("\u25aa", "BLACK SMALL SQUARE", "- or *"),
("\u25fc", "BLACK MEDIUM SQUARE", "- or *"),
)
CHAR_LOOKUP: dict[str, tuple[str, str]] = {
ch: (name, hint) for ch, name, hint in FORBIDDEN_CHARS
}
def collect_markdown_files() -> list[Path]:
files: list[Path] = []
for pattern in SCAN_GLOBS:
files.extend(ROOT.glob(pattern))
return sorted({path for path in files if path.is_file()})
def check_file(path: Path) -> list[str]:
errors: list[str] = []
in_fence = False
for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if line.strip().startswith("```"):
in_fence = not in_fence
continue
if in_fence:
continue
for col, char in enumerate(line, 1):
if char not in CHAR_LOOKUP:
continue
name, hint = CHAR_LOOKUP[char]
code = f"U+{ord(char):04X}"
errors.append(
f"{path.relative_to(ROOT)}:{line_no}:{col}: {name} {code} (use {hint})"
)
return errors
def main() -> int:
files = collect_markdown_files()
all_errors: list[str] = []
for path in files:
all_errors.extend(check_file(path))
if all_errors:
print("Typography check failed:", file=sys.stderr)
for err in all_errors:
print(f" {err}", file=sys.stderr)
print(
f"\n{len(all_errors)} violation(s). "
"Fix the character or extend .github/scripts/check_skill_typography.py.",
file=sys.stderr,
)
return 1
print(f"OK: typography check passed ({len(files)} markdown files).")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Fail on voice violations in SKILL.md and references/.
Directive-first prose for agent skill docs: no tutorial framing, meta references
to "this guide/skill", emoji good/bad markers, or legacy // Bad:/// Good: comments.
Scans SKILL.md and references/*.md. README.md is excluded (GitHub-facing).
Extend LINE_PATTERNS and PROSE_PATTERNS below when adding new bans.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
# Every line (including inside fenced samples — comment conventions only).
LINE_PATTERNS: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"//\s*Bad\b", re.I), "use // WRONG: in sample comments"),
(re.compile(r"//\s*Good\b", re.I), "use // CORRECT: in sample comments"),
(re.compile(r"//\s*✅|//\s*❌"), "emoji in // comments"),
(re.compile(r"//\s*Copy this into", re.I), "tutorial // Copy this into"),
]
# Markdown prose only (outside ``` fences).
PROSE_PATTERNS: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"❌|✅|🔴|🟡|🟢"), "emoji good/bad or severity markers"),
(re.compile(r"\*\*Bad:\*\*|\*\*Good:\*\*", re.I), "use **Wrong:** / **Correct:**"),
(re.compile(r">\s*\*\*Warning:\*\*", re.I), "blockquote **Warning:** (use **Required:** / **Forbidden:**)"),
(re.compile(r"\(Recommended\)", re.I), "(Recommended) label"),
(re.compile(r"\*\*Preferred:\*\*|\*\*Alternative:\*\*", re.I), "**Preferred:** / **Alternative:** framing"),
(re.compile(r"\bthis guide\b", re.I), "meta: this guide"),
(re.compile(r"\bthis skill\b|\bthis skillset\b", re.I), "meta: this skill / skillset"),
(re.compile(r"\bthis codebase\b", re.I), "meta: this codebase"),
(re.compile(r"\bthe modern\b", re.I), "the modern (delete adjective)"),
(re.compile(r"\*\*P0\s*—|\*\*P1\s*—|\*\*P2\s*—|\*\*Blocker:\*\*", re.I), "P0/P1/P2/Blocker label in prose"),
(re.compile(r"Related Guides|When to Use This Guide", re.I), "meta section title"),
(
re.compile(r"(?:^|[\s(\"'])(?:we|our|us)\b", re.I),
"first-person plural (we/our/us)",
),
(re.compile(r"^\s*Note:\s", re.I), "Note: label (promote to directive)"),
(re.compile(r"Step-by-Step", re.I), "Step-by-Step tutorial framing"),
]
def iter_voice_files() -> list[Path]:
files = [ROOT / "SKILL.md"]
ref = ROOT / "references"
if ref.is_dir():
files.extend(sorted(ref.glob("*.md")))
return [p for p in files if p.is_file()]
def check_file(path: Path) -> list[str]:
errors: list[str] = []
in_fence = False
for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
stripped = line.strip()
if stripped.startswith("```"):
in_fence = not in_fence
continue
for pattern, label in LINE_PATTERNS:
if pattern.search(line):
errors.append(f"{path.relative_to(ROOT)}:{line_no}: {label}")
if not in_fence:
for pattern, label in PROSE_PATTERNS:
if pattern.search(line):
errors.append(f"{path.relative_to(ROOT)}:{line_no}: {label}")
return errors
def main() -> int:
files = iter_voice_files()
all_errors: list[str] = []
for path in files:
all_errors.extend(check_file(path))
if all_errors:
print("Skill voice check failed:", file=sys.stderr)
for err in all_errors:
print(f" {err}", file=sys.stderr)
print(
f"\n{len(all_errors)} violation(s). "
"Fix the line or add an exception in .github/scripts/check_skill_voice.py.",
file=sys.stderr,
)
return 1
print(f"OK: voice check passed ({len(files)} files).")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
exec python3 "${ROOT}/.github/scripts/check_skill_ergonomics.py"
#!/usr/bin/env bash
# Verify reference line counts in skill markdown (INDEX-sections headings, anchors, ~N blurbs).
# Pass --fix to update stale counts locally (not used in CI).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
exec python3 "${ROOT}/.github/scripts/check_skill_index_line_counts.py" "$@"
#!/usr/bin/env bash
# Verify internal markdown links under SKILL.md, references/, and related docs.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
exec python3 "${ROOT}/.github/scripts/check_skill_links.py" "$@"
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
exec python3 "${ROOT}/.github/scripts/check_skill_typography.py"
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
exec python3 "${ROOT}/.github/scripts/check_skill_voice.py"
#!/usr/bin/env python3
"""Rewrite ](/references/foo.md) to ](foo.md) inside references/*.md."""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
REFERENCES = ROOT / "references"
OLD = "](/references/"
NEW = "]("
def main() -> int:
changed_files = 0
replacements = 0
for path in sorted(REFERENCES.glob("*.md")):
text = path.read_text(encoding="utf-8")
if OLD not in text:
continue
updated = text.replace(OLD, NEW)
count = text.count(OLD)
path.write_text(updated, encoding="utf-8")
changed_files += 1
replacements += count
print(f"{path.relative_to(ROOT)}: {count} link(s)")
if replacements == 0:
print("No /references/ absolute links found under references/.")
return 0
print(f"Normalized {replacements} link(s) in {changed_files} file(s).")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env bash
# Validate Agent Skills frontmatter (skills-ref) and internal markdown links.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${ROOT}"
echo "==> Reference line counts (INDEX-sections headings, anchors, ~N blurbs)"
"${ROOT}/.github/scripts/check-skill-index-line-counts.sh"
echo "==> Internal markdown links"
"${ROOT}/.github/scripts/check-skill-links.sh"
echo "==> Agent ergonomics (INDEX/SKILL size, -quick.md coverage)"
"${ROOT}/.github/scripts/check-skill-ergonomics.sh"
echo "==> Skill-doc voice"
"${ROOT}/.github/scripts/check-skill-voice.sh"
echo "==> Markdown typography (ASCII punctuation and spaces)"
"${ROOT}/.github/scripts/check-skill-typography.sh"
run_skills_ref() {
# skills-ref requires the path basename to match `name:` in frontmatter (not ".").
skills-ref validate "${ROOT}"
}
if command -v skills-ref >/dev/null 2>&1; then
echo "==> Agent Skills frontmatter (skills-ref)"
run_skills_ref
echo "Skill validation passed."
exit 0
fi
if command -v uv >/dev/null 2>&1; then
echo "==> Agent Skills frontmatter (skills-ref via uv)"
uv tool run --from "skills-ref @ git+https://github.com/agentskills/agentskills.git#subdirectory=skills-ref" \
skills-ref validate "${ROOT}"
echo "Skill validation passed."
exit 0
fi
echo "WARN: skills-ref not found; skipped frontmatter check." >&2
echo "Install: uv tool install \"skills-ref @ git+https://github.com/agentskills/agentskills.git#subdirectory=skills-ref\"" >&2
echo "Link check passed."
name: Validate skill
on:
push:
branches: [main, master]
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v7
with:
enable-cache: false
- name: Validate skill package
run: ./.github/scripts/validate-skill.sh
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/androidTestResultsUserPreferences.xml
/.idea/deploymentTargetDropDown.xml
/.idea/deploymentTargetSelector.xml
/.idea/appInsightsSettings.xml
/.idea/ktlint-plugin.xml
/.idea/ktfmt.xml
/.idea/studiobot.xml
/.idea/other.xml
/.idea/runConfigurations.xml
/.idea/ChatHistory_schema_v2.xml
/.idea/artifacts/*
/.idea/kotlinNotebook.xml
/.idea/ChatHistory_schema_v3.xml
/.idea/markdown.xml
/.idea/AndroidProjectSystem.xml
/.idea/deviceManager.xml
/.idea/inspectionProfiles/
.DS_Store
/build
/captures
.cxx
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle
.gradle/
build/
.kotlin/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio
/*/build/
/*/local.properties
/*/out
/*/*/build
/*/*/production
captures/
.navigation/
*.ipr
*~
*.swp
# IntelliJ
*.iml
*.iws
/out/
deploymentTargetDropdown.xml
render.experimental.xml
# User-specific configurations
.idea/**/caches/
.idea/**/libraries/
.idea/**/shelf/
.idea/**/codeStyles
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/.name
.idea/**/compiler.xml
.idea/**/copyright/profiles_settings.xml
.idea/**/encodings.xml
.idea/**/misc.xml
.idea/**/modules.xml
.idea/**/scopes/scope_settings.xml
.idea/**/dictionaries
.idea/**/vcs.xml
.idea/**/jsLibraryMappings.xml
.idea/**/datasources.xml
.idea/**/dataSources.ids
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/assetWizardSettings.xml
.idea/**/gradle.xml
.idea/**/jarRepositories.xml
.idea/**/navEditor.xml
.idea/copilot.*.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
*.jks
*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Local task tracking
TASKS.md
/*
* Convention plugin for baseline profile generation
* Configures: Baseline profile plugin for performance optimization
* Applies to: App module
*/
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
class AndroidApplicationBaselineProfileConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "androidx.baselineprofile")
extensions.configure<ApplicationExtension> {
// Baseline profile configuration is handled by the plugin
// Just ensure we have the dependency
}
dependencies {
// Reference to baselineprofile module (if exists)
// add("baselineProfile", project(":baselineprofile"))
}
}
}
}
/*
* Convention plugin for Android application with Compose
* Applies: Compose compiler plugin and configures Compose options
* Requires: `app.android.application` (or equivalent) already applied so `com.android.application` runs exactly once.
*/
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.getByType
class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "org.jetbrains.kotlin.plugin.compose")
val extension = extensions.getByType<ApplicationExtension>()
configureAndroidCompose(extension)
}
}
}
/*
* Convention plugin for Android application modules
* Configures: Android, Lint, Dependency Guard
* Note: AGP 9+ has built-in Kotlin support, no need for kotlin-android plugin
*/
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
class AndroidApplicationConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "com.android.application")
apply(plugin = "app.android.lint")
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig {
targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
testOptions {
animationsDisabled = true
}
configureGradleManagedDevices(this)
}
extensions.configure<ApplicationAndroidComponentsExtension> {
configurePrintApksTask(this)
}
}
}
}
/*
* Convention plugin for JaCoCo code coverage on Android application modules
* Configures: JaCoCo plugin, coverage reports (XML + HTML), exclusions
* Applies to: :app module when code coverage is needed
*/
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.getByType
import org.gradle.testing.jacoco.plugins.JacocoPlugin
class AndroidApplicationJacocoConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply<JacocoPlugin>()
configureJacoco(
commonExtension = extensions.getByType<ApplicationExtension>(),
androidComponentsExtension = extensions.getByType<ApplicationAndroidComponentsExtension>(),
)
}
}
}
/*
* Convention plugin for feature implementation modules
* Configures: Feature module with UI, ViewModel, Hilt, Navigation3
* Applies to: feature/:feature-name modules
*/
import com.android.build.api.dsl.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "app.android.library")
apply(plugin = "app.android.library.compose")
apply(plugin = "app.hilt")
extensions.configure<LibraryExtension> {
testOptions {
animationsDisabled = true
}
configureGradleManagedDevices(this)
}
dependencies {
// Core dependencies
add("implementation", project(":core:ui"))
add("implementation", project(":core:domain"))
add("implementation", project(":core:data"))
// Lifecycle
add("implementation", libs.findLibrary("androidx.lifecycle.runtime.compose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.viewmodel.compose").get())
// Navigation3
add("implementation", libs.findLibrary("androidx.navigation3.runtime").get())
add("implementation", libs.findLibrary("androidx.navigation3.compose").get())
// Adaptive layouts (NavigationSuiteScaffold, ListDetailPaneScaffold, SupportingPaneScaffold)
libs.findBundle("adaptive").ifPresent { add("implementation", it) }
// Testing
add("androidTestImplementation", libs.findLibrary("androidx.lifecycle.runtime.compose").get())
}
}
}
}
/*
* Convention plugin for Android library with Compose
* Applies: Compose compiler plugin and configures Compose options
* Requires: `app.android.library` (or `app.android.feature`) already applied so `com.android.library` runs exactly once.
*/
import com.android.build.api.dsl.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.getByType
class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "org.jetbrains.kotlin.plugin.compose")
val extension = extensions.getByType<LibraryExtension>()
configureAndroidCompose(extension)
}
}
}
/*
* Convention plugin for Android library modules
* Configures: Android, Lint, Testing
* Note: AGP 9+ has built-in Kotlin support, no need for kotlin-android plugin
*/
import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "com.android.library")
apply(plugin = "app.android.lint")
extensions.configure<LibraryExtension> {
configureKotlinAndroid(this)
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Version catalog entries for targetSdk
testOptions.targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
lint.targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
}
testOptions {
animationsDisabled = true
}
configureGradleManagedDevices(this)
// Resource prefix based on module path
// :core:data → core_data_
resourcePrefix = path.split("""\W""".toRegex())
.drop(1)
.distinct()
.joinToString(separator = "_")
.lowercase() + "_"
}
extensions.configure<LibraryAndroidComponentsExtension> {
configurePrintApksTask(this)
disableUnnecessaryAndroidTests(target)
}
dependencies {
add("androidTestImplementation", libs.findLibrary("kotlin.test").get())
add("testImplementation", libs.findLibrary("kotlin.test").get())
add("testImplementation", libs.findLibrary("junit").get())
}
}
}
}
/*
* Convention plugin for JaCoCo code coverage on Android library modules
* Configures: JaCoCo plugin, coverage reports (XML + HTML), exclusions
* Applies to: Library modules when code coverage is needed
*/
import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.getByType
import org.gradle.testing.jacoco.plugins.JacocoPlugin
class AndroidLibraryJacocoConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply<JacocoPlugin>()
configureJacoco(
commonExtension = extensions.getByType<LibraryExtension>(),
androidComponentsExtension = extensions.getByType<LibraryAndroidComponentsExtension>(),
)
}
}
}
/*
* Convention plugin for Android Lint configuration
* Configures: XML/SARIF reports, dependency checking
*/
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.dsl.Lint
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
class AndroidLintConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
when {
pluginManager.hasPlugin("com.android.application") ->
configure<ApplicationExtension> { lint(Lint::configureLint) }
pluginManager.hasPlugin("com.android.library") ->
configure<LibraryExtension> { lint(Lint::configureLint) }
else -> {
apply(plugin = "com.android.lint")
configure<Lint> { configureLint() }
}
}
}
}
}
private fun Lint.configureLint() {
xmlReport = true
sarifReport = true
checkDependencies = true
// Disable noisy dependency warnings
disable += "GradleDependency"
}
/*
* Convention plugin for Room database modules
* Configures: Room 3 plugin, KSP, schema directory, bundled SQLite driver
*/
import androidx.room3.gradle.RoomExtension
import com.google.devtools.ksp.gradle.KspExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
class AndroidRoomConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "androidx.room3")
apply(plugin = "com.google.devtools.ksp")
extensions.configure<KspExtension> {
arg("room.generateKotlin", "true")
}
extensions.configure<RoomExtension>("room3") {
// Schema directory for Room auto migrations
// See https://developer.android.com/reference/kotlin/androidx/room3/AutoMigration
schemaDirectory("$projectDir/schemas")
}
dependencies {
add("implementation", libs.findLibrary("room3.runtime").get())
add("implementation", libs.findLibrary("androidx.sqlite.bundled").get())
add("ksp", libs.findLibrary("room3.compiler").get())
}
}
}
}
/*
* Convention plugin for Android test modules
* Configures: Test modules for instrumentation testing
* Applies to: test modules (e.g., :benchmark, :baselineprofile)
* Note: AGP 9+ has built-in Kotlin support, no need for kotlin-android plugin
*/
import com.android.build.api.dsl.TestExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
class AndroidTestConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "com.android.test")
extensions.configure<TestExtension> {
configureKotlinAndroid(this)
defaultConfig {
targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
}
configureGradleManagedDevices(this)
}
}
}
}
/*
* Build script for convention plugins
* This module contains reusable convention plugins for the project
*/
plugins {
`kotlin-dsl`
}
group = "com.example.buildlogic"
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
dependencies {
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.kotlin.composeGradlePlugin)
compileOnly(libs.ksp.gradlePlugin)
compileOnly(libs.room3.gradlePlugin)
implementation(libs.plugin.detekt)
implementation(libs.kotlinx.coroutines.core)
}
gradlePlugin {
plugins {
register("androidApplication") {
id = "app.android.application"
implementationClass = "AndroidApplicationConventionPlugin"
}
register("androidApplicationCompose") {
id = "app.android.application.compose"
implementationClass = "AndroidApplicationComposeConventionPlugin"
}
register("androidApplicationBaselineProfile") {
id = "app.android.application.baseline"
implementationClass = "AndroidApplicationBaselineProfileConventionPlugin"
}
register("androidApplicationJacoco") {
id = "app.android.application.jacoco"
implementationClass = "AndroidApplicationJacocoConventionPlugin"
}
register("androidLibrary") {
id = "app.android.library"
implementationClass = "AndroidLibraryConventionPlugin"
}
register("androidLibraryCompose") {
id = "app.android.library.compose"
implementationClass = "AndroidLibraryComposeConventionPlugin"
}
register("androidLibraryJacoco") {
id = "app.android.library.jacoco"
implementationClass = "AndroidLibraryJacocoConventionPlugin"
}
register("androidFeature") {
id = "app.android.feature"
implementationClass = "AndroidFeatureConventionPlugin"
}
register("androidTest") {
id = "app.android.test"
implementationClass = "AndroidTestConventionPlugin"
}
register("androidRoom") {
id = "app.android.room"
implementationClass = "AndroidRoomConventionPlugin"
}
register("androidLint") {
id = "app.android.lint"
implementationClass = "AndroidLintConventionPlugin"
}
register("hilt") {
id = "app.hilt"
implementationClass = "HiltConventionPlugin"
}
register("detekt") {
id = "app.detekt"
implementationClass = "DetektConventionPlugin"
}
register("spotless") {
id = "app.spotless"
implementationClass = "SpotlessConventionPlugin"
}
register("jvmLibrary") {
id = "app.jvm.library"
implementationClass = "JvmLibraryConventionPlugin"
}
register("kotlinSerialization") {
id = "app.kotlin.serialization"
implementationClass = "KotlinSerializationConventionPlugin"
}
register("firebase") {
id = "app.firebase"
implementationClass = "FirebaseConventionPlugin"
}
register("sentry") {
id = "app.sentry"
implementationClass = "SentryConventionPlugin"
}
register("playVitals") {
id = "app.play.vitals"
implementationClass = "PlayVitalsReportingConventionPlugin"
}
}
}
/*
* Compose configuration utilities
* Configures: Compose features, compiler metrics, stability configuration
*/
import com.android.build.api.dsl.CommonExtension
import org.gradle.api.Project
import org.gradle.api.provider.Provider
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension
/**
* Configure Compose-specific options
*/
internal fun Project.configureAndroidCompose(
commonExtension: CommonExtension,
) {
commonExtension.apply {
buildFeatures.compose = true
dependencies {
val bom = libs.findLibrary("androidx.compose.bom").get()
add("implementation", platform(bom))
add("androidTestImplementation", platform(bom))
add("implementation", libs.findLibrary("androidx.compose.ui.tooling.preview").get())
add("debugImplementation", libs.findLibrary("androidx.compose.ui.tooling").get())
}
}
extensions.configure<ComposeCompilerGradlePluginExtension> {
fun Provider<String>.onlyIfTrue() =
flatMap { provider { it.takeIf(String::toBoolean) } }
fun Provider<*>.relativeToRootProject(dir: String) = map {
@Suppress("UnstableApiUsage")
isolated.rootProject.projectDirectory
.dir("build")
.dir(projectDir.toRelativeString(rootDir))
}.map { it.dir(dir) }
// Enable Compose compiler metrics (set enableComposeCompilerMetrics=true in gradle.properties)
project.providers.gradleProperty("enableComposeCompilerMetrics")
.onlyIfTrue()
.relativeToRootProject("compose-metrics")
.let(metricsDestination::set)
// Enable Compose compiler reports (set enableComposeCompilerReports=true in gradle.properties)
project.providers.gradleProperty("enableComposeCompilerReports")
.onlyIfTrue()
.relativeToRootProject("compose-reports")
.let(reportsDestination::set)
// Compose stability configuration file
@Suppress("UnstableApiUsage")
stabilityConfigurationFiles.add(
isolated.rootProject.projectDirectory.file("compose_compiler_config.conf")
)
}
}
/*
* Android instrumentation test utilities
* Configures: Disable unnecessary Android tests for non-UI modules
*/
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import org.gradle.api.Project
/**
* Disable unnecessary Android instrumentation tests for modules without UI
* This improves build performance by skipping test APK generation
*/
internal fun LibraryAndroidComponentsExtension.disableUnnecessaryAndroidTests(
project: Project,
) = beforeVariants {
it.enableAndroidTest = it.enableAndroidTest &&
project.projectDir.resolve("src/androidTest").exists()
}
/*
* Gradle Managed Devices configuration
* Configures: Emulator devices for instrumentation tests
* Note: AGP 9+ uses localDevices/create instead of devices/maybeCreate
*/
import com.android.build.api.dsl.CommonExtension
import org.gradle.kotlin.dsl.get
import org.gradle.kotlin.dsl.invoke
/**
* Configure project for Gradle managed devices
*/
internal fun configureGradleManagedDevices(
commonExtension: CommonExtension,
) {
val pixel6Api31 = DeviceConfig("Pixel 6", 31, "aosp")
val pixel8Api34 = DeviceConfig("Pixel 8", 34, "google")
val pixel9Api36 = DeviceConfig("Pixel 9", 36, "google")
val allDevices = listOf(pixel6Api31, pixel8Api34, pixel9Api36)
val ciDevices = listOf(pixel6Api31)
commonExtension.testOptions.apply {
managedDevices {
localDevices {
allDevices.forEach { deviceConfig ->
create(deviceConfig.taskName) {
device = deviceConfig.device
apiLevel = deviceConfig.apiLevel
systemImageSource = deviceConfig.systemImageSource
}
}
}
groups {
create("ci") {
ciDevices.forEach { deviceConfig ->
targetDevices.add(localDevices[deviceConfig.taskName])
}
}
}
}
}
}
private data class DeviceConfig(
val device: String,
val apiLevel: Int,
val systemImageSource: String,
) {
val taskName = buildString {
append(device.lowercase().replace(" ", ""))
append("api")
append(apiLevel.toString())
append(systemImageSource.replace("-", ""))
}
}
/*
* JaCoCo configuration for Android modules
* Generates combined coverage reports from unit and instrumented tests
*/
import com.android.build.api.artifact.ScopedArtifact
import com.android.build.api.dsl.CommonExtension
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.api.variant.ScopedArtifacts
import com.android.build.api.variant.SourceDirectories
import org.gradle.api.Project
import org.gradle.api.file.Directory
import org.gradle.api.file.RegularFile
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.testing.Test
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.register
import org.gradle.kotlin.dsl.withType
import org.gradle.testing.jacoco.plugins.JacocoPluginExtension
import org.gradle.testing.jacoco.tasks.JacocoReport
import org.gradle.testing.jacoco.tasks.JacocoReportsContainer
import java.util.Locale
private val coverageExclusions = listOf(
// Android
"**/R.class",
"**/R\$*.class",
"**/BuildConfig.*",
"**/Manifest*.*",
"**/Hilt_*.class",
"**/*_Hilt*.class",
"**/*_Factory.class",
"**/*_MembersInjector.class",
"**/Dagger*.class",
"**/*Module.class",
"**/*Component.class",
"**/*ComponentImpl.class",
)
private fun String.capitalize() = replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
}
/**
* Creates a new task that generates a combined coverage report with data from local and
* instrumented tests.
*
* Task name: `create{variant}CombinedCoverageReport`
*
* Example: `./gradlew createDebugCombinedCoverageReport`
*
* Coverage data must exist before running the task. Run tests first:
* - Unit tests: `./gradlew testDebugUnitTest`
* - Instrumented tests: `./gradlew connectedDebugAndroidTest`
*
* If configuration fails with MissingValueException / unresolved providers on `compile*JavaWithJavac` after an AGP bump, isolate `ScopedArtifacts` wiring here before chasing Kotlin pins; see `references/android-code-coverage.md`.
*/
internal fun Project.configureJacoco(
commonExtension: CommonExtension,
androidComponentsExtension: AndroidComponentsExtension<*, *, *>,
) {
// Configure only the debug build
commonExtension.buildTypes.named("debug") {
enableAndroidTestCoverage = true
enableUnitTestCoverage = true
}
configure<JacocoPluginExtension> {
toolVersion = libs.findVersion("jacoco").get().toString()
}
androidComponentsExtension.onVariants { variant ->
val myObjFactory = project.objects
val buildDir = layout.buildDirectory.get().asFile
val allJars: ListProperty<RegularFile> = myObjFactory.listProperty(RegularFile::class.java)
val allDirectories: ListProperty<Directory> =
myObjFactory.listProperty(Directory::class.java)
val reportTask =
tasks.register<JacocoReport>(
"create${variant.name.capitalize()}CombinedCoverageReport",
) {
classDirectories.setFrom(
allJars,
allDirectories.map { dirs ->
dirs.map { dir ->
myObjFactory.fileTree().setDir(dir).exclude(coverageExclusions)
}
},
)
reports {
xml.required = true
html.required = true
}
fun SourceDirectories.Flat?.toFilePaths(): Provider<List<String>> = this
?.all
?.map { directories -> directories.map { it.asFile.path } }
?: provider { emptyList() }
sourceDirectories.setFrom(
files(
variant.sources.java.toFilePaths(),
variant.sources.kotlin.toFilePaths(),
),
)
executionData.setFrom(
project.fileTree("$buildDir/outputs/unit_test_code_coverage/${variant.name}UnitTest")
.matching { include("**/*.exec") },
project.fileTree("$buildDir/outputs/code_coverage/${variant.name}AndroidTest")
.matching { include("**/*.ec") },
)
}
variant.artifacts.forScope(ScopedArtifacts.Scope.PROJECT)
.use(reportTask)
.toGet(
ScopedArtifact.CLASSES,
{ _ -> allJars },
{ _ -> allDirectories },
)
}
tasks.withType<Test>().configureEach {
configure<org.gradle.testing.jacoco.plugins.JacocoTaskExtension> {
// Required for JaCoCo + Robolectric
// https://github.com/robolectric/robolectric/issues/2230
isIncludeNoLocationClasses = true
// Required for JDK 11+
// https://github.com/gradle/gradle/issues/5184#issuecomment-391982009
excludes = listOf("jdk.internal.*")
}
}
}
/*
* Kotlin and Android configuration utilities
* Configures: compileSdk, minSdk, Java version, Kotlin compiler options
* AGP 9+ uses built-in Kotlin; compiler options are set via KotlinCompile tasks.
*/
import com.android.build.api.dsl.CommonExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Project
import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
/**
* Configure base Kotlin with Android options
*/
internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension,
) {
commonExtension.apply {
compileSdk {
version = release(libs.findVersion("compileSdk").get().toString().toInt())
}
defaultConfig.apply {
minSdk = libs.findVersion("minSdk").get().toString().toInt()
}
compileOptions.apply {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true // Required for API < 26 (java.time, Duration API)
}
}
configureKotlinCompileTasks()
dependencies {
add("coreLibraryDesugaring", libs.findLibrary("androidx.core.desugaring").get())
}
}
/**
* Configure base Kotlin options for JVM (non-Android)
*/
internal fun Project.configureKotlinJvm() {
extensions.configure<JavaPluginExtension> {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
configureKotlin<KotlinJvmProjectExtension>()
}
/**
* Configure Kotlin compiler options via KotlinCompile tasks.
* Works with AGP 9+ built-in Kotlin where KotlinAndroidProjectExtension is not registered.
*/
private fun Project.configureKotlinCompileTasks() {
val warningsAsErrors = providers.gradleProperty("warningsAsErrors")
.map { it.toBoolean() }
.orElse(false)
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
allWarningsAsErrors = warningsAsErrors
freeCompilerArgs.addAll(
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi",
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
)
}
}
}
/**
* Configure Kotlin options for JVM projects via extension
*/
private inline fun <reified T : org.jetbrains.kotlin.gradle.dsl.KotlinBaseExtension> Project.configureKotlin() =
configure<T> {
val warningsAsErrors = providers.gradleProperty("warningsAsErrors")
.map { it.toBoolean() }
.orElse(false)
when (this) {
is KotlinJvmProjectExtension -> compilerOptions
else -> TODO("Unsupported project extension $this ${T::class}")
}.apply {
jvmTarget = JvmTarget.JVM_17
allWarningsAsErrors = warningsAsErrors
freeCompilerArgs.addAll(
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi",
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
)
}
}
/*
* Print APKs task configuration
* Creates task to print all generated APK paths
*/
import com.android.build.api.variant.AndroidComponentsExtension
import org.gradle.api.Project
import org.gradle.kotlin.dsl.register
/**
* Configure task to print all APK paths for a project
* Usage: ./gradlew printApks
*/
internal fun Project.configurePrintApksTask(
extension: AndroidComponentsExtension<*, *, *>,
) {
extension.onVariants { variant ->
tasks.register("print${variant.name.capitalize()}Apks") {
group = "help"
description = "Prints all APK paths for ${variant.name} variant"
doLast {
println("APKs for ${variant.name}:")
variant.artifacts.getAll(com.android.build.api.artifact.SingleArtifact.APK)
.forEach { apk ->
println(" - ${apk.absolutePath}")
}
}
}
}
}
/*
* Project extension utilities
* Provides: Version catalog accessor
*/
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalog
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
/**
* Access the libs version catalog from any Project
*/
val Project.libs: VersionCatalog
get() = extensions.getByType<VersionCatalogsExtension>().named("libs")
/*
* Convention plugin for Detekt static analysis
* Configures: Detekt plugin, Compose rules, baseline, type resolution
* Detekt 2.0+ uses dev.detekt package, Property API, and removed txt report
*/
import dev.detekt.gradle.Detekt
import dev.detekt.gradle.DetektCreateBaselineTask
import dev.detekt.gradle.extensions.DetektExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.withType
class DetektConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
val detektPluginId = libs.findPlugin("detekt").get().get().pluginId
pluginManager.apply(detektPluginId)
dependencies {
add("detektPlugins", libs.findLibrary("compose.rules.detekt").get())
}
extensions.configure<DetektExtension> {
buildUponDefaultConfig.set(true)
basePath.set(rootProject.layout.projectDirectory)
parallel.set(true)
config.setFrom(rootProject.file("config/detekt.yml"))
val moduleConfig = project.file("detekt.yml")
if (moduleConfig.exists()) {
config.from(moduleConfig)
}
baseline.set(project.file("detekt-baseline.xml"))
}
tasks.withType<Detekt>().configureEach {
jvmTarget.set("17")
reports {
checkstyle.required.set(true)
html.required.set(true)
sarif.required.set(true)
markdown.required.set(false)
}
if (project.pluginManager.hasPlugin("org.jetbrains.kotlin.jvm")) {
val javaExtension = extensions.findByType(JavaPluginExtension::class.java)
javaExtension?.let {
classpath.from(it.sourceSets.getByName("main").compileClasspath)
}
}
}
tasks.withType<DetektCreateBaselineTask>().configureEach {
jvmTarget.set("17")
}
}
}
/*
* Convention plugin for Firebase integration
* Configures: Firebase Crashlytics, Analytics
* Applies to: App module when using Firebase
*/
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
class FirebaseConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "com.google.gms.google-services")
apply(plugin = "com.google.firebase.crashlytics")
dependencies {
val bom = libs.findLibrary("firebase.bom").get()
add("implementation", platform(bom))
add("implementation", libs.findLibrary("firebase.analytics").get())
add("implementation", libs.findLibrary("firebase.crashlytics").get())
}
extensions.configure<CrashlyticsExtension> {
// Enable collection of native symbols for NDK crashes
nativeSymbolUploadEnabled = true
// Disable Crashlytics collection in debug builds
if (project.gradle.startParameter.taskNames.any { it.contains("Debug") }) {
mappingFileUploadEnabled = false
}
}
}
}
}
/*
* Convention plugin for Hilt dependency injection
* Configures: Hilt plugin, KSP compiler, common dependencies
*/
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.dependencies
class HiltConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "com.google.devtools.ksp")
apply(plugin = "dagger.hilt.android.plugin")
val hiltCompiler = libs.findLibrary("hilt.compiler").get()
val hiltTesting = libs.findLibrary("hilt.android.testing").get()
dependencies {
add("implementation", libs.findLibrary("hilt.android").get())
add("ksp", hiltCompiler)
// For testing
add("kspTest", hiltCompiler)
add("testImplementation", hiltTesting)
add("kspAndroidTest", hiltCompiler)
add("androidTestImplementation", hiltTesting)
}
}
}
}
/*
* Convention plugin for pure JVM/Kotlin library modules
* Configures: Kotlin JVM libraries without Android dependencies
* Applies to: Pure Kotlin modules (e.g., :core:model, utility modules)
*/
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.dependencies
class JvmLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "org.jetbrains.kotlin.jvm")
apply(plugin = "app.android.lint")
configureKotlinJvm()
dependencies {
add("testImplementation", libs.findLibrary("kotlin.test").get())
}
}
}
}
/*
* Convention plugin for Kotlin Serialization
* Configures: kotlinx-serialization for JSON/data serialization
* Applies to: Modules that need JSON serialization (e.g., network, data)
*/
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.dependencies
class KotlinSerializationConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "org.jetbrains.kotlin.plugin.serialization")
dependencies {
add("implementation", libs.findLibrary("kotlinx.serialization").get())
}
}
}
}
/*
* Optional: registers playVitalsReport on the root project only.
* See references/android-performance.md and references/gradle-setup.md
*/
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.register
class PlayVitalsReportingConventionPlugin : Plugin<Project> {
override fun apply(project: Project) {
check(project == project.rootProject) {
"app.play.vitals must be applied only in the root build.gradle.kts"
}
project.tasks.register<PlayVitalsReportingTask>("playVitalsReport") {
group = "reporting"
description =
"Optional: Play Developer Reporting API vitals (see references/android-performance.md)"
}
}
}
/*
* Optional Gradle task: entry point for Play Developer Reporting API + Slack.
* Add PlayVitalsRepository, Reporting API deps, and timeline helpers per references/android-performance.md
*/
import kotlinx.coroutines.runBlocking
import org.gradle.api.DefaultTask
import org.gradle.api.tasks.TaskAction
abstract class PlayVitalsReportingTask : DefaultTask() {
@TaskAction
fun report() {
val json = System.getenv("PLAY_REPORTING_SERVICE_ACCOUNT_JSON")
val app = System.getenv("PLAY_REPORTING_APP_RESOURCE")
if (json.isNullOrBlank() || app.isNullOrBlank()) {
logger.warn(
"Skipping play vitals report: set PLAY_REPORTING_SERVICE_ACCOUNT_JSON " +
"and PLAY_REPORTING_APP_RESOURCE",
)
return
}
runBlocking {
logger.lifecycle(
"Play vitals: env OK for $app. Add PlayVitalsRepository and uncomment the lines below (see references/android-performance.md).",
)
// Add PlayVitalsRepository to this module and catalog deps, then uncomment:
// val repository = PlayVitalsRepository(appName = app, serviceAccountJson = json)
// val timeline = buildTimelineSpecDaily(...) // GooglePlayDeveloperReportingV1beta1TimelineSpec
// val request = GooglePlayDeveloperReportingV1beta1QueryAnrRateMetricSetRequest()
// .setTimelineSpec(timeline)
// .setMetrics(listOf("anrRate", "anrRate7dUserWeighted", "anrRate28dUserWeighted", ...))
// val summary = repository.queryAnrRates(request)
// postToSlackAnr(summary) // if summary is null, post "ANR: n/a" or omit section; task still succeeds
}
}
}
Convention Plugins - Setup & Reference
Required: copy sources from assets/convention/ into build-logic/ per Setup Instructions; consumer projects never edit assets/convention/ in place.
Forbidden: drift build-logic from assets/convention/ without re-copying - stale plugins ship wrong SDKs, Detekt rules, and Room 3 wiring.
Table of Contents
- Plugin Mapping
- Common Plugin Combinations
- Setup Instructions
- What Each Plugin Provides
- Version Catalog Requirements
- Troubleshooting
Plugin Mapping Table
| Plugin ID | File | Purpose | Common Apply To |
|---|---|---|---|
app.android.application | AndroidApplicationConventionPlugin.kt | Root app module config | :app |
app.android.application.compose | AndroidApplicationComposeConventionPlugin.kt | Compose compiler only; apply after app.android.application | :app |
app.android.application.baseline | AndroidApplicationBaselineProfileConventionPlugin.kt | Baseline profiles | :app |
app.android.application.jacoco | AndroidApplicationJacocoConventionPlugin.kt | Code coverage for app | :app (when coverage needed) |
app.android.library | AndroidLibraryConventionPlugin.kt | Android library | :core:*, :feature:* |
app.android.library.compose | AndroidLibraryComposeConventionPlugin.kt | Compose compiler only; apply after app.android.library | UI libraries |
app.android.library.jacoco | AndroidLibraryJacocoConventionPlugin.kt | Code coverage for library | Libraries (when coverage needed) |
app.android.feature | AndroidFeatureConventionPlugin.kt | Feature module | :feature:auth, etc. |
app.android.test | AndroidTestConventionPlugin.kt | Test-only module | :benchmark |
app.android.room | AndroidRoomConventionPlugin.kt | Room 3 database | Modules with DB |
app.android.lint | AndroidLintConventionPlugin.kt | Lint analysis | All Android modules |
app.hilt | HiltConventionPlugin.kt | Hilt DI | All modules |
app.detekt | DetektConventionPlugin.kt | Detekt analysis | All modules |
app.spotless | SpotlessConventionPlugin.kt | Code formatting | All modules |
app.jvm.library | JvmLibraryConventionPlugin.kt | Pure Kotlin lib | :core:model |
app.kotlin.serialization | KotlinSerializationConventionPlugin.kt | JSON serialization | Network/data modules |
app.firebase | FirebaseConventionPlugin.kt | Firebase Crashlytics | :app |
app.sentry | SentryConventionPlugin.kt | Sentry crash reporting | :app |
app.play.vitals | PlayVitalsReportingConventionPlugin.kt | Root-only Play Vitals task | Root build.gradle.kts only |
Common Plugin Combinations
Required: declare the base Android plugin (app.android.application or app.android.library) before the matching Compose plugin (app.android.application.compose or app.android.library.compose). Compose convention plugins apply only org.jetbrains.kotlin.plugin.compose; they assume com.android.application / com.android.library is already on the classpath from the base convention.
Application Module
plugins {
alias(libs.plugins.app.android.application)
alias(libs.plugins.app.android.application.compose)
alias(libs.plugins.app.hilt)
alias(libs.plugins.app.detekt)
alias(libs.plugins.app.spotless)
alias(libs.plugins.app.firebase) // if using Firebase Crashlytics
alias(libs.plugins.app.sentry) // OR if using Sentry (not both)
alias(libs.plugins.app.android.application.jacoco) // if code coverage needed
}Feature Module
plugins {
alias(libs.plugins.app.android.feature) // includes library + compose + hilt
alias(libs.plugins.app.detekt)
alias(libs.plugins.app.spotless)
}Data Layer (with Room)
plugins {
alias(libs.plugins.app.android.library)
alias(libs.plugins.app.hilt)
alias(libs.plugins.app.android.room)
alias(libs.plugins.app.kotlin.serialization)
alias(libs.plugins.app.detekt)
alias(libs.plugins.app.android.library.jacoco) // if code coverage needed
}UI Library (Compose)
plugins {
alias(libs.plugins.app.android.library)
alias(libs.plugins.app.android.library.compose)
alias(libs.plugins.app.hilt)
alias(libs.plugins.app.detekt)
}Domain/Model (Pure Kotlin)
plugins {
alias(libs.plugins.app.jvm.library)
alias(libs.plugins.app.kotlin.serialization)
alias(libs.plugins.app.detekt)
}Root project (app.play.vitals)
Required: apply app.play.vitals only in the root build.gradle.kts, never in :app:
plugins {
// alias(libs.plugins.app.play.vitals)
}Play Vitals reporting plugin: android-performance.md.
Setup Instructions
Copy convention plugins
Required: copy every .kt from assets/convention/ into:
build-logic/convention/src/main/kotlin/Create build-logic tree
build-logic/
├── convention/
│ ├── build.gradle.kts (from `assets/convention/build.gradle.kts`)
│ └── src/main/kotlin/
│ ├── AndroidApplicationConventionPlugin.kt
│ ├── AndroidLibraryConventionPlugin.kt
│ ├── ... (all other .kt files)
│ └── config/
│ ├── KotlinAndroid.kt
│ ├── AndroidCompose.kt
│ └── ... (all configuration files)
└── settings.gradle.ktsCreate build-logic/settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
rootProject.name = "build-logic"
include(":convention")Wire includeBuild("build-logic") in root settings.gradle.kts
pluginManagement {
includeBuild("build-logic")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}Register plugins in the version catalog
Required: merge the [plugins] block from assets/libs.versions.toml.template (search comment Convention plugins) into gradle/libs.versions.toml.
Create Detekt configuration
Required: config/detekt.yml at repo root - start from assets/detekt.yml.template.
Compose stability configuration
Use when: enabling Compose compiler stability packages for core model classes - add compose_compiler_config.conf at repo root:
// Classes that should be considered stable for Compose
com.example.core.model.*What Each Plugin Provides
Android Application Plugin
- Android configuration with built-in Kotlin (compileSdk, minSdk, Java 17)
- Test instrumentation runner
- Gradle managed devices (Pixel 6 API 31, Pixel 8 API 34, Pixel 9 API 36)
- Lint configuration
- Core library desugaring (for API < 26)
- Print APKs task
Android Library Plugin
- Same as application + resource prefix based on module path (e.g.,
feature_auth_) - Disables Android tests for modules without
src/androidTest/ - Standard testing dependencies (JUnit, kotlin-test)
Compose Plugins
- Compose compiler plugin
- Compose BOM dependency (all Compose versions aligned)
- UI tooling (preview + debug)
- Compiler metrics/reports (if enabled via gradle.properties)
- Stability configuration (from
compose_compiler_config.conf)
Feature Plugin
- Android library + Compose + Hilt
- Auto-adds dependencies:
:core:ui,:core:domain,:core:data - Lifecycle (ViewModel + runtime-compose)
- Navigation3 (runtime + compose)
- Adaptive layouts (adaptive, adaptive-layout, adaptive-navigation, navigation-suite)
- Managed devices
Room Plugin (Room 3)
androidx.room3Gradle plugin + KSProom3-runtime+sqlite-bundled(forBundledSQLiteDriver()onRoom.databaseBuilder)room3-compiler(KSP); DAOs use `suspend` and `Flow` (no separate Room KTX artifact)room3 { schemaDirectory(...) }for schema export and auto-migrations
Hilt Plugin
- Hilt Android + KSP compiler
- Test dependencies (hilt-android-testing)
- KSP for test variants (main, test, androidTest)
Detekt Plugin
- Detekt plugin + Compose rules
- Central config (
config/detekt.yml) - Module-specific overrides (
detekt.ymlbeside the module when needed) - Baseline support (
detekt-baseline.xml) - Type resolution enabled
- XML, HTML, SARIF reports
Spotless Plugin
- ktlint for Kotlin formatting
- Format .kts files
- Format XML (for Android modules)
- Trim trailing whitespace
- Ensure newline at end of file
Firebase Plugin
- Google Services plugin
- Firebase Crashlytics plugin
- Firebase BOM dependency
- Crashlytics and Analytics libraries
- Crashlytics configuration (native symbols, debug builds)
Sentry Plugin
- Sentry Android Gradle plugin
- Sentry Kotlin Compiler plugin (automatic @Composable tagging)
- Sentry Android SDK
- Sentry Compose integration
- Automatic mapping file upload and source context
Forbidden: apply app.firebase and app.sentry together unless the product intentionally dual-reports crashes to both backends.
JaCoCo Plugins (Code Coverage)
- JaCoCo plugin + version configuration
- Combined coverage reports (unit + instrumented tests)
- Exclusions for generated code (Hilt, R files, BuildConfig)
- XML and HTML reports
- Compatible with Robolectric
- Task:
create{Variant}CombinedCoverageReport
JaCoCo workflow (commands, reports): android-code-coverage.md.
Configuration Files
Configuration utilities are located in the config/ subdirectory:
| File | Purpose |
|---|---|
config/KotlinAndroid.kt | Common Kotlin/Android config (SDK, Java 17, desugaring, opt-ins) |
config/AndroidCompose.kt | Compose configuration (BOM, metrics, stability) |
config/ProjectExtensions.kt | Version catalog access (Project.libs) |
config/GradleManagedDevices.kt | Emulator configuration for tests (Pixel 6, Pixel 8, Pixel 9) |
config/AndroidInstrumentationTest.kt | Disable unnecessary Android tests |
config/PrintApksTask.kt | Task to print APK paths |
Version Catalog Entries (libs.versions.toml)
Required: align gradle/libs.versions.toml with assets/libs.versions.toml.template - full copy for greenfield repos, selective merge when preserving existing catalog blocks.
gradle.properties Flags
# Enable Compose compiler metrics
enableComposeCompilerMetrics=true
# Enable Compose compiler reports
enableComposeCompilerReports=trueRequired output paths after enabling metrics:
build/compose-metrics/build/compose-reports/
Outcomes
| Outcome | Mechanism |
|---|---|
| Consistent SDKs | Single KotlinAndroid.kt source |
| Single edit point | Convention plugins + shared config/ |
| Thin module scripts | plugins { alias(...) } only |
| Typed Gradle DSL | Kotlin + version catalog accessors |
| Portable templates | assets/convention/ + assets/*.template |
Troubleshooting
| Issue | Fix |
|---|---|
| Plugin not found | Add includeBuild("build-logic") to root settings.gradle.kts |
| Version catalog not accessible | Fix build-logic/settings.gradle.kts from(files("../gradle/libs.versions.toml")) path |
| Type resolution fails in Detekt | ./gradlew --stop; ./gradlew clean; apply Android + Kotlin plugins before Detekt |
| Resource prefix errors | Module path must map to prefix (:feature:auth → feature_auth_) |
| Compose metrics not generated | Set gradle.properties flags; apply Compose plugin in the module emitting UI |
| Hilt compiler errors | Apply KSP plugin before Hilt in the same plugins block |
| Room schemas not found | Create $projectDir/schemas/ or disable export until migrations exist |
| Room 3 build fails (driver) | Room.databaseBuilder must call .setDriver(BundledSQLiteDriver()) (or another SQLiteDriver) |
Migration Checklist
Room 2→3, Navigation, Compose: migration.md.
Setup Checklist
- [ ] Copy all
.ktfiles tobuild-logic/convention/src/main/kotlin/ - [ ] Add
build-logic/convention/build.gradle.kts(copy fromassets/convention/build.gradle.kts) - [ ] Add
build-logic/settings.gradle.kts(see step 3 above) - [ ] Update root
settings.gradle.ktswithincludeBuild("build-logic") - [ ] Copy
detekt.yml.templatetoconfig/detekt.yml - [ ] Add convention plugin entries to
gradle/libs.versions.toml(from template) - [ ] Ensure Gradle plugin dependencies are in
gradle/libs.versions.toml(from template) - [ ] Update module build files to use convention plugins
- [ ] Remove duplicated configuration from modules
- [ ] Test build with
./gradlew build - [ ] Verify Detekt with
./gradlew detekt - [ ] Verify tests with
./gradlew test
References
/*
* Convention plugin for Sentry integration
* Configures: Sentry SDK, Compose integration, Kotlin compiler plugin
* Applies to: App module when using Sentry for crash reporting
*/
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.dependencies
class SentryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "io.sentry.android.gradle")
apply(plugin = "io.sentry.kotlin.compiler.gradle")
dependencies {
add("implementation", libs.findLibrary("sentry.android").get())
add("implementation", libs.findLibrary("sentry.compose.android").get())
}
}
}
}
/*
* Convention plugin for Spotless code formatting
* Configures: ktlint, license headers, formatting
* Applies to: All modules for consistent code style
*/
import com.diffplug.gradle.spotless.SpotlessExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
class SpotlessConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "com.diffplug.spotless")
extensions.configure<SpotlessExtension> {
kotlin {
target("src/**/*.kt")
ktlint(libs.findVersion("ktlint").get().requiredVersion)
.editorConfigOverride(
mapOf(
"android" to "true",
"max_line_length" to "120"
)
)
trimTrailingWhitespace()
endWithNewline()
}
format("kts") {
target("*.kts", "**/*.kts")
trimTrailingWhitespace()
endWithNewline()
}
// Format XML files (layouts, resources)
if (pluginManager.hasPlugin("com.android.library") ||
pluginManager.hasPlugin("com.android.application")
) {
format("xml") {
target("src/**/*.xml")
trimTrailingWhitespace()
indentWithSpaces(4)
endWithNewline()
}
}
}
}
}
}
config:
validation: true
excludes: []
processors:
active: true
exclude:
- 'DetektProgressListener'
console-reports:
active: true
exclude:
- 'ProjectStatisticsReport'
- 'ComplexityReport'
- 'NotificationReport'
- 'FileBasedFindingsReport'
comments:
active: true
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
DocumentationOverPrivateFunction:
active: false
DocumentationOverPrivateProperty:
active: false
EndOfSentenceFormat:
active: false
endOfSentenceFormat: ([.?!][ \t\n\r\f<])|([.?!:]$)
UndocumentedPublicClass:
active: false
searchInNestedClass: true
searchInInnerClass: true
searchInInnerObject: true
searchInInnerInterface: true
UndocumentedPublicFunction:
active: false
UndocumentedPublicProperty:
active: false
complexity:
active: true
ComplexCondition:
active: true
threshold: 4
ComplexInterface:
active: false
threshold: 10
includeStaticDeclarations: false
CyclomaticComplexMethod:
active: true
threshold: 15
ignoreSingleWhenExpression: false
ignoreSimpleWhenEntries: false
ignoreNestingFunctions: false
nestingFunctions:
- 'run'
- 'let'
- 'apply'
- 'with'
- 'also'
- 'use'
- 'forEach'
- 'isNotNull'
- 'ifNull'
LabeledExpression:
active: false
ignoredLabels: []
LargeClass:
active: true
threshold: 600
LongMethod:
active: true
threshold: 60
LongParameterList:
active: true
functionThreshold: 8
constructorThreshold: 6
ignoreDefaultParameters: true
MethodOverloading:
active: false
threshold: 6
NestedBlockDepth:
active: true
threshold: 4
StringLiteralDuplication:
active: false
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
threshold: 3
ignoreAnnotation: true
excludeStringsWithLessThan5Characters: true
ignoreStringsRegex: '$^'
TooManyFunctions:
active: false
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
thresholdInFiles: 11
thresholdInClasses: 11
thresholdInInterfaces: 11
thresholdInObjects: 11
thresholdInEnums: 11
ignoreDeprecated: false
ignorePrivate: false
ignoreOverridden: false
coroutines:
active: true
GlobalCoroutineUsage:
active: false
RedundantSuspendModifier:
active: true
empty-blocks:
active: true
EmptyCatchBlock:
active: true
allowedExceptionNameRegex: "^(_|(ignore|expected).*)"
EmptyClassBlock:
active: true
EmptyDefaultConstructor:
active: true
EmptyDoWhileBlock:
active: true
EmptyElseBlock:
active: true
EmptyFinallyBlock:
active: true
EmptyForBlock:
active: true
EmptyFunctionBlock:
active: true
ignoreOverridden: false
EmptyIfBlock:
active: true
EmptyInitBlock:
active: true
EmptyKtFile:
active: true
EmptySecondaryConstructor:
active: true
EmptyWhenBlock:
active: true
EmptyWhileBlock:
active: true
exceptions:
active: true
ExceptionRaisedInUnexpectedLocation:
active: false
methodNames:
- 'toString'
- 'hashCode'
- 'equals'
- 'finalize'
InstanceOfCheckForException:
active: false
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
NotImplementedDeclaration:
active: false
PrintStackTrace:
active: false
RethrowCaughtException:
active: false
ReturnFromFinally:
active: false
ignoreLabeled: false
SwallowedException:
active: false
ignoredExceptionTypes:
- 'InterruptedException'
- 'NumberFormatException'
- 'ParseException'
- 'MalformedURLException'
allowedExceptionNameRegex: "^(_|(ignore|expected).*)"
ThrowingExceptionFromFinally:
active: false
ThrowingExceptionInMain:
active: false
ThrowingExceptionsWithoutMessageOrCause:
active: false
exceptions:
- 'IllegalArgumentException'
- 'IllegalStateException'
- 'IOException'
ThrowingNewInstanceOfSameException:
active: false
TooGenericExceptionCaught:
active: true
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
exceptionNames:
- ArrayIndexOutOfBoundsException
- Error
- Exception
- IllegalMonitorStateException
- NullPointerException
- IndexOutOfBoundsException
- RuntimeException
- Throwable
allowedExceptionNameRegex: "^(_|(ignore|expected).*)"
TooGenericExceptionThrown:
active: true
exceptionNames:
- Error
- Exception
- Throwable
- RuntimeException
naming:
active: true
ClassNaming:
active: true
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
classPattern: '[A-Z$][a-zA-Z0-9$]*'
ConstructorParameterNaming:
active: true
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
parameterPattern: '[a-z][A-Za-z0-9]*'
privateParameterPattern: '[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
EnumNaming:
active: true
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
enumEntryPattern: '^[A-Z][_a-zA-Z0-9]*'
ForbiddenClassName:
active: false
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
forbiddenName: []
FunctionMaxLength:
active: false
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
maximumFunctionNameLength: 30
FunctionMinLength:
active: false
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
minimumFunctionNameLength: 3
FunctionNaming:
active: true
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
functionPattern: '^([a-zA-Z$][a-zA-Z$0-9]*)|(`.*`)$'
excludeClassPattern: '$^'
ignoreAnnotated:
- 'Composable'
FunctionParameterNaming:
active: true
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
parameterPattern: '[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
InvalidPackageDeclaration:
active: false
rootPackage: ''
MatchingDeclarationName:
active: true
MemberNameEqualsClassName:
active: true
ignoreOverridden: true
ObjectPropertyNaming:
active: true
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
constantPattern: '[A-Za-z][_A-Za-z0-9]*'
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*'
PackageNaming:
active: true
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
packagePattern: '^[a-z]+(\.[a-z][A-Za-z0-9]*)*$'
TopLevelPropertyNaming:
active: true
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
constantPattern: '[A-Z][_A-Z0-9]*'
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*'
VariableMaxLength:
active: false
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
maximumVariableNameLength: 64
VariableMinLength:
active: false
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
minimumVariableNameLength: 1
VariableNaming:
active: true
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
variablePattern: '[a-z][A-Za-z0-9]*'
privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
performance:
active: true
ArrayPrimitive:
active: true
ForEachOnRange:
active: true
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
SpreadOperator:
active: false
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
UnnecessaryTemporaryInstantiation:
active: true
potential-bugs:
active: true
Deprecation:
active: true
EqualsAlwaysReturnsTrueOrFalse:
active: true
EqualsWithHashCodeExist:
active: true
ExplicitGarbageCollectionCall:
active: true
HasPlatformType:
active: false
ImplicitDefaultLocale:
active: false
InvalidRange:
active: true
IteratorHasNextCallsNextMethod:
active: true
IteratorNotThrowingNoSuchElementException:
active: true
LateinitUsage:
active: false
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
ignoreAnnotated: []
ignoreOnClassesPattern: ""
MapGetWithNotNullAssertionOperator:
active: false
UnconditionalJumpStatementInLoop:
active: false
UnreachableCode:
active: true
UnsafeCallOnNullableType:
active: true
UnsafeCast:
active: false
UselessPostfixExpression:
active: false
WrongEqualsTypeParameter:
active: true
style:
active: true
CollapsibleIfStatements:
active: false
DataClassContainsFunctions:
active: false
conversionFunctionPrefix:
- 'to'
DataClassShouldBeImmutable:
active: false
EqualsNullCall:
active: true
EqualsOnSignatureLine:
active: false
ExplicitCollectionElementAccessMethod:
active: false
ExplicitItLambdaParameter:
active: false
ExpressionBodySyntax:
active: false
includeLineWrapping: false
ForbiddenComment:
active: true
comments:
- 'TODO:'
- 'FIXME:'
- 'STOPSHIP:'
allowedPatterns: ""
ForbiddenImport:
active: true
imports:
- 'androidx.lifecycle.LiveData'
- 'androidx.lifecycle.MutableLiveData'
forbiddenPatterns: ""
ForbiddenMethodCall:
active: false
methods: []
ForbiddenVoid:
active: false
ignoreOverridden: false
ignoreUsageInGenerics: false
FunctionOnlyReturningConstant:
active: true
ignoreOverridableFunction: true
excludedFunctions:
- 'describeContents'
ignoreAnnotated:
- "dagger.Provides"
LoopWithTooManyJumpStatements:
active: true
maxJumpCount: 1
MagicNumber:
active: true
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
ignoreNumbers:
- '-1'
- '0'
- '1'
- '2'
ignoreHashCodeFunction: true
ignorePropertyDeclaration: true
ignoreLocalVariableDeclaration: false
ignoreConstantDeclaration: true
ignoreCompanionObjectPropertyDeclaration: true
ignoreAnnotation: false
ignoreNamedArgument: true
ignoreEnums: false
ignoreRanges: false
BracesOnIfStatements:
active: false
MaxLineLength:
active: true
maxLineLength: 120
excludePackageStatements: true
excludeImportStatements: true
excludeCommentStatements: false
MayBeConst:
active: true
ModifierOrder:
active: true
NestedClassesVisibility:
active: false
NewLineAtEndOfFile:
active: true
excludes:
- '**/*.kt'
NoTabs:
active: false
OptionalAbstractKeyword:
active: true
OptionalUnit:
active: false
PreferToOverPairSyntax:
active: false
ProtectedMemberInFinalClass:
active: true
RedundantExplicitType:
active: false
RedundantVisibilityModifierRule:
active: false
ReturnCount:
active: true
max: 2
excludedFunctions:
- "equals"
excludeLabeled: false
excludeReturnFromLambda: true
excludeGuardClauses: false
SafeCast:
active: true
SerialVersionUIDInSerializableClass:
active: false
SpacingBetweenPackageAndImports:
active: false
ThrowsCount:
active: true
max: 2
TrailingWhitespace:
active: false
UnderscoresInNumericLiterals:
active: false
acceptableLength: 5
UnnecessaryAbstractClass:
active: true
ignoreAnnotated:
- "dagger.Module"
UnnecessaryApply:
active: false
UnnecessaryInheritance:
active: true
UnnecessaryLet:
active: false
UnnecessaryParentheses:
active: false
UntilInsteadOfRangeTo:
active: false
UnusedImports:
active: false
UnusedPrivateClass:
active: true
UnusedPrivateMember:
active: false
allowedNames: "(_|ignored|expected|serialVersionUID)"
ignoreAnnotated:
- 'Preview'
UseArrayLiteralsInAnnotations:
active: false
UseCheckOrError:
active: false
UseDataClass:
active: false
ignoreAnnotated: []
allowVars: false
UseIfInsteadOfWhen:
active: false
UseRequire:
active: false
UselessCallOnNotNull:
active: true
UtilityClassWithPublicConstructor:
active: true
VarCouldBeVal:
active: false
WildcardImport:
active: true
excludes:
- '**/test/**'
- '**/androidTest/**'
- '**/*.Test.kt'
- '**/*.Spec.kt'
- '**/*.Spek.kt'
excludeImports:
- 'java.util.*'
# Disable ktlint rules that are overly opinionated for most projects.
# These are registered automatically by Detekt 2.0's ktlint wrapper.
ktlint:
TrailingCommaOnCallSite:
active: false
TrailingCommaOnDeclarationSite:
active: false
ChainMethodContinuation:
active: false
ClassSignature:
active: false
FunctionSignature:
active: false
NoEmptyFirstLineInClassBody:
active: false
BlankLineBeforeDeclaration:
active: false
EnumWrapping:
active: false
FunctionExpressionBody:
active: false
BackingPropertyNaming:
active: false
MultilineExpressionWrapping:
active: false
Compose:
ComposableAnnotationNaming:
active: true
ComposableNaming:
active: true
ComposableParamOrder:
active: true
CompositionLocalAllowlist:
active: true
CompositionLocalNaming:
active: true
ContentEmitterReturningValues:
active: true
ContentTrailingLambda:
active: true
ContentSlotReused:
active: true
DefaultsVisibility:
active: true
LambdaParameterEventTrailing:
active: true
LambdaParameterInRestartableEffect:
active: true
Material2:
active: true
ModifierClickableOrder:
active: true
ModifierComposed:
active: false # Migrating Modifier.composed to Modifier.Node requires significant refactoring
ModifierMissing:
active: true
ModifierNaming:
active: true
ModifierNotUsedAtRoot:
active: true
ModifierReused:
active: true
ModifierWithoutDefault:
active: true
MultipleEmitters:
active: true
MutableParams:
active: true
MutableStateAutoboxing:
active: true
MutableStateParam:
active: true
ParameterNaming:
active: true
PreviewAnnotationNaming:
active: true
PreviewNaming:
active: false
PreviewPublic:
active: true
RememberMissing:
active: true
RememberContentMissing:
active: true
UnstableCollections:
active: false
ViewModelForwarding:
active: true
ViewModelInjection:
active: true
//settings.gradle.kts
pluginManagement {
includeBuild("build-logic")
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
// Uncomment if using libraries from JitPack
// maven { url = uri("https://jitpack.io") }
}
versionCatalogs {
create("libs") {
from(files("gradle/libs.versions.toml"))
}
}
}
rootProject.name = "{{PROJECT_NAME}}"
// Type-safe project accessors are enabled by default in Gradle 9+
// Also configure in gradle.properties:
// org.gradle.configuration-cache=true
// org.gradle.caching=true
// org.gradle.parallel=true
// App module - Navigation coordination, DI setup, app entry point
include(":app")
// Feature modules - Self-contained features with clear boundaries
// include(":feature-auth")
// include(":feature-home")
// include(":feature-profile")
// include(":feature-settings")
// Core modules - Shared library code with strict dependency rules
include(":core:domain") // Pure Kotlin: Use Cases, Repository interfaces, Domain models
include(":core:data") // Data layer: Repository implementations, DataSources, Data models
include(":core:ui") // Shared UI components, themes, base ViewModels
include(":core:network") // Retrofit, API models, network utilities
include(":core:database") // Room DAOs, entities, migrations
include(":core:datastore") // Preferences storage (DataStore)
include(":core:common") // Shared utilities, extensions, dispatchers
include(":core:testing") // Test utilities, test doubles, test rules
// Configure build optimization for multi-module projects
configureBuildOptimization()
/**
* Configures build optimization settings for our modular architecture
*/
fun configureBuildOptimization() {
// Set consistent build file names
gradle.settingsEvaluated {
for (project in projects) {
project.setBuildFileName("build.gradle.kts")
}
}
// Configure project structure validation
gradle.projectsLoaded {
validateProjectStructure()
}
}
/**
* Validates that our modular architecture rules are followed
*/
fun validateProjectStructure() {
val projects = gradle.rootProject.allprojects
// Validate no feature-to-feature dependencies
projects.forEach { project ->
project.afterEvaluate {
val dependencies = configurations.getByName("implementation").allDependencies
dependencies.forEach { dependency ->
if (dependency is ProjectDependency) {
val dependencyPath = dependency.dependencyProject.path
val currentPath = project.path
// Feature modules cannot depend on other feature modules
if (currentPath.startsWith(":feature-") && dependencyPath.startsWith(":feature-") && currentPath != dependencyPath) {
logger.warn("⚠️ VIOLATION: Feature module $currentPath depends on feature module $dependencyPath")
logger.warn(" This violates our architecture rule: NO feature-to-feature dependencies allowed")
}
// Core:domain should have no Android dependencies (enforced in build.gradle.kts)
if (currentPath == ":core:domain") {
// This is validated in the core:domain build file
}
// Core:data should depend on core:domain
if (currentPath == ":core:data" && !dependencyPath.startsWith(":core:")) {
logger.warn("⚠️ Core:data should only depend on other core modules")
}
}
}
}
}
}Android accessibility (quick)
Full guide: android-accessibility.md (~1530 lines). Section anchors: INDEX-sections.md.
Required before shipping interactive Compose UI:
contentDescriptionon every icon and meaningful image;nullonly when an adjacent label already conveys the action.- 48dp x 48dp minimum touch targets; do not rely on color alone.
- String resources for all user-visible accessibility text - android-i18n.md.
- Test with TalkBack (and Espresso a11y checks for critical flows).
Section routing
| Task | Open |
|---|---|
| WCAG 2.2 on Compose | WCAG 2.2 Criteria That Apply Here |
contentDescription, roles, custom actions | Semantic Properties |
| 48dp targets, spacing | Touch Target Sizes |
| Traversal order, headings, live regions | Screen Reader Navigation |
| Contrast, color-only cues | Color & Visual Accessibility |
| Focus order, keyboard | Focus Management |
| Tabs, lists, forms, dialogs | Common Patterns |
| TalkBack, Espresso, checks | Testing Accessibility |
Hard rules (summary)
Required:
- Concise labels (purpose, not "button" / "tap here").
mergeDescendantsto group related content;stateDescriptionfor state changes.- Support dark mode and high contrast.
Forbidden:
- Touch targets smaller than 48dp.
contentDescriptionon purely decorative images.- Ignoring form validation error announcements.
- Hardcoded user-visible strings in semantics.
Open the full file for WCAG tables, code samples, and Espresso patterns.