- JavaScript 66.9%
- Kotlin 24.7%
- CSS 8.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
Build Android APK / build-apk (push) Successful in 2m15s
bundle.test.js loads preload/dist, which is gitignored and was never built by the test run, so it passed only against a stale local bundle and failed on a clean checkout (as in CI). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
| .forgejo/workflows | ||
| app | ||
| gradle/wrapper | ||
| preload | ||
| upstream | ||
| .gitignore | ||
| .gitmodules | ||
| build.gradle.kts | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| README.md | ||
| settings.gradle.kts | ||
VacuumTube for Android
An Android app that runs YouTube without ads, in the interface you already know. It's a descendant of VacuumTube, which wraps YouTube's TV interface on the desktop; this port targets tablets first, then phones, and is driven entirely by touch.
How it works
YouTube's mobile site is the same interface as the YouTube app — the same layout, the same bottom bar, the
same player — and it's already built for touch. So the app is a WebView pointed at m.youtube.com, with a
script injected before any of YouTube's own code runs. That script edits YouTube's API responses on their
way into the page, which is where the ads live.
| Piece | What it does |
|---|---|
MainActivity |
The WebView, fullscreen video, the back button, window insets, keeping the screen on during playback |
UserAgent |
Builds Chrome for Android's user agent from the installed WebView, so YouTube serves the mobile site |
RequestInterceptor / CspRewriter |
Re-fetches the page document with its Content-Security-Policy loosened |
ConfigStore |
Settings, in SharedPreferences, edited from the in-page settings sheet |
VtBridge / preload/src/bridge.js |
The @JavascriptInterface the page talks to the app through |
preload/src/mweb/innertube.js |
Hooks JSON.parse, fetch and XMLHttpRequest, which is how everything below sees YouTube's responses |
Upstream VacuumTube stays as a git submodule in upstream/VacuumTube, and is never edited. What's still
taken from it is its translations, which preload/build.mjs bundles, and the idea behind the ad blocker.
The rest of upstream's preload targets the TV interface and isn't used here.
Features
- Ad blocking. Feed ads, search ads, Shorts ads and the ads before and during a video are removed from YouTube's API responses before the page sees them, so there's nothing to skip and nothing to detect.
- A default quality for every video. Pick one in settings and every video starts there. A video that doesn't go that high falls back to the closest quality below it — choose 1440p, and a video that tops out at 1080p plays at 1080p.
- SponsorBlock. Skips sponsor segments, looked up by a hash prefix of the video id so the API never learns what you're watching.
- DeArrow. Swaps clickbait titles and thumbnails for crowdsourced ones. Off by default: it has to ask another server about every video in a feed before that feed can be shown.
- Return YouTube Dislike. Puts a dislike count back on the dislike button.
- Hide Shorts. Takes the Shorts rows off Home. Search, subscriptions and the Shorts tab keep theirs.
- Home in a swipeable row. Home's recommended videos sit in one row you swipe sideways, as in the YouTube app on a tablet, and more load as you near the end. Shorts and the other sections stay below it.
- Fullscreen. The status and navigation bars are hidden; swipe in from an edge to bring them back.
The first six are set from the settings sheet, reached from the gear in YouTube's top bar.
Everything else — navigating, playing and pausing, scrubbing, fullscreen video, Shorts, search, comments, signing in, and Home apart from its row — is YouTube's own mobile interface, unmodified. There's no gesture layer in between.
What YouTube's markup can break
Three features read the page rather than the API, so YouTube renaming things will stop them working (and nothing else):
- the dislike count, which is written onto the dislike button
- the settings gear, which is placed in YouTube's top bar, and floats in the corner if it can't find one
- Home's row, which reads each of YouTube's (hidden) grid tiles; if the tiles stop carrying their data, Home falls back to YouTube's own grid
Building
Requirements: JDK 17 or 21, Node.js 20+, Android SDK (platform 35).
git submodule update --init
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
Gradle runs npm ci and the preload bundle itself. To bundle on its own: cd preload && npm run build
(output in preload/dist).
Tests
./gradlew check runs the Kotlin unit tests and the preload's own (cd preload && npm test). Those cover
the parts that are pure logic and the ones most likely to break quietly:
- ad removal against the response shapes mweb uses for feeds, search, Shorts and the player
- the quality fallback rule
- DeArrow's title and thumbnail rewriting across the renderers mweb uses for a video
- the built bundle, loaded into a stub page, to catch a startup crash or a hook that never installs
Debug builds allow WebView remote debugging: open chrome://inspect in desktop Chrome with the device
connected. window.__vtAdblockDebug = true logs what the ad blocker removes, and
window.__vtQualityDebug = true logs which quality each video was set to and why.
Releases
.forgejo/workflows/build-android.yaml runs ./gradlew check assembleRelease on every push to main and
keeps the APK as a build artifact (vacuumtube-build-<run>.apk). Pushing a v* tag does the same and then
publishes a Forgejo release with the APK attached:
git tag v0.2.0
git push origin v0.2.0
The tag names the build: v0.2.0 produces vacuumtube-0.2.0.apk with versionName 0.2.0. versionCode is
the CI run number, because it has to rise on every build and a version name can't promise that. Local builds
are versionCode 1, so installing one over a CI build needs adb install -r -d.
Release notes are the commits since the previous tag. Re-running a tag's workflow replaces the attached APK,
so a bad release can be rebuilt in place. The job uses the runner's automatic token; if that can't create
releases on your instance, add a FORGEJO_TOKEN secret (a personal access token with write:repository).
Installing with Obtainium
Add an app in Obtainium with the URL https://git.thenymans.com/brian/VacuumTubeAndroid and the
Forgejo/Codeberg source. If your Obtainium version won't accept a self-hosted host for that source, use the
HTML source with https://git.thenymans.com/brian/VacuumTubeAndroid/releases and filter links to \.apk$.
The repository is public, so no token is needed.
Release signing
Android identifies an app by its signing key: an APK signed with a different key can't upgrade an installed one (the only fix is to uninstall, which here also signs you out of YouTube), and losing the key means no existing install can ever be updated again. Back it up.
Create the key once, outside the repository:
cd ~
keytool -genkeypair -v -keystore vacuumtube-release.jks -alias vacuumtube \
-keyalg RSA -keysize 4096 -validity 10000
It asks for one password: a PKCS12 keystore has a single password covering the store and the key. Then add four secrets under the repository's Settings → Actions → Secrets:
| Secret | Value |
|---|---|
ANDROID_KEYSTORE_BASE64 |
base64 -w0 < vacuumtube-release.jks |
ANDROID_KEYSTORE_PASSWORD |
the password |
ANDROID_KEY_ALIAS |
vacuumtube |
ANDROID_KEY_PASSWORD |
the same password again |
Until they exist, release builds fall back to the debug key rather than failing; the workflow's "Report the
signing certificate" step prints which certificate each APK carries. To sign a release build locally, put
the same four values in keystore.properties at the repository root (gitignored):
storeFile=/absolute/path/to/vacuumtube-release.jks
storePassword=...
keyAlias=vacuumtube
keyPassword=...
Not supported
- Device discoverability (DIAL), casting from the YouTube phone app: needs a native HTTP server and an SSDP listener.
- Picture-in-picture and background playback: the mobile site doesn't offer them, and nothing here adds them yet.
- Custom CSS (userstyles): needs a folder or file picker the user can reach.