Skip to content

Commit d12a31e

Browse files
author
root
committed
TV App Lock v1.12 — PIN-lock for Android TV apps
UsageStats-polling guardian (no accessibility service), PIN gate with watchdog re-arm, configurable grace period persisted in prefs, alarm keep-alive, EN/UK UI, GitHub Actions CI + signed releases.
0 parents  commit d12a31e

28 files changed

Lines changed: 1509 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
build:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: actions/setup-java@v4
14+
with:
15+
distribution: temurin
16+
java-version: 17
17+
- uses: gradle/actions/setup-gradle@v4
18+
- name: Assemble debug APK
19+
run: ./gradlew assembleDebug --no-daemon
20+
- name: Upload artifact
21+
uses: actions/upload-artifact@v4
22+
with:
23+
name: tvapplock-debug
24+
path: app/build/outputs/apk/debug/app-debug.apk

.github/workflows/release.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags: ["v*"]
6+
7+
jobs:
8+
release:
9+
runs-on: ubuntu-latest
10+
permissions:
11+
contents: write
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: actions/setup-java@v4
15+
with:
16+
distribution: temurin
17+
java-version: 17
18+
- uses: gradle/actions/setup-gradle@v4
19+
20+
- name: Decode signing key
21+
run: |
22+
mkdir -p secrets
23+
echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > secrets/release.jks
24+
25+
- name: Assemble release APK
26+
env:
27+
RELEASE_KEYSTORE: ${{ github.workspace }}/secrets/release.jks
28+
RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
29+
RELEASE_KEY_ALIAS: tvlock
30+
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
31+
run: ./gradlew assembleRelease --no-daemon
32+
33+
- name: Checksums
34+
run: |
35+
cp app/build/outputs/apk/release/app-release.apk tvapplock-release.apk
36+
sha256sum tvapplock-release.apk > SHA256SUMS.txt
37+
38+
- name: GitHub Release
39+
uses: softprops/action-gh-release@v2
40+
with:
41+
files: |
42+
tvapplock-release.apk
43+
SHA256SUMS.txt
44+
generate_release_notes: true

.gitignore

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Build artifacts
2+
build/
3+
.gradle/
4+
*.apk
5+
*.aab
6+
local.properties
7+
8+
# Signing keys — NEVER commit
9+
.android/
10+
*.keystore
11+
*.jks
12+
secrets/
13+
14+
# IDE
15+
.idea/
16+
*.iml
17+
.DS_Store

