Android builds
Note
Synced from tie-gui/docs/ANDROID.md on 2026-09-17. Edit the source, not this copy.
imgview and tie-view both build as Android APKs with in-app libmpv video
playback (arm64-v8a). ./build-android.sh links against a cross-compiled
libmpv+ffmpeg vendored in third_party/android-libs/ and bundles those native
libraries into the APK. A libmpv-free build is available via NOMPV=1.
tie-audio also packages as an APK through the same script and bundles no native libraries — its on-device playback engine (pwplay’s pure-Go decoders) outputs through OpenSL ES, an Android system library. See “tie-audio local playback” below.
How the build works
libmpv is a native (cgo) dependency linked with -lmpv. Android has no system
libmpv, so we vendor a cross-compiled one and bundle it into the APK:
mpvplayer/mpv.goandmpvplayer/screenshot.gocarry//go:build !nompv, so they compile on Android. GL proc-address resolution and mpv options are factored into per-platform files:mpvplayer/platform_desktop.go(//go:build !nompv && !android, GLFW resolver) andmpvplayer/platform_android.go(//go:build android && !nompv, EGL resolver viaeglGetProcAddress,hwdec=mediacodec-copyhardware decode,ao=opensles).mpvplayer/mpv_stub.go(//go:build nompv) provides the same public API as no-ops for libmpv-free builds.NewMPVPlayerreturns an error and the frame extractors returnnil, which callers handle by falling back to a placeholder thumbnail.
The nompv tag also makes a desktop build without libmpv:
go build -tags nompv ./...Build the APKs:
git submodule update --init # fetch the fyne fork (third_party/fyne)
./build-libmpv-android.sh # first time: cross-compile + vendor libmpv
./build-android.sh # all three APKs, android/arm64, libmpv viewers
./build-android.sh imgview # just one (imgview, tie-view, tie-audio)
NOMPV=1 ./build-android.sh # libmpv-free viewer build (no video)
RELEASE=1 ./build-android.sh # signed release buildbuild-android.sh exports CGO_CFLAGS=-I third_party/android-libs/include and
CGO_LDFLAGS=-L third_party/android-libs/arm64-v8a before fyne package, then
runs bundle-native-libs.sh to inject the .so files and re-sign (see below).
The packaging tool must come from the fork
build-android.sh compiles third_party/fyne/cmd/fyne into .build/fyne
(git-ignored) and runs that — never a fyne from $PATH. Override with
FYNE_CMD=/path/to/fyne if you must.
Why: fyne package does not compile any Java at build time. It writes a
precompiled classes.dex of GoNativeActivity.java, embedded as a base64
string in cmd/fyne/internal/mobile/dex.go of whichever tool you run. The
fork’s Java adds setSystemBarsVisible(boolean), which Window.SetFullScreen
calls through JNI to enter/leave immersive mode (status + navigation bars
hidden; edge swipe shows them transiently; re-applied on window focus). The
upstream fyne.io/tools CLI has no such method in its dex, so an APK built
with it logs Fyne: cannot find method setSystemBarsVisible (Z)V at startup
and every SetFullScreen call becomes a silent no-op — the app thinks it is
fullscreen while the bars stay on screen. Check an APK with:
unzip -p cmd/tie-view/tie_view.apk classes.dex | grep -c setSystemBarsVisible # must be ≥ 1After editing GoNativeActivity.java, regenerate the embedded dex and
commit both the Java and dex.go in the submodule:
cd third_party/fyne/cmd/fyne/internal/mobile && go generategendex needs javac on $PATH and ANDROID_HOME with a platform whose
android.jar is API 30+ (the Java uses WindowInsetsController) plus
build-tools ≥ 34 (older d8 NPEs on class files from JDK 21+). It picks the
highest-numbered platform and build-tools directories. Platforms and
build-tools can be dropped in from the official zips, e.g.
platform-35_r02.zip → $ANDROID_HOME/platforms/android-35 and
build-tools_r35_linux.zip → $ANDROID_HOME/build-tools/35.0.0.
The dex compiles all internal/driver/mobile/app/*.java — which now
includes PlaybackService.java (media session / foreground service, see
below). Apps whose manifest does not declare the service never start it;
their manifests and permissions are unchanged.
tie-audio local playback
tie-audio plays on-device (no pwplay-server) when Backend = "local" — the
default on Android. The engine is pwplay’s own player package (the same
code pwplay-server runs: queue, gapless, seek, volume 0–2, pure-Go FLAC /
MP3 / WAV / OGG / Opus decoders) behind a pluggable sink; on Android the
sink is OpenSL ES (player/sink_opensl.go in the pwplay repo), a system
library, so the APK bundles no native .so and still builds with
-tags nompv. Track URLs (http://filehost/<hash>) are downloaded to the
app cache dir (the driver sets $TMPDIR) and decoded by content type, so a
phone must be able to reach the filehost.
Background playback, lock-screen controls and audio focus ride a media
playback foreground service in the fork’s dex
(internal/driver/mobile/app/PlaybackService.java — framework
MediaSession, Notification.MediaStyle, AudioFocusRequest, the
becoming-noisy broadcast; no androidx in the dex toolchain). Go reaches it
through fyne.io/fyne/v2/driver/mobile.MediaSessionDriver (a type
assertion on the driver; it fails off Android); tie-audio’s mediaBridge
(cmd/tie-audio/internal/ui/mediabridge.go) is a permanent transport view
that pushes track/state/artwork and owns the focus policy (transient loss →
pause + resume, duck → volume ×0.35 + restore, permanent loss → pause,
headphone unplug → pause). Regenerate the dex after editing the Java (see
the previous section), and note PlaybackService needs
cmd/tie-audio/AndroidManifest.xml — tie-audio ships its own manifest (the
generated template cannot declare services) and must stay in sync with the
fork’s template. The manifest’s android:foregroundServiceType attribute is
resolved by a fallback table in the fork’s binres package
(attrsAddedAfterMinSDK) — the embedded attribute table predates API 29 and
its generator cannot parse modern platform tables.
The first local playback requests the POST_NOTIFICATIONS runtime permission (API 33+); denying it hides the notification but the service still runs and lock-screen session controls still work. Playback survives screen-off and backgrounding while the process lives; the service is deliberately not sticky (without the Go process there is nothing to play).
Toolchain discovery (no hardcoded paths)
All the Android scripts source android-env.sh, which finds the Android SDK,
NDK and a working JDK on any machine instead of hardcoding one developer’s
home directory. Order of preference:
- SDK —
$ANDROID_HOME/$ANDROID_SDK_ROOTif valid, else common locations (~/android-sdk,~/Android/Sdk,~/Library/Android/sdk,/opt/android-sdk-update-manager,/opt/android-sdk,/usr/lib/android-sdk). - NDK —
$ANDROID_NDK_HOME/$ANDROID_NDK_ROOTif valid, else an NDK installed inside the SDK ($ANDROID_HOME/ndk/*,ndk-bundle), else a standalone NDK under/apps/android-ndk-*,/opt/android-ndk-*,~/android-ndk-*(newest wins). - JDK — verifies
$JAVA_HOMEactually runs; if not (e.g. a Gentoojava-configuser VM pointing at a removed JDK), it falls back to/usr/libexec/java_home,java-config -O, or a working JVM under/usr/lib/jvm//usr/lib64/jvm, and puts itsbin/first onPATH. This is whyapksigner/keytoolsign correctly even when the system defaultjavawrapper is misconfigured.
Override any of them by exporting ANDROID_HOME, ANDROID_NDK_HOME or
JAVA_HOME before running a script.
Touch gestures & mobile UX
On mobile imgview behaves like a phone photo gallery. All of the touch code is
gated behind fyne.CurrentDevice().IsMobile(), so the desktop experience
(free-drag pan, scroll-wheel zoom, keyboard navigation) is completely unchanged.
Gestures
- Swipe left / right — page to the next / previous image.
- Pinch — zoom toward the midpoint between the two fingers (focal-point zoom, the same idea as desktop scroll-zoom-toward-cursor).
- Drag while zoomed in — pan the image, clamped so it can’t be pulled past its own edges.
- Drag past the edge (or a fast flick) — once the image is at a border, the extra drag “overscrolls” and pages to the next/previous image.
These live on ImageView in gallery/imageview.go:
ImageViewimplementsmobile.Touchable(TouchDown/TouchUp/TouchCancel) andmobile.Movable(TouchMoved). It tracks each finger by itsTouchEvent.IDin thetouchesmap, so a two-finger pinch can be distinguished from a one-finger drag.Dragged/DragEndbranch todraggedMobile/dragEndMobileon mobile.panBoundscomputes the clamp; horizontal drag past a bound accumulates intoswipeAccum, anddragEndMobilepages when that exceeds a threshold (20% of width when the image fits, 40% when zoomed in — a firmer throw so panning doesn’t accidentally flip). Fast flicks page for free via Fyne’s post-release drag momentum.- Paging is wired by
Viewer.ChangeImage, which setsimg.nextFn/img.prevFntoChangeImage(NextImage()/PrevImage())— the same navigation the keyboard hotkeys use.
Performance notes
Pinch zoom resizes the image widget every frame. Four things keep it smooth:
- Fork patch:
canvas.Image.Resizedoes not invalidate the texture forImageScaleFastest/ImageScalePixels. Upstream Fyne queues a texture refresh on every resize, so a resize per pinch frame meant deleting and re-uploading the whole bitmap each frame (plus a CPU pixel conversion). With those scale modes the uploaded texture holds the source pixels unchanged, so the fork only requests a repaint and the GPU rescales the cached texture (third_party/fyne/canvas/image.go). A pinch frame is now one textured quad. - Direct resize, not
Refresh. During a live pinch,TouchMovedcallsiv.Resize/iv.Movedirectly rather thancontainer.Refresh(). The zoom-% title and layout state are synced once when the pinch ends (endPinch). - Upload-ready bitmaps.
LoadImageanddecodeFullImageconvert the decoded image to*image.RGBA(toRGBA), the only type the GL painter uploads without a full-bitmapdraw.Drawon the UI thread. The full-res decode does this on its background goroutine. - Downscale on load (
downscaleForMobile). Full-resolution phone photos are 12MP+ (~48MB of RGBA). On mobile the decoded image is scaled so its longest edge is ≤ 2× the screen’s longest edge, keeping the initial texture a few MB;applyZoomQualityswaps in the full-resolution decode once the user zooms past 1.15×. Desktop keeps full resolution.
Loading images — the folder picker
Mobile apps get no command-line arguments and no accessible working directory,
so imgview starts with an empty gallery. When that happens (cmd/imgview/main.go
detects IsMobile() && viewer.ImageCount() == 0), it shows a Select folder
button that opens the Android document chooser (dialog.ShowFolderOpen).
Picked images come back as content:// URIs, not filesystem paths, so they are
loaded through the gallery’s existing CustomReader pipeline: uriReader
(in cmd/imgview/main.go) wraps a fyne.URI, reading it once into memory
because GetReader must return a seekable stream and content URIs aren’t
re-openable like os files. readersFromFolder lists the folder, filters by
image extension, and sorts by name.
Video playback internals
Three pieces make libmpv video work on Android: the cross-compiled native libraries, an EGL-based mpv render path, and an Android GLVideo painter in the vendored Fyne fork.
1. Cross-compiled libmpv (+ ffmpeg), vendored
libmpv is not distributed for Android; it is built from source against the NDK.
The .so binaries are not committed to this repo (they are large and
reproducible) — third_party/android-libs/ is gitignored and regenerated.
One command (recommended)
./build-libmpv-android.sh # cross-compile libmpv for arm64 + vendor itThis wraps the mpv-android buildscripts so it works on a fresh machine with no manual setup:
- Clones the buildscripts under
build/mpv-android/(gitignored). - Makes the NDK version the buildscripts require available under
build/mpv-android/buildscripts/sdk/— reusing a matching NDK already on the machine, or downloading the standalone NDK zip. (Only thempvnative target is built, so the full Android SDK / Gradle app is not needed.) - Fetches the library sources (ffmpeg, mpv, dav1d, mbedtls, libass, …).
- Runs
buildall.sh --arch arm64 mpv. - Calls
vendor-android-libs.shwith the rightMPV_PREFIX/NDK.
Host build tools required (checked up front): git wget unzip meson ninja nasm autoconf automake libtool pkg-config gperf cmake javac python3. The first run
downloads a lot and the native compile takes a while; subsequent runs reuse the
checkout under build/.
Manual, if you already have an mpv-android prefix
./buildall.sh --arch arm64 mpv # builds ffmpeg + libmpv + deps for arm64
MPV_PREFIX=/path/to/prefix/arm64 ./vendor-android-libs.shvendor-android-libs.sh copies prefix/arm64/lib/lib{mpv,av*,sw*}.so plus the
NDK’s libc++_shared.so into third_party/android-libs/arm64-v8a/ (stripping
with llvm-strip --strip-unneeded, ~113 MB → ~31 MB) and the mpv headers
(prefix/arm64/include/mpv/*.h) into third_party/android-libs/include/mpv/.
libmpv.so’s NEEDED deps (libav*, libsw*, libc++_shared) are all vendored so
the runtime linker resolves them from the APK. It auto-detects the NDK and
looks for the prefix under build/mpv-android/.../prefix/arm64 then
~/src/mpv-android/...; override MPV_PREFIX / ANDROID_NDK_HOME to point
elsewhere.
2. EGL mpv render path (mpvplayer/)
mpv.go initializes mpv’s OpenGL render API via
MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, resolving GL symbols through a
platform-specific proc-address function:
platform_desktop.go(!android) resolves via GLFW and passes an X11/Wayland native display.platform_android.go(android) resolves viaeglGetProcAddress(dlsym(RTLD_DEFAULT, ...)fallback), passes no native display, and selectshwdec=mediacodec-copy(hardware decode) andao=opensles. MediaCodec needs the process JavaVM registered with ffmpeg viaav_jni_set_java_vm; the VM pointer is surfaced from the mobile driver’sJNI_OnLoadthroughfyne/v2/driver.RunNative(see themediacodec-copycommit). Frames are decoded on the GPU/DSP then copied to system memory, so the existing texture upload path is unchanged (no Surface / zero-copy interop).
RenderInto(fbo, w, h) (render_to_fbo) uses MPV_RENDER_PARAM_OPENGL_FBO, so
mpv binds the supplied framebuffer and renders into it itself. The software frame
extractor in screenshot.go (!nompv, no GL context) restores real video
thumbnails on mobile.
3. Android GLVideo painter (fork)
Fyne’s mobile GL driver (internal/driver/mobile/gl) is an x/mobile-style
work-queue binding: the EGL context is current only on the driver’s GL worker
thread, and the paint traversal goroutine merely enqueues GL calls. libmpv,
however, resolves GL entry points itself and renders synchronously, so it
must run on the GL thread. The fork adds:
- Framebuffer ops to the mobile GL binding (
CreateFramebuffer,BindFramebuffer,FramebufferTexture2D,DeleteFramebuffer,CheckFramebufferStatus) — the binding previously had none. RunOnGLThread(func())— enqueues a blocking Go callback thatDoWorkruns on the GL worker thread with the context current. This is the seam that lets libmpv’s direct GL render execute on the right thread.glvideo_mobile.go— the mobiledrawGLVideo: sets up an offscreen FBO+texture via the work queue, runsRenderIntoinsideRunOnGLThread, restores the window framebuffer, then composites the texture withdrawTextureRegion(identical FBO-composite logic toglvideo_gles.go).
Build wiring
build-android.sh points cgo at the vendored headers/libs and, after
fyne package links the app .so, calls bundle-native-libs.sh to inject the
native .so files into the APK’s lib/arm64-v8a/, zipalign, and re-sign
(fyne package’s signature is invalidated by the edit). The debug key
(~/.android/debug.keystore) is used unless RELEASE=1 with KEYSTORE /
KEYSTORE_PASS / KEY_ALIAS is set. The generated manifest already grants
INTERNET, which tie-view needs for streaming via StreamURL().
Verify a built APK contains the libs and the app links libmpv:
unzip -l cmd/imgview/imgview.apk | grep lib/arm64-v8aNot yet done
- Other ABIs. Only
arm64-v8ais built/vendored.armeabi-v7aandx86_64(emulator) would each need their own cross-compile and a bundling tweak. - On-device verification of audio output is pending (video playback with
mediacodec-copyhardware decode is verified on a Note9).