This directory holds the Android remote client: a WebView around the web
interface of the application on the PC, with a native Material 3 Expressive
pairing flow. It programs against the remote protocol, not
against the Go code. Users install the .apk and pair it with the PC; see
The Android app.
| Path | Content |
|---|---|
settings.gradle.kts, build.gradle.kts, gradle.properties |
Project setup; plugin versions are pinned here. |
gradlew, gradlew.bat, gradlew.ps1, gradle/wrapper/ |
The Gradle wrapper (Gradle Wrapper Neo); gradle-wrapper.properties pins Gradle with its SHA-256. |
versions.env |
Pinned tool versions that CI and the dev container read (Java, the command-line tools and the packages of the Android SDK, AGP, Kotlin, Material). |
app/build.gradle.kts |
Application ID dev.vfox.vfoxplus, minSdk 26, targetSdk 36; versionName is read from mygo/VERSION, so the phone shows the same version as the desktop. CameraX 1.6.2 and zxing core 3.5.3 (both Apache-2.0) scan the QR code. The Material Symbols are imported too, from google/material-design-icons (see the design system). |
app/src/main/ |
Kotlin sources (dev.vfox.vfoxplus), layouts, the Material 3 Expressive theme with the colours of the design system (values/colors.xml, values-night/colors.xml), strings in English and Simplified Chinese, and the launcher icon (adaptive, with a monochrome layer for themed icons; see the design system). |
- Pairing (
PairingFragment) is native: the fox on the 9-sided cookie of the shape library, where to find the pairing on the PC (Settings → Web interface → Pair a phone), a button that scans the QR code that the PC shows, and a field for the one-time link (its end icon pastes while the field is empty and clears it when it is not). After a first pairing the screen offers the last server first, with a button that reconnects. Connecting hides the keyboard, puts the loading indicator in the place of the button, and opens one session however often Go or Connect is pressed. - Scanning (
ScanFragment) is a CameraX preview with a zxing decoder: the decoded link fills the pairing field and connects at once. The picture is dimmed except for a square with 32 dp corners and corner brackets (ViewfinderView), which settles in on the default spatial spring, and a floating toolbar offers the torch, where the phone has one, and "Enter manually".CAMERAis requested as not required — without a camera, or when permission is denied, paste the link instead. - Session (
ViewerFragment) is a WebView of/appwith the session cookie persisted across launches, file upload enabled (for plugin.zipand.luafiles), pull-to-refresh, and a wavy progress indicator while a page loads. After a one-time link sets the cookie, the client moves to/appitself and starts the history of the page there, so that back does not load the used link again. The two things that only the app can do, pairing another link and disconnecting, are on the This phone card of the page's Settings: the app gives the page the objectwindow.vfoxAndroid(the bridge), and the page shows the card when it is there. A server that cannot be reached shows a native state (the error shape, "Try again", "Pair a new link") in place of the error page of the WebView. The web download of a failed task's full log is hidden inside the WebView, which cannot download files; read it in a browser instead. The window stays on the paired server: the address of another site that the page links to (the homepage of a plugin, its documentation) opens in the browser of the phone (shouldOverrideUrlLoading; the page clicks a link for them instead of callingwindow.open, which the WebView does not report). - TLS (
onReceivedSslError) shows the SHA-256 fingerprint dialog before trusting a self-signed certificate — the same fingerprint that the settings of vfox-plus on the PC show, in rows of eight bytes so that the two can be compared at a glance. Bringing your own certificate on the PC avoids the warning. - Back walks the WebView history before leaving the session, and the app
opts in to the predictive back animation (
enableOnBackInvokedCallback). - A new Activity (the dark theme, another language, the phone turned) gets the page and its history back: the session screen saves the state of the WebView.
The app follows the design system as the other interfaces
do. This is where it lives in android/.
- Edge to edge.
MainActivitycallsenableEdgeToEdge(), and the app draws under the system bars. A screen keeps clear of them withpadToSafeArea(Insets.kt: the bars, the display cutout and the keyboard become padding); a scroll view also brings the field that has the focus above the keyboard, because the system does not resize an edge-to-edge window. The page in the WebView gets the same insets as the CSS variables--sai-*(the bridge). - Theme.
Theme.VfoxPlusextendsTheme.Material3Expressive.DayNight.NoActionBar.values/colors.xmlandvalues-night/colors.xmlhold the 48 roles of the colour scheme of the seed#F28C1B(the theme maps 46 of them to attributes; shadow and scrim have none), with the same literal values as the--m3-*variables of the web interface. A test inmygo/pair(webapp_design_test.go) computes the scheme withmygo/ui/mcuand fails when the page or these files differ from it. The Expressive styles (Widget.Material3Expressive.*) are the defaults of the theme, which names no style of a component. A layout that needs another variant names the Expressive style (Widget.Material3Expressive.Button.TextButton) or a theme attribute (?attr/materialButtonTonalStyle), and sets the size of a button withmaterialSizeOverlay. It names no baselineWidget.Material3.*style, for that would hide the shape morph and the sizes, except for the floating toolbar of the scanner: Material 1.14.0 has no Expressive style for it, so it takesWidget.Material3.FloatingToolbar.*. - Icons. Material Symbols Rounded, as the vector drawables that Google publishes for them, which the build imports unchanged (Icons).
- Motion. The springs are the
motionSpring*attributes of the theme (Motion.kt).springInbrings the parts of a screen in, one after the other, on the default spatial spring (they rise) and the default effects spring (they fade in); the transitions between screens are shared axes on the slow effects spring, sampled into a curve over the time it needs to settle (SpringInterpolator). The root of each screen is a transition group (android:transitionGroup), so that the screen slides as one piece: otherwise each part slides by itself, and the outlined text field, which places its label from where its input is when it is laid out, keeps a label that was placed half way across. When the user removed the animations, everything jumps to its end. - Shapes. The cookie and the soft burst of the shape library
(
ExpressiveShapes, drawn byPolygonDrawable) are built from the public API ofandroidx.graphics:graphics-shapes:MaterialShapesof Material 1.14.0 is restricted to the library group, and its drawable draws nothing.
The screens are Views with Material Components 1.14.0, not Compose. Memory did not decide it: the pairing screen written both ways takes about the same, within the noise of the measuring (Views or Compose in the Android app). These did:
- Material 3 Expressive works in Views with the pinned toolchain. Material
Components 1.14.0 is a stable release with the Expressive theme, the buttons
with their shape morph, the floating toolbar and the loading and wavy progress
indicators, and it builds with AGP 8.13.2 and compileSdk 36. In Compose, as
checked on 2026-10-30, the stable Material 3 1.4.0 keeps the Expressive theme,
the motion schemes and the emphasized text styles
internal, and its shape library cannot be used; Material 3 1.5.0-beta02 is a beta that needs AGP 9.1.0, compileSdk 37 and Compose 1.12.0, and the latest Compose, 1.12.1, needs the same AGP and compileSdk. Compose would mean a pre-release dependency and a new toolchain. - The APK is smaller. Adding Compose makes it 1.2 MB (37 %) larger.
- The native part is small. The app is three screens around a WebView: what the user works in is the web interface, which no toolkit changes.
When Material 3 for Compose offers Expressive in a stable release that builds with the pinned toolchain, measure again as the memory page describes. The question will then be the size of the APK and the cost of the migration, not memory.
JDK 17 and the Android SDK (platform 36 and build-tools 35.0.0, the default of
the pinned AGP) are required. CI installs them, and so does the dev
container, with ANDROID_HOME set; locally point
ANDROID_HOME at an SDK (or write sdk.dir into the git-ignored
local.properties); Gradle installs a missing package only after its license is accepted
(sdkmanager --licenses). The wrapper downloads the Gradle that
gradle/wrapper/gradle-wrapper.properties pins, so no Gradle install is needed:
cd android
./gradlew :app:assembleReleaseThe build needs no KVM; running the APK on an emulator does (see The emulator
and KVM). The first run compiles
GradleWrapperNeo.java with the JDK and downloads Gradle and the dependencies,
which takes minutes. Where services.gradle.org
is slow, configure a download mirror for your user, not for the project; see
"Configure download mirrors" in the Neo README.
The release APK lands at
app/build/outputs/apk/release/app-release.apk
(about 3.2 MB), signed with the release key when the environment gives one, and with the debug key of the machine otherwise (see Signing status). Rename it with the version for distribution:
cp app/build/outputs/apk/release/app-release.apk \
vfox-plus_$(tr -d '[:space:]' < ../mygo/VERSION).apkInstall it on the phone (allow installing unknown apps when asked) and pair it as described in The Android app.
There is one client: the paired session is the server's web interface in the
system WebView. CI builds and measures it; run the paired line locally as
the memory page describes, with vfxtool measure-apk --pair-url.
Android installs an update only over an app signed with the same key, and refuses
another one (INSTALL_FAILED_UPDATE_INCOMPATIBLE). So every release of vfox-plus
is signed with the release key: one key, kept as the secrets of the release
environment of the repository, the same for every release. A release installs over
the one before it, and the data and the pairing with the PC stay.
The sign-windows job of the release refuses to run without the key. Its four secrets are
ANDROID_RELEASE_KEYSTORE_BASE64 (the keystore, in base64),
ANDROID_RELEASE_STORE_PASSWORD, ANDROID_RELEASE_KEY_ALIAS and
ANDROID_RELEASE_KEY_PASSWORD. The Gradle build (app/build.gradle.kts) signs with
the release key when the environment gives one (ANDROID_RELEASE_KEYSTORE and the
three passwords). Otherwise a local build uses the debug key of the machine, which
Gradle creates on the first build, so each machine has its own key: an APK of one
machine cannot update an APK of another, which is why the debug key is for local
builds only. The job of android.yml signs
its artifact with a key that it makes and throws away, so that the release signing is
built on every change. The release builds the APK unsigned (ANDROID_RELEASE_UNSIGNED
makes the Gradle build leave it so), and the sign-windows job signs it with the same
key, with apksigner after zipalign, as the Gradle build would: the key never
reaches the build job.
The four secrets are in the release environment, and nowhere else in the repository.
Only the sign-windows job of the release reads them. A run waits for a member of
@version-fox/vfox-maintainers or @version-fox/vfox-committers to approve it once,
before the signing of the Windows packages and the APK, unless an administrator of
the repository starts it: administrators may bypass the approval.
Only a run from main or from a tag v* can use the environment. GitHub does not show their values, and the maintainers do
not share them. If you need the actual values of these four secrets, contact
https://github.com/linghengqian.
To set the release key up, once. Create the release environment in the repository
settings (Environments): the required reviewers are the teams vfox-maintainers and
vfox-committers, "Allow administrators to bypass configured protection rules" is on, and
the deployment branches and tags are main and v*. Keep the keystore and its passwords in a password
manager, and a copy somewhere else: without them no later release can update an
installed app.
keytool -genkeypair -v -keystore vfox-plus-release.jks -alias vfox-plus \
-keyalg RSA -keysize 4096 -validity 10000
base64 -w0 vfox-plus-release.jks | gh secret set ANDROID_RELEASE_KEYSTORE_BASE64 --env release -R version-fox/vfox-plus
gh secret set ANDROID_RELEASE_STORE_PASSWORD --env release -R version-fox/vfox-plus
gh secret set ANDROID_RELEASE_KEY_ALIAS --env release -R version-fox/vfox-plus
gh secret set ANDROID_RELEASE_KEY_PASSWORD --env release -R version-fox/vfox-plusKeep the .jks file out of the repository: *.jks and *.keystore are ignored. The
APKs built before this key, with a debug key, cannot be updated by a release: uninstall
the app once. The version code follows the version (1.2.3 is 10203), because Android
accepts an update only with a higher one.
usesCleartextTrafficis on: the server may be plain HTTP on a trusted local network (the PC side only speaks HTTPS on non-loopback binds, but the user may terminate TLS elsewhere). The session cookie is the credential either way.- The wrapper is Gradle Wrapper Neo
0.2.0 (Apache-2.0): the stock
gradle-wrapper.jaris a binary, which the repository rejects, and Neo does the same job from one Java source file thatgradlewcompiles on first use.gradlew,gradlew.bat,gradlew.ps1andGradleWrapperNeo.javastay as the release publishes them (so.licenserc.yamlskips their headers); to update, download those four files from the new release as its README shows..gitattributeskeeps*.batas CRLF, whichgradlew.batneeds on Windows. - To change Gradle, edit
distributionUrlanddistributionSha256Sumingradle/wrapper/gradle-wrapper.properties(checksums: https://gradle.org/release-checksums/). It is 9.5.1 because AGP 8.13.2 fails on Gradle 9.8.0; raise AGP and Gradle together.