No description
  • JavaScript 66.9%
  • Kotlin 24.7%
  • CSS 8.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Brian c350236c1f
All checks were successful
Build Android APK / build-apk (push) Successful in 2m15s
Build the preload bundle before its tests
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>
2026-09-22 23:46:59 -06:00
.forgejo/workflows Build and publish signed release APKs from Forgejo Actions 2026-09-22 23:39:14 -06:00
app Build the preload bundle before its tests 2026-09-22 23:46:59 -06:00
gradle/wrapper Android port of VacuumTube with touch controls for tablets 2026-09-15 11:42:04 -06:00
preload Build the preload bundle before its tests 2026-09-22 23:46:59 -06:00
upstream Android port of VacuumTube with touch controls for tablets 2026-09-15 11:42:04 -06:00
.gitignore Build and publish signed release APKs from Forgejo Actions 2026-09-22 23:39:14 -06:00
.gitmodules Android port of VacuumTube with touch controls for tablets 2026-09-15 11:42:04 -06:00
build.gradle.kts Android port of VacuumTube with touch controls for tablets 2026-09-15 11:42:04 -06:00
gradle.properties Android port of VacuumTube with touch controls for tablets 2026-09-15 11:42:04 -06:00
gradlew Android port of VacuumTube with touch controls for tablets 2026-09-15 11:42:04 -06:00
gradlew.bat Android port of VacuumTube with touch controls for tablets 2026-09-15 11:42:04 -06:00
README.md Build and publish signed release APKs from Forgejo Actions 2026-09-22 23:39:14 -06:00
settings.gradle.kts Android port of VacuumTube with touch controls for tablets 2026-09-15 11:42:04 -06:00

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.