Developer guide
| Folder | What |
|---|---|
app/ | Android app (Kotlin, Jetpack Compose, minSdk 26, Credential Manager) |
web/ | Symfony 8 web app (PostgreSQL): agent stats API, stats pages, user admin |
docs/ | This documentation (VitePress), published to GitHub Pages |
How it works
The app signs the user in with Sign in with Google and gets a Google ID token.
In Ingress the agent shares their all-time stats with the app. The app converts them to an
agent_statrow (the column names of 4e-website) and saves it right away withPOST /api/agent-statsandAuthorization: Bearer <id token>:json{ "datetime": "2026-10-19 15:35:56", "nickname": "Agent", "faction": "Enlightened", "ap": 243517130, … }webverifies the token against its Google Web client ID (OAUTH_GOOGLE_ID), finds the user (by Google ID, else by email) and stores the stats (once perdatetime). An unknown account is registered as a user and saves stats right away. Without an agent name, the first stats set it (unless another user has it).The app shows the latest stats with medals (
GET /api/agent-stats). On the web, logged-in users see their stats at/agent-stats.Screenshots of the Ingress medal grid shared with the app are scanned on the device for 4e-website's custom medals (anomaly, annual and event medals). The agent reviews them; they are saved to
web.
The app uses the Web client ID as serverClientId; the Android OAuth client only registers the package name and SHA-1. See google-sign-in-setup.md.
Quick start
# Web app (Docker, migrations, fixtures)
cd web && bin/install && bin/start
# Android app (needs a JDK 17–25, e.g. Android Studio's JBR)
cd app
cp local.properties.example local.properties # set google.serverClientId and server URLs
./gradlew installDebugTo try everything without Google credentials, use fake tokens in dev: app/README.md → Without Google.
More details: app/README.md, web/CLAUDE.md, Architecture, API.
Tests
cd web && make tests # PHPUnit, PHPStan, Rector, PHP-CS-Fixer
cd app && ./gradlew testDebugUnitTest lintDebugCI runs both on GitHub Actions; the Symfony test coverage is published with this documentation (Downloads).
Documentation
This site is built with VitePress from docs/, in English at the top and in German and Spanish in docs/de/ and docs/es/ (the same pages and headings' anchors; a change to a page goes into all three):
cd docs
npm install
npm run dev # http://localhost:5173/AgentTools/
npm run build # docs/.vitepress/distThe GitHub Pages workflow (.github/workflows/symfony-pages.yml) builds it on every push to master that changes docs/, web/ or app/, and adds the dev APK (/agenttools-dev.apk) and the web test coverage (/coverage/). The screenshots in docs/public/screenshots/ show the demo agents (symfony console app:demo-agents; the app signed in as DemoFrog with a fake token), never real users. Each language has its own: English in app/ and web/, German and Spanish in their de/ and es/ folders, which the German and Spanish pages show.
The website's screenshots are taken by a script, with the web app running in dev and the demo agents added: headless Chromium in the dark theme and in each language, signed in as DemoFrog, without the dev-only parts. It needs a Chromium or Chrome (CHROME, default chromium; not Brave, which hides the browser's language from sites):
cd docs
CHROME=/path/to/chrome npm run screenshots:web # optional: -- <URL of the web app> [en de es]The app's screenshots are taken on a phone over adb, in each language. The script needs the web app on 127.0.0.1:8001 with fake tokens and the debug app signed in with them as DemoFrog; how to set that up is at the top of docs/scripts/app-screenshots.sh. Real users are private while it runs, and their settings and the app's language are restored when it ends. A phone or the Pixel 6a emulator (Pixel_6a_API_35, the same screen) works; with both connected ANDROID_SERIAL picks one, and the script's top has the emulator's command (with 2 GB instead of its 6):
cd docs
npm run screenshots:app # optional: -- en de esThe link list
The Ingress link list of the website (/links), the app (☰ → Links) and this site (Links) is one Markdown file, docs/links.md, changed with pull requests. Between <!-- #region list --> and <!-- #endregion list --> it has only ## sections (in English) with entries - [Title](https://…) — Description (the description is optional, https only); the German and Spanish pages include that part.
- The web app reads the file from its checkout (the deploy has the whole repository), so a merged change is live after the next deploy.
LinkList/LinkListParserreads it with league/commonmark (cached until the file changes) for/linksandGET /api/links. - The app loads
GET /api/linksand keeps the last list in its files (links.json) for offline. LinkListFileTestfails on an entry in another format; the Symfony tests run on pull requests that change the file..github/workflows/links.ymlchecks every link with lychee once a day (config in.github/lychee.toml: retries, 403 and 429 count as working). A link that fails two runs in a row goes into one issue with the labelbroken-links, updated each day and closed when all links work again. A pull request that changes the list fails on a broken link. Telegram (t.me) answers even for a channel that is gone, so the check cannot find those.
Dev app
The GitHub Pages workflow also builds the dev build type of the latest master and puts it on the Downloads page as agenttools-dev.apk. It is a release build with its own package (com.elkuku.agenttools.dev, "AgentTools Dev", installs next to the release app), talks to the live server and, like debug builds, has the server switch in its settings (HTTPS only). Its version name is <versionName>-dev+<run number>.<commit>.
It is signed with the dev key (~/agenttools-dev.jks, alias agenttools-dev), stored in the repository secrets DEV_KEYSTORE_BASE64 (base64 -w0 ~/agenttools-dev.jks) and DEV_KEYSTORE_PASSWORD (store and key password). Its SHA-1 has its own Android OAuth client for the .dev package. Build it locally with the dev.* properties (see local.properties.example): cd app && ./gradlew assembleDev.
Releasing the app
- Raise
versionCodeandversionNameinapp/app/build.gradle.kts(an APK with the same or a lowerversionCodedoesn't install over the previous one). - Build the signed APK:
cd app && ./gradlew assembleRelease. It is signed with the keystore set inlocal.properties(release.*, seelocal.properties.example) and usesserver.baseUrl.release. The release key's SHA-1 has its own Android OAuth client. - Publish it as a GitHub release, e.g.
gh release create v0.2.0 agenttools-0.2.0.apk --title "AgentTools app 0.2.0". The README links to the latest release.
Deploying the web app
On the server, web/bin/deploy installs the Composer dependencies, dumps .env.local.php, compiles the assets and runs the migrations with Plesk's PHP. The SSH user needs a non-chrooted shell (/bin/bash), otherwise Plesk's PHP isn't available. Production logs are in web/var/log/prod-*.log.
Slack notifications
The web app posts to Slack: new users, stats uploads (with the AP gain) and CSV imports to an activity channel (SLACK_CHANNEL), and errors (the same one at most every 10 minutes; not 400, 403, 404, 405, 422) and deprecations (each once a day) to an errors channel (SLACK_ERRORS_CHANNEL). Production uses #agenttools-uploads and #agenttools-errors, dev #agenttools-uploads-dev and #agenttools-errors-dev (web/.env.dev); tests post nothing. The same messages also go to Telegram.
- At api.slack.com/apps, create an app in your workspace, give its bot the scope
chat:write(OAuth & Permissions) and install it. Copy the bot token (xoxb-…). - Create the four channels and invite the bot to each (
/invite @AgentTools). - Put the token in
web/.env.local, on the server and in your dev checkout:and deploy (SLACK_DSN=slack://xoxb-…@defaultweb/bin/deploydumps it into.env.local.php). Other channels: setSLACK_CHANNELandSLACK_ERRORS_CHANNELthere too. - Check it with
bin/console app:slack:test(-vshows Slack's answers): it checks the token, posts to both channels of the environment and says what to do for Slack's errors, e.g.not_in_channel.
Without SLACK_DSN (null://null, the default in dev and tests) nothing is posted. A Slack failure never breaks a request: activity is sent after the response, and failures only go to the log.
Telegram notifications
The same messages also go to Telegram, through the bot @IngressAgentToolsBot: activity to a channel (TELEGRAM_CHAT_ID), errors and deprecations to another (TELEGRAM_ERRORS_CHAT_ID). Production uses the channels "AgentTools Uploads" and "AgentTools Errors" (web/.env), dev "AgentTools Uploads DEV" and "AgentTools Errors DEV" (web/.env.dev); the pictures are in images/telegram/. An empty chat ID leaves Telegram out, as an empty Slack channel leaves Slack out.
- In Telegram, ask @BotFather for a new bot (
/newbot) and copy its token (123456789:AA…). - Create the four channels and add the bot to each as an administrator that may post messages.
- Put the token in
web/.env.local, on the server and in your dev checkout:and deploy.TELEGRAM_DSN=telegram://123456789:AA…@default - Check it with
bin/console app:telegram:test(-vshows Telegram's answers): it checks the token, posts to both chats of the environment and says what to do for Telegram's errors, e.g.chat not found.
Other chats: app:telegram:test --chats lists the chats the bot has seen messages from (Telegram keeps them for a day, so send a message in a new chat first); set their IDs as TELEGRAM_CHAT_ID and TELEGRAM_ERRORS_CHAT_ID in web/.env.local. A group's ID starts with -, a channel's or supergroup's with -100.