Dockerfile

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
FROM eclipse-temurin:17-jdk
2+
3+
ENV ANDROID_HOME=/opt/android-sdk \
4+
GRADLE_HOME=/opt/gradle \
5+
PATH=/opt/gradle/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:$PATH
6+
7+
RUN apt-get update && apt-get install -y --no-install-recommends unzip curl ca-certificates git \
8+
&& rm -rf /var/lib/apt/lists/*
9+
10+
# Gradle 8.7
11+
RUN curl -fsSL -o /tmp/gradle.zip https://services.gradle.org/distributions/gradle-8.7-bin.zip \
12+
&& unzip -q /tmp/gradle.zip -d /opt && mv /opt/gradle-8.7 /opt/gradle && rm /tmp/gradle.zip
13+
14+
# Android commandline tools + SDK packages
15+
RUN mkdir -p $ANDROID_HOME/cmdline-tools \
16+
&& curl -fsSL -o /tmp/cmdtools.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \
17+
&& unzip -q /tmp/cmdtools.zip -d $ANDROID_HOME/cmdline-tools \
18+
&& mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest \
19+
&& rm /tmp/cmdtools.zip \
20+
&& yes | sdkmanager --licenses > /dev/null 2>&1 || true \
21+
&& sdkmanager "platforms;android-34" "build-tools;34.0.0" "platform-tools"
22+
23+
WORKDIR /project

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Oleksii Klots
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<div align="center">
2+
3+
# 🔒 TV App Lock
4+
5+
**Put a PIN on any app on your Android TV. No root. No cloud. No ads. No tracking.**
6+
7+
[![License: MIT](https://img.shields.io/badge/License-MIT-2563eb)](LICENSE)
8+
[![Release](https://img.shields.io/github/v/release/alexeyklots/tv-app-lock?label=release)](https://github.com/alexeyklots/tv-app-lock/releases/latest)
9+
[![Build](https://img.shields.io/github/actions/workflow/status/alexeyklots/tv-app-lock/ci.yml?label=build)](https://github.com/alexeyklots/tv-app-lock/actions)
10+
![Android](https://img.shields.io/badge/Android%20TV-6.0%2B%20(API%2021)-3DDC84?logo=android&logoColor=white)
11+
![Offline](https://img.shields.io/badge/works-offline-059669)
12+
![Size](https://img.shields.io/badge/APK-~0.8%20MB-7c3aed)
13+
14+
</div>
15+
16+
TV App Lock watches which app is on the foreground of your Android TV and puts
17+
a 4-digit PIN gate on top of the apps you choose — SmartTube, YouTube, Lampa,
18+
whatever you want to keep behind a password. Built for a real living-room
19+
remote: everything is operated with the D-pad.
20+
21+
## How it works
22+
23+
- A tiny foreground service polls the system usage stats every 250 ms.
24+
- When a **locked app enters the foreground**, the launcher is parked to Home
25+
and a PIN screen is raised over it.
26+
- Correct PIN → you land **inside the app**, and a configurable *grace period*
27+
(0–60 min or "until reboot") decides how long re-entries stay unlocked.
28+
- A watchdog re-arms the gate if Android silently refuses the overlay launch,
29+
and an alarm keep-alive resurrects the service if the firmware kills it.
30+
- Everything is local. No network permission at all.
31+
32+
### Why not an accessibility service?
33+
34+
Accessibility-based app lockers (most of the Play Store ones) intercept window
35+
events, but on Android TV they globally change how the remote's OK button is
36+
delivered — short taps start opening the long-press context menu in the
37+
launcher. TV App Lock deliberately uses polling instead of accessibility: zero
38+
interference with input, at the cost of a ~0.5 s window between an app's icon
39+
and the gate appearing.
40+
41+
## Features
42+
43+
| | |
44+
|---|---|
45+
| 🔢 PIN | 4 digits, typed with the remote, auto-saves |
46+
| 📱 App picker | pick any app visible on the TV home screen (LAUNCHER + LEANBACK_LAUNCHER) |
47+
| ⏱️ Grace period | 0 / 5 / 15 / 30 / 60 min / until reboot, cycled from the UI |
48+
| 🔓 State visible | the main screen always shows whether the lock is armed or open |
49+
| ♻️ Survives reboot | boot receiver + keep-alive; no root needed |
50+
| 🌍 UI | English & Ukrainian |
51+
52+
## Requirements
53+
54+
- Android TV 6.0+ (API 21), tested on Android 14
55+
- ADB available for the one-time install and permission grant
56+
57+
## Install
58+
59+
1. Download `tvapplock-release.apk` from the
60+
[latest release](https://github.com/alexeyklots/tv-app-lock/releases/latest)
61+
and verify it against `SHA256SUMS.txt`.
62+
2. Enable ADB debugging on the TV (Settings → Device preferences → USB
63+
debugging; on some builds you must first tap "Build version" 7 times in
64+
About).
65+
3. From any computer on the same network:
66+
67+
```bash
68+
adb connect <TV_IP>:5555
69+
adb install tvapplock-release.apk
70+
71+
# grant the two permissions the locker needs (one-time setup)
72+
adb shell appops set com.alexk.tvlock GET_USAGE_STATS allow
73+
adb shell appops set com.alexk.tvlock SYSTEM_ALERT_WINDOW allow
74+
75+
# start the guardian service
76+
adb shell am start-foreground-service com.alexk.tvlock/.LockPollService
77+
```
78+
79+
4. Open **TV App Lock** on the TV → *Set PIN* → pick the apps to lock.
80+
81+
## Known limitations (honest list)
82+
83+
- **~0.5 s race**: polling is not a hook. The locked app may show its first
84+
frame before the gate rises. Without root/system signature this is not
85+
solvable completely.
86+
- **Bypassable by anyone who can reach Settings**: disabling Usage Access or
87+
force-stopping the app lifts the lock. This is child resistance (4–10 y.o.),
88+
not security against a determined adult.
89+
- **Grace period is a tradeoff**: `0` (ask every time) is the safest; longer
90+
windows leave the door open for everyone, not just for you.
91+
- The PIN is stored locally, hashed only by SharedPreferences — fine for a
92+
family TV, never reuse a real password.
93+
94+
## Build from source
95+
96+
Requires JDK 17 and an Android SDK with platform 34 (or just Docker):
97+
98+
```bash
99+
./gradlew assembleRelease # with env vars RELEASE_KEYSTORE, RELEASE_STORE_PASSWORD, ...
100+
# or fully containerized:
101+
docker build -t tvlock-build .
102+
docker run --rm -v $PWD:/project -v $PWD/.android:/root/.android \
103+
-v $PWD/secrets:/secrets tvlock-build \
104+
./gradlew assembleRelease
105+
```
106+
107+
## Privacy
108+
109+
The app requests no internet permission. The usage-stats permission is used
110+
solely to determine the foreground app, locally and in real time. Nothing ever
111+
leaves the device.
112+
113+
## License
114+
115+
[MIT](LICENSE)

app/build.gradle.kts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
plugins {
2+
id("com.android.application")
3+
id("org.jetbrains.kotlin.android")
4+
}
5+
6+
// Release signing: env-driven so the keystore never lives in the repo.
7+
// Local/CI debug builds work without any of this.
8+
val releaseKeystore = System.getenv("RELEASE_KEYSTORE")
9+
10+
android {
11+
namespace = "com.alexk.tvlock"
12+
compileSdk = 34
13+
14+
defaultConfig {
15+
applicationId = "com.alexk.tvlock"
16+
minSdk = 21
17+
targetSdk = 34
18+
versionCode = 13
19+
versionName = "1.12"
20+
}
21+
22+
if (!releaseKeystore.isNullOrBlank()) {
23+
signingConfigs.create("release") {
24+
storeFile = file(releaseKeystore)
25+
storePassword = System.getenv("RELEASE_STORE_PASSWORD")
26+
keyAlias = System.getenv("RELEASE_KEY_ALIAS")
27+
keyPassword = System.getenv("RELEASE_KEY_PASSWORD")
28+
}
29+
}
30+
31+
buildTypes {
32+
debug {
33+
isMinifyEnabled = false
34+
}
35+
release {
36+
isMinifyEnabled = false
37+
if (!releaseKeystore.isNullOrBlank()) {
38+
signingConfig = signingConfigs.getByName("release")
39+
}
40+
}
41+
}
42+
43+
compileOptions {
44+
sourceCompatibility = JavaVersion.VERSION_17
45+
targetCompatibility = JavaVersion.VERSION_17
46+
}
47+
kotlinOptions {
48+
jvmTarget = "17"
49+
}
50+
}
51+
52+
dependencies {
53+
implementation("org.jetbrains.kotlin:kotlin-stdlib:1.9.22")
54+
}

app/src/main/AndroidManifest.xml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3+
xmlns:tools="http://schemas.android.com/tools">
4+
5+
<!-- BAL exemption on Android 14: granted once via appops; we never draw overlays -->
6+
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
7+
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS"
8+
tools:ignore="ProtectedPermissions" />
9+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
10+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
11+
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
12+
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
13+
tools:ignore="QueryAllPackagesPermission" />
14+
15+
<application
16+
android:label="TV App Lock"
17+
android:icon="@drawable/ic_launcher_tvlock"
18+
android:theme="@android:style/Theme.DeviceDefault.NoActionBar"
19+
android:allowBackup="false">
20+
21+
<activity
22+
android:name=".MainActivity"
23+
android:exported="true"
24+
android:banner="@android:drawable/ic_lock_idle_lock">
25+
<intent-filter>
26+
<action android:name="android.intent.action.MAIN" />
27+
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
28+
<category android:name="android.intent.category.LAUNCHER" />
29+
</intent-filter>
30+
</activity>
31+
32+
<activity
33+
android:name=".PinActivity"
34+
android:exported="false"
35+
android:excludeFromRecents="true"
36+
android:launchMode="singleInstance"
37+
android:theme="@android:style/Theme.DeviceDefault.NoActionBar" />
38+
39+
<activity
40+
android:name=".BlockedAppsActivity"
41+
android:exported="false"
42+
android:theme="@android:style/Theme.DeviceDefault.NoActionBar" />
43+
44+
<service
45+
android:name=".LockPollService"
46+
android:exported="true"
47+
android:foregroundServiceType="specialUse">
48+
<property
49+
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
50+
android:value="App lock guardian" />
51+
</service>
52+
53+
<receiver
54+
android:name=".BootReceiver"
55+
android:exported="true">
56+
<intent-filter>
57+
<action android:name="android.intent.action.BOOT_COMPLETED" />
58+
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
59+
</intent-filter>
60+
</receiver>
61+
</application>
62+
</manifest>

0 commit comments

Comments
 (0)