A native Android video recorder that, once set up, starts and stops without opening the app — from a home-screen widget or the Stop button in the notification. It keeps recording with the screen off and does nothing else.
Kotlin + CameraX + Foreground Service + Storage Access Framework. No Compose, no WebView, no Flutter, no React Native, no heavy third-party libraries.
Two distributions are built from this source tree:
| Flavor | Where | What it contains |
|---|---|---|
foss |
F-Droid, GitHub | The app as described below: no ads, no billing, no INTERNET permission, no proprietary code. |
play |
Google Play | The same app plus Google Mobile Ads (AdMob), Google's consent form (UMP) and Play Billing for a one-time "remove ads" purchase. Everything extra lives in app/src/play; app/src/main knows nothing about it. |
Unless a section says otherwise, this document describes the foss build. For the privacy
policy of both see PRIVACY.md; for publishing to Google Play see
docs/google-play.
gradlew assembleFossDebug # installable, debug-signed, debuggable
gradlew assembleFossRelease # F-Droid / GitHub APK; unsigned unless a keystore is configured
gradlew bundlePlayRelease # .aab for the Play Console; signed if a keystore is configured
gradlew assemblePlayDebug # Play flavor with Google's sample (test) ads
gradlew testPlayDebugUnitTest # unit tests for the ad rulesThe foss APK declares no INTERNET permission, and CI checks that on every push. Debug
builds of the play flavor use Google's sample ad units, so developing never produces real
ad traffic; only release builds carry the live ad unit ids.
Create a keystore once, and keep it outside the repository:
keytool -genkey -v -keystore bcam-release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias bcamThen point local.properties at it — that file is git-ignored, so no key material
enters the repository:
RELEASE_STORE_FILE=C:/path/outside/the/repo/bcam-release.jks
RELEASE_STORE_PASSWORD=...
RELEASE_KEY_ALIAS=bcam
RELEASE_KEY_PASSWORD=...The same four values are also read from the environment, which is what CI should use.
With none of them set, assembleRelease still succeeds and simply produces an unsigned
APK, so cloning and building the project never requires a key.
Keep this keystore safe and backed up: Android identifies an app by package name and signing key, so losing it means no future version can update an installed one.
The Release AAB for Google Play workflow (Actions tab → Run workflow) builds the signed
.aab on GitHub's runners, so nothing has to be installed locally. It reads the key from four
repository secrets — RELEASE_KEYSTORE_BASE64, RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS,
RELEASE_KEY_PASSWORD — and the result is the play-release-aab artefact of the run.
JDK 17 and Android SDK Platform 36 (JDK 25 is too new for this AGP).
Keep
!out of the project path. Kotlin's compiler treats!/as the JAR-entry separator and fails on the generatedR.jar. Android Studio behaves the same way.
| Component | Version |
|---|---|
| Android Gradle Plugin | 8.13.0 |
| Gradle | 8.13 |
| Kotlin | 2.2.20 |
| compileSdk / targetSdk | 36 (Google Play requires 36 for new releases from 31 Aug 2026) |
| minSdk | 26 |
| CameraX | 1.3.4 |
| Google Mobile Ads SDK / Play Billing (play flavor only) | 25.4.0 / 9.1.0 |
app/src/main/java/io/github/holego/bcam/
├─ MainActivity.kt setup screen and manual start/stop
├─ AboutActivity.kt what the app is, privacy, contact links
├─ recording/
│ ├─ RecordingService.kt owns the camera, the recording, the file, the state
│ ├─ RecordingNotifications.kt ongoing notification + Stop action
│ └─ RecordingState.kt state machine and shared state holder
├─ camera/
│ ├─ CameraRepository.kt camera enumeration, selector, qualities, frame rates
│ └─ CameraInfoModel.kt camera model, quality and compression options
├─ storage/
│ └─ VideoStorageManager.kt SAF folder, .mp4 creation, descriptors, free space
├─ settings/
│ └─ SettingsRepository.kt SharedPreferences
└─ widget/
└─ RecordingWidgetProvider.kt home-screen button
app/src/play/…/monetization/ Google Play flavor only
├─ MonetizationController.kt the four calls MainActivity makes; wires everything below
├─ AdsManager.kt banner, interstitial, rewarded (+ interstitial), native, app-open
├─ AdPolicy.kt when ads may show (pure Kotlin, unit-tested)
├─ ConsentManager.kt Google's UMP consent form
├─ BillingManager.kt the one-time "remove_ads" product
└─ AdUnits.kt ad unit ids (test units in debug, live units in release)
app/src/foss/…/monetization/ an empty MonetizationController with the same API
All active recording lives in RecordingService. MainActivity only configures and sends
commands — closing it, or Android destroying it, does not affect a running recording.
Android imposes two separate restrictions:
- Android 12+ forbids starting a foreground service from the background.
- Android 11+ additionally denies camera and microphone access to a service that was started from the background ("while-in-use" permission restrictions).
Interacting with a widget is a documented exemption from both:
"The user performs an action on a UI element related to your app. For example, they might interact with a bubble, notification, widget, or activity." "The service starts by interacting with app widgets."
So the app needs no SYSTEM_ALERT_WINDOW, no invisible trampoline Activity, and no
battery-optimisation opt-out. To keep the exemption intact, the widget's PendingIntent
starts the service directly via PendingIntent.getForegroundService() — no intermediate
BroadcastReceiver.
The tap sends a single TOGGLE action; the service decides start-vs-stop from its own
state, so a stale widget rendering can never send the wrong command.
IDLE → STARTING → RECORDING → STOPPING → IDLE, plus ERROR.
Every transition goes through RecordingStateHolder on the main thread, which rules out
double starts, two camera instances, two Recording objects, a double stop(), and two
files being written at once. Pressing Stop during STARTING is remembered and applied as
soon as the Start event arrives. A 15-second watchdog fails cleanly if the camera never
opens (for example when another app is holding it).
A file counts as recorded only after VideoRecordEvent.Finalize. Empty files and
ERROR_NO_VALID_DATA results are deleted automatically.
A PARTIAL_WAKE_LOCK is held while a recording is active.
Why: with the screen off many devices suspend the application processor. The camera and encoder hold their own kernel wake locks, but the app-side code feeding them does not, and on a number of devices that is enough to truncate or corrupt a long screen-off recording.
It is partial only: it never turns the screen on, never keeps it on, and does not stop the
device from locking. It is taken on Start and released on Finalize, so it cannot
outlive a recording, with a hard 4-hour cap on top. Set USE_WAKE_LOCK = false in
RecordingService to measure a device without it.
The app does not ask you to disable battery optimisation.
- Start recording, wait 10 seconds.
- Lock the phone, leave the screen off for 2–5 minutes.
- Unlock, stop the recording (app, widget or notification).
- Open the resulting MP4 in a normal player.
- Check that the duration covers the screen-off period, that audio did not drop out, and that the file is properly finalized.
| Setting | Notes |
|---|---|
| Video folder | SAF directory, persisted across reboots |
| Camera | every lens CameraX can bind (see limitations) |
| Video quality | only resolutions the selected camera reports; default 1080p |
| File size | Standard / Light (~40% smaller) / Smallest (~65% smaller) |
| Frame rate | Auto / 30 / 60, only if the camera advertises it |
| Record audio | on by default; RECORD_AUDIO requested only when you enable it |
Files are named VID_yyyy-MM-dd_HH-mm-ss.mp4.
CameraX 1.3 records MP4/H.264 and exposes no API to choose a container or a codec. The
only real lever on file size — short of lowering resolution or frame rate — is the target
encoding bitrate, which is what the File size setting drives via
Recorder.Builder.setTargetVideoEncodingBitRate(). Standard deliberately sets nothing
and leaves the device's own tuned bitrate in place.
If you want genuinely smaller files at the same resolution, the next step is HEVC/H.265
(roughly half the size of H.264). That needs the recording pipeline rewritten on
MediaCodec + MediaMuxer directly instead of CameraX, which is a large change and a lot
more device-specific fragility. It is not a setting that can simply be switched on.
CameraX can only bind cameras returned by getAvailableCameraInfos(). On many phones the
OEM hides the ultrawide and telephoto modules inside a single logical camera, where they
are reachable only as CameraCharacteristics.getPhysicalCameraIds() — and CameraX has no
supported way to bind a physical sub-camera on its own.
When the OEM publishes the lenses as separate camera ids (many devices do), they appear in the list individually as Back Main, Back Ultrawide, Back Telephoto. When it does not, you get only the logical back and front cameras.
On downloading other tools for this: there are none that solve it. No library can
unlock a lens the vendor has not exposed. The only real alternative is dropping CameraX
and writing the capture pipeline against Camera2 directly, where
OutputConfiguration.setPhysicalCameraId() (Android 9+) can target a physical sensor of a
logical multi-camera. That means reimplementing session management, encoding and rotation
by hand, and it still only works on devices whose logical camera actually advertises those
physical ids. This app deliberately does not use private or vendor APIs.
VideoCapture.Builder.setTargetFrameRate() is a request, not a guarantee. Devices clamp
it in low light or when the chosen quality has no matching camcorder profile. Only rates
the camera advertises are offered, and the setting defaults to Auto.
This section describes the foss (F-Droid / GitHub) build. The Google Play build adds ads
and a purchase and therefore uses the network; see PRIVACY.md.
The app collects, stores and transmits nothing. No account, no analytics, no crash
reporting, no telemetry, and no INTERNET permission at all. Videos are written straight
into the folder you choose and never leave the device. Settings live in the app's private
storage.
| Permission | Why |
|---|---|
CAMERA |
recording video |
RECORD_AUDIO |
requested only when you turn sound on |
FOREGROUND_SERVICE + FOREGROUND_SERVICE_CAMERA |
the recording service |
FOREGROUND_SERVICE_MICROPHONE |
declared only when audio is actually captured |
POST_NOTIFICATIONS |
Android 13+, for the recording indicator |
WAKE_LOCK |
see the screen-locked section |
No WRITE_EXTERNAL_STORAGE: everything goes through the SAF folder.
Tags: RecorderApp, RecordingService, CameraManager, Widget.
adb logcat -s RecorderApp RecordingService CameraManager WidgetLogged: camera list and selection, camera id, chosen quality / bitrate / frame rate,
initialisation, output file URI, start, stop, Finalize, and every error.
VideoRecordEvent.Status is deliberately not logged so it cannot flood logcat.
MIT © 2026 Holego