Debloat Mi TV 4A and add a tvOS-style launcher

Disable 25 Xiaomi/Google bloat packages (telemetry, PatchWall, SmartShare,
MiLink, update service, Alexa bridge, preloads) with a restore script and
pre-change package backups.

Add a native Android TV launcher (launcher/): black background, bottom dock
with four favourites in a rounded band, app grid below, a single spring-
animated glass highlight, ambient glow and large title for the focused dock
app, frosted Inputs / Wi-Fi / Settings pills, Outfit clock, and a compact
Inputs dropdown driving the TV input framework.

Add tools/tv.py, a pure-Python ADB client that works around macOS Local
Network privacy blocking the stock adb, plus a chunked base64 uploader.
Include the reusable prompt (PROMPT.md), before/after photos and licences.
This commit is contained in:
Gathin23 committed 2026-10-17 13:40:10 +05:30
1 parent 13070138af
commit d9451aaeb2
54 files changed
+2606

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
tools/venv/
tools/adbkey/
tools/__pycache__/
launcher/.gradle/
launcher/build/
launcher/app/build/
launcher/local.properties
# device identifiers (serial, android_id, device names) stay local
backup/getprop.txt
backup/settings.txt
.DS_Store
+27
View File
@@ -0,0 +1,27 @@
MIT License
Copyright (c) 2026 Gathin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
The Outfit typeface in launcher/app/src/main/res/font/outfit.ttf is copyright
The Outfit Project Authors and licensed under the SIL Open Font License 1.1,
see launcher/FONT-LICENSE-OFL.txt.
+65
View File
@@ -0,0 +1,65 @@
# The prompt
Paste this into Claude Code on a Mac or Linux machine that is on the same Wi-Fi as the TV.
Works on Android TV / Google TV sets (Xiaomi, TCL, Hisense, Sony, OnePlus, Realme and most others).
It will not work on Samsung Tizen, LG webOS or Fire TV without changes.
Before you start, on the TV: Settings → Device Preferences → About → press OK on "Build" 7 times,
then go back → Developer options → turn on USB debugging (and Network/ADB debugging if your TV
shows it). Keep the TV on.
---
I want to debloat my Android TV and replace its launcher with a clean Apple TV style home screen.
My TV model is: <MODEL NAME HERE>. Work on the real device, step by step, and verify everything you
do with screenshots pulled from the TV. Ask me before anything that depends on my preferences.
Connect
- Find the TV on my network (hostname, ARP table, or ask me for the IP) and connect over ADB on port 5555.
- If the stock adb binary fails with "No route to host" on macOS even though the port is open, that is
macOS Local Network privacy blocking adb. Don't debug it: use the Python `adb-shell` package in a venv
as the ADB client instead. If its file push hangs on larger files, transfer files as base64 chunks
through the shell and verify the md5.
- Tell me when the "Allow USB debugging?" prompt will appear on the TV so I can accept it.
Back up first
- Save the full package list, disabled-package list, getprop output and all settings to a backup folder
before changing anything, and write a restore script that re-enables everything you disable.
Debloat
- List installed packages and sort them into: manufacturer telemetry/analytics, manufacturer content
and ad services (PatchWall, recommendation rows, content providers), background services I probably
don't need (share/cast duplicates, cleaners, update services), preloaded apps, and streaming apps.
- Disable the first categories with `pm disable-user --user 0`, never uninstall system packages.
- Before touching streaming apps, voice assistant, Chromecast, Miracast or the update service, ask me
which ones I use with a checklist question and disable only what I don't tick.
- Run `pm` commands one per shell call, chained commands may silently stop after the first.
Launcher
- Don't install a third-party launcher that charges for basics like wallpapers. Build a small native
Android TV launcher instead (Java, AndroidX RecyclerView, minSdk matching the TV, debug build is fine),
install it over ADB, set it as the default home and disable the stock launcher. If several apps claim
the HOME intent, set the default with `cmd package set-home-activity`.
- Design, Apple TV 4K style: pitch black background; a dock row at the bottom of the screen inside a
slightly lighter rounded band holding the four apps I pick (ask me which, and the order); the rest of
the apps in a 4-column grid below that scrolls up when I press Down; one translucent "glass" highlight
that springs from tile to tile instead of per-tile borders; the focused tile scales up slightly and
shows its name underneath; above the dock, the focused dock app's name in large light type with an
ambient glow in a colour sampled from its banner; frosted pill buttons top right for Inputs, Wi-Fi
(showing the network name) and Settings; a greeting, a large thin clock and the date top left in a
downloaded open-licence typeface such as Outfit. Inputs opens a compact glass dropdown under the pill
listing the HDMI ports, AV and Live TV, switching via the TV input framework.
- Make D-pad navigation deterministic: Up/Down move exactly one row, Up from the dock goes to the top
bar, the ends of the top bar don't fall through to tiles. Remember the last focused tile.
- Keep it light: the TV has about 1 GB of RAM. No blur, no video, no network polling.
Verify
- After each change, inject remote key presses over ADB, take a screenshot from the TV and look at it.
Fix what you see before moving on.
- When done, give me a restore script, a build-and-install script, and a README that explains how to
re-enable any app and how to change the dock order or the wallpaper.
---
Expect to answer three or four checklist questions (which apps you use, which four go in the dock,
whether to keep the voice assistant, Chromecast and firmware updates). Everything is reversible.
+98
View File
@@ -0,0 +1,98 @@
# tv-vibehack
Debloating a Xiaomi Mi TV 4A (`MiTV-MOOQ1`, codename `tarzan`, Android TV 10) over ADB, and
replacing its launcher with a tiny custom one. Everything here was done in one afternoon with
Claude Code talking to the real TV. The prompt to reproduce it on your own Android TV is in
[`PROMPT.md`](PROMPT.md).
| Before | After |
|---|---|
| ![Stock Google TV home with a sponsored ad](docs/before.jpg) | ![Custom tvOS-style launcher](docs/after.jpg) |
## What was done (2026-10-17)
- Disabled 25 packages: Xiaomi analytics, PatchWall and its content rows, SmartShare, MiLink,
TV Manager, the update service, Alexa bridge, Mi Music, Play Games, Duo, demo apps and two
unused streaming apps. Full list with reasons in `debloat.txt`.
- Uninstalled TV Bro browser at the user's request.
- Replaced the Google TV launcher with a custom home screen (`launcher/`), see below.
- Halved system animation durations.
- Kept: Netflix, Prime Video, Hotstar, SonyLIV, Zee5, Aha, Roja, YouTube, Google Assistant,
Chromecast, Miracast, Mi media player.
Apart from TV Bro nothing was uninstalled; `./restore.sh` re-enables the disabled apps and the
stock launcher.
## The launcher (`launcher/`)
A tvOS-style Android TV home screen, package `com.gathin.tvhome`:
- Pitch-black background, like Apple TV 4K.
- Top left: greeting, a large light clock with AM/PM and the date, set in the Outfit typeface
(`res/font/outfit.ttf`, SIL Open Font License, from Google Fonts).
- Top right, frosted glass pills: **Inputs** (opens a glass dropdown under the pill listing
HDMI 1-3, AV and Live TV with icons; a port that reports a definite connected/disconnected
state shows it; selection switches via the TV input framework), **Wi-Fi** (shows the connected
network name, opens network settings) and **Settings**. Reading the network name needs location permission on Android 10; the install
script grants it over adb.
- A pinned dock row at the bottom of the screen inside a lighter rounded band: Netflix, Prime
Video, YouTube, JioHotstar (order in `FAVOURITES` in `AppAdapter.java`). The shelf above shows
the focused dock app's name in large type with an ambient glow in a colour sampled from its
banner. Pressing Down scrolls the dock up and reveals the remaining apps alphabetically, 4 per
row; Up brings the dock back down.
- One floating "liquid glass" highlight (`GlassView`) springs from tile to tile with a soft
overshoot instead of per-tile borders; the focused tile also scales up and, outside the dock,
shows its name underneath. Tiles animate in with a stagger.
- Up/Down move exactly one row; Up from the dock goes to the top bar. Click launches, long-press
opens the app's info page. The last focused tile is remembered when you come back.
Live TV and Mi Gallery are hidden (edit `HIDDEN` in `AppAdapter.java`).
Build and install (needs JDK 17, Gradle and Android command-line tools from Homebrew, plus the
Python venv under `tools/`):
```bash
launcher/build-and-install.sh
```
Projectivy Launcher was tried first and dropped because custom wallpapers are a paid feature.
## Talking to the TV
The stock `adb` binary is blocked by macOS "Local Network" privacy (it fails with
`No route to host` even though the port is open). `tools/tv.py` is a pure-Python ADB client
that works around this.
```bash
python3 -m venv tools/venv && tools/venv/bin/pip install -r tools/requirements.txt
tools/venv/bin/python -I tools/tv.py --probe # device info
tools/venv/bin/python -I tools/tv.py 'pm list packages -d' # one command
printf 'cmd1\ncmd2\n' | tools/venv/bin/python -I tools/tv.py --batch # many commands, one connection
tools/venv/bin/python -I tools/tv.py --pull /sdcard/screen.png screen.png
tools/venv/bin/python -I tools/b64push.py local.apk /data/local/tmp/x.apk # file upload
```
`--push` in tv.py only works for files under about 64 KB on this TV; `b64push.py` sends larger
files as base64 chunks over the shell and verifies the md5.
The TV IP defaults to `192.168.0.131`; override with `TV_HOST=...`. USB debugging must be on
(Settings → Device Preferences → About → tap Build 7 times → Developer options). On this model
there is no separate wireless-debugging toggle; USB debugging opens network ADB on port 5555.
The first connection shows an "Allow USB debugging?" prompt on the TV. The RSA key lives in
`tools/adbkey/` (git-ignored).
## Re-enable a single app
```bash
tools/venv/bin/python -I tools/tv.py 'pm enable com.xiaomi.mitv.updateservice'
```
## Backups
`backup/` holds the full package list captured before any change, plus the disabled lists before
and after. The raw `getprop` and `settings` dumps are kept locally only (git-ignored) because they
contain device identifiers.
## Licence
MIT, see [`LICENSE`](LICENSE). The Outfit typeface is under the SIL Open Font License, see
`launcher/FONT-LICENSE-OFL.txt`.
+130
View File
@@ -0,0 +1,130 @@
package:/system/app/TvService/TvService.apk=mitv.service
package:/system/priv-app/CtsShimPrivPrebuilt/CtsShimPrivPrebuilt.apk=com.android.cts.priv.ctsshim
package:/system/priv-app/MiTvSetupCustomizer/MiTvSetupCustomizer.apk=com.xiaomi.android.tvsetup.partnercustomizer
package:/system/priv-app/GoogleExtServices/GoogleExtServices.apk=com.google.android.ext.services
package:/data/app/com.google.android.apps.mediashell-vY-i31ppdqzVmRbUqV6WDA==/base.apk=com.google.android.apps.mediashell
package:/data/app/com.google.android.katniss-_AIYua_SQVThwIbDzfz72A==/base.apk=com.google.android.katniss
package:/system/priv-app/DynamicSystemInstallationService/DynamicSystemInstallationService.apk=com.android.dynsystem
package:/system/app/Factory/Factory.apk=com.duokan.factorytest
package:/system/priv-app/CalendarProvider/CalendarProvider.apk=com.android.providers.calendar
package:/system/priv-app/TvSettingsPlus/TvSettingsPlus.apk=com.android.tv.settings
package:/system/priv-app/MediaProvider/MediaProvider.apk=com.android.providers.media
package:/system/product/priv-app/GoogleOneTimeInitializer/GoogleOneTimeInitializer.apk=com.google.android.onetimeinitializer
package:/system/app/GoogleExtShared/GoogleExtShared.apk=com.google.android.ext.shared
package:/system/priv-app/TvLock/TvLock.apk=com.mitv.tvlock
package:/system/priv-app/ExternalStorageProvider/ExternalStorageProvider.apk=com.android.externalstorage
package:/system/app/HTMLViewer/HTMLViewer.apk=com.android.htmlviewer
package:/system/app/CompanionDeviceManager/CompanionDeviceManager.apk=com.android.companiondevicemanager
package:/system/priv-app/DownloadProvider/DownloadProvider.apk=com.android.providers.downloads
package:/data/app/com.phlox.tvwebbrowser-KtWubY34PvaQWnZV_ZDcpw==/base.apk=com.phlox.tvwebbrowser
package:/system/priv-app/TvProvider/TvProvider.apk=com.android.providers.tv
package:/product/overlay/Bluetooth__auto_generated_rro_product.apk=com.android.bluetooth.auto_generated_rro_product__
package:/system/priv-app/InProcessNetworkStack/InProcessNetworkStack.apk=com.android.networkstack.inprocess
package:/system/app/FloatingFrame/FloatingFrame.apk=com.xiaomi.floatingframe
package:/data/app/com.google.android.backdrop-siiFkpbGrskrcjK_FoUNZg==/base.apk=com.google.android.backdrop
package:/system/priv-app/MtkTvInput/MtkTvInput.apk=com.mediatek.tvinput
package:/data/app/com.aha.mitv-stMfbRJhGahz2Tam-XCt9w==/base.apk=com.aha.mitv
package:/system/priv-app/LiveTVSetupWizard/LiveTVSetupWizard.apk=com.mediatek.wwtv.setupwizard
package:/data/app/com.google.android.tungsten.setupwraith-YEEV62_MZfarWXuo86GpYw==/base.apk=com.google.android.tungsten.setupwraith
package:/data/app/com.xm.webcontent-UwVDN044EohtV70A9GgEHg==/base.apk=com.xm.webcontent
package:/data/app/com.android.vending-sjOtKvHjonIboRKwyznfHw==/base.apk=com.android.vending
package:/system/app/PacProcessor/PacProcessor.apk=com.android.pacprocessor
package:/system/product/app/ModuleMetadata/ModuleMetadata.apk=com.android.modulemetadata
package:/system/app/CertInstaller/CertInstaller.apk=com.android.certinstaller
package:/data/app/com.google.android.marvin.talkback-Ys4GLWmgmIML8VbcerQZAw==/base.apk=com.google.android.marvin.talkback
package:/data/app/com.xiaomi.amazon.alexa-jJLWfcUzVRazWcGRqB7U7A==/base.apk=com.xiaomi.amazon.alexa
package:/system/framework/framework-res.apk=android
package:/data/app/com.sonyliv-ioQn9EUNNfWBKpmXQ4jMjQ==/base.apk=com.sonyliv
package:/data/app/com.ipcloud.ashokadigital-OyphJ_7PgY1anEXDzUBUYg==/base.apk=com.ipcloud.ashokadigital
package:/data/app/com.mitv.tvhome.atv-yQRhufXr48tENEYSVvYVLQ==/base.apk=com.mitv.tvhome.atv
package:/system/priv-app/BackupRestoreConfirmation/BackupRestoreConfirmation.apk=com.android.backupconfirm
package:/data/app/android.autoinstalls.config.xiaomi.amelie-coYQp4eu4PLA5kOeoqNARg==/base.apk=android.autoinstalls.config.xiaomi.amelie
package:/system/priv-app/StatementService/StatementService.apk=com.android.statementservice
package:/data/app/in.startv.hotstar-9Z-s2-X3_Agw8blMfdFixw==/base.apk=in.startv.hotstar
package:/data/app/com.google.android.apps.tachyon-P5g1WMyA4yGW72pTKjfiIw==/base.apk=com.google.android.apps.tachyon
package:/system/product/priv-app/SettingsIntelligence/SettingsIntelligence.apk=com.android.settings.intelligence
package:/system/priv-app/TVMediaExplorer/TVMediaExplorer.apk=com.xiaomi.mitv.mediaexplorer
package:/system/priv-app/GooglePermissionController/GooglePermissionController.apk=com.google.android.permissioncontroller
package:/system/product/app/MediatekFactory/MediatekFactory.apk=mediatek.factorymenu.ui
package:/data/app/com.amazon.amazonvideo.livingroom-huCpYw3FPsnSaZ8Sr5JkvA==/base.apk=com.amazon.amazonvideo.livingroom
package:/system/priv-app/SettingsProvider/SettingsProvider.apk=com.android.providers.settings
package:/system/priv-app/SharedStorageBackup/SharedStorageBackup.apk=com.android.sharedstoragebackup
package:/system/app/PrintSpooler/PrintSpooler.apk=com.android.printspooler
package:/data/app/com.xiaomi.mitv.updateservice-x7HxCAMWHvLOQUGHt6eJ7A==/base.apk=com.xiaomi.mitv.updateservice
package:/data/app/com.mitv.videoplayer-wxPNbE9ggqMF1_kDuab5Uw==/base.apk=com.mitv.videoplayer
package:/system/app/BasicDreams/BasicDreams.apk=com.android.dreams.basic
package:/product/overlay/SettingsProvider__auto_generated_rro_product.apk=com.android.providers.settings.auto_generated_rro_product__
package:/system/app/SecureElement/SecureElement.apk=com.android.se
package:/system/product/app/VendorDisclaimer/VendorDisclaimer.apk=com.mstar.android.tv.disclaimercustomization
package:/system/priv-app/InputDevices/InputDevices.apk=com.android.inputdevices
package:/data/app/ahaflix.tv-1hbK9zpa2y7_FGgREOH7Aw==/base.apk=ahaflix.tv
package:/data/app/com.google.android.tvrecommendations-n4urKKNNGKq3WniSdlLa1w==/base.apk=com.google.android.tvrecommendations
package:/system/product/priv-app/RecommendationsService/RecommendationsService.apk=com.google.android.leanbacklauncher.recommendations
package:/system/product/priv-app/RetailDisplay/RetailDisplay.apk=fusion.android.tv.demo
package:/data/app/com.roja.rojadigital-qysOhjzYqFUvuUEay2DXCg==/base.apk=com.roja.rojadigital
package:/system/priv-app/MiTVMultiCast_india/MiTVMultiCast_india.apk=com.xiaomi.mitv.smartshare
package:/vendor/overlay/Bluetooth__auto_generated_rro_vendor.apk=com.android.bluetooth.auto_generated_rro_vendor__
package:/system/priv-app/TVMusic/TVMusic.apk=com.xiaomi.mimusic2
package:/vendor/overlay/SettingsProvider__auto_generated_rro_vendor.apk=com.android.providers.settings.auto_generated_rro_vendor__
package:/data/app/com.google.android.tvlauncher-1ZmGxfuK2CLV_1LAx5ApfA==/base.apk=com.google.android.tvlauncher
package:/data/app/com.google.android.webview-grDgJCGmxwmRm50g_aMSfg==/base.apk=com.google.android.webview
package:/system/product/app/GoogleContactsSyncAdapter/GoogleContactsSyncAdapter.apk=com.google.android.syncadapters.contacts
package:/system/app/KeyChain/KeyChain.apk=com.android.keychain
package:/data/app/com.google.android.youtube.tvmusic-am-xTEJh2MpIWozn-AwfFQ==/base.apk=com.google.android.youtube.tvmusic
package:/system/product/app/FactoryCommandService/FactoryCommandService.apk=com.mediatek.tv.factory
package:/system/app/BootAppSplashScreen/BootAppSplashScreen.apk=com.google.android.boot.appsplashscreen
package:/system/priv-app/GooglePackageInstaller/GooglePackageInstaller.apk=com.google.android.packageinstaller
package:/system/product/app/HotKeyDispatcher/HotKeyDispatcher.apk=com.mediatek.hotkey.dispatcher
package:/system/app/mitv-framework-res/mitv-framework-res.apk=com.xiaomi.mitv.res
package:/data/app/com.google.android.gms-I8a-_UJKgTHwlUkJaly4Bw==/base.apk=com.google.android.gms
package:/system/product/priv-app/GoogleServicesFramework/GoogleServicesFramework.apk=com.google.android.gsf
package:/data/app/com.google.android.tts-0AeyKVZuIW63-Q8yqRJGLg==/base.apk=com.google.android.tts
package:/system/product/priv-app/GooglePartnerSetup/GooglePartnerSetup.apk=com.google.android.partnersetup
package:/system/priv-app/LocalTransport/LocalTransport.apk=com.android.localtransport
package:/data/app/com.google.android.videos-AX4MCzyFu9SlAdyzapQKpQ==/base.apk=com.google.android.videos
package:/system/app/milegal/milegal.apk=com.xiaomo.tv.milegal
package:/data/app/com.xiaomitv.sonyliv-Ks9IrS-x_mfM0VanYTtZoA==/base.apk=com.xiaomitv.sonyliv
package:/data/app/com.graymatrix.did-rmkorUfvTGwqSUWTwCpBQQ==/base.apk=com.graymatrix.did
package:/system/priv-app/ProxyHandler/ProxyHandler.apk=com.android.proxyhandler
package:/system/product/app/VendorWebView/VendorWebView.apk=com.mediatek.wwtv.webview
package:/data/app/com.mitv.tvhome.mitvplus-FKBmF1mW9VdeDt9_Tm8MuQ==/base.apk=com.mitv.tvhome.mitvplus
package:/product/overlay/ContactsProvider__auto_generated_rro_product.apk=com.android.providers.contacts.auto_generated_rro_product__
package:/system/product/priv-app/GoogleFeedback/GoogleFeedback.apk=com.google.android.feedback
package:/system/product/app/GoogleCalendarSyncAdapter/GoogleCalendarSyncAdapter.apk=com.google.android.syncadapters.calendar
package:/system/product/app/MNetflixObserver/MNetflixObserver.apk=com.mstar.netflixobserver
package:/system/product/app/BugReportSender/BugReportSender.apk=com.google.android.tv.bugreportsender
package:/system/priv-app/LiveTV/LiveTV.apk=com.mediatek.wwtv.tvcenter
package:/system/priv-app/MiTVGallery/MiTVGallery.apk=com.mitv.gallery
package:/system/app/MiTVBiAnalytics/MiTVBiAnalytics.apk=com.miui.tv.analytics
package:/system/product/priv-app/LeanbackLauncher/LeanbackLauncher.apk=com.google.android.leanbacklauncher
package:/system/app/WfdSinkHelper/WfdSinkHelper.apk=com.xiaomi.wfdsinkhelperservice
package:/system/priv-app/PlatformNetworkPermissionConfig/PlatformNetworkPermissionConfig.apk=com.android.networkstack.permissionconfig
package:/system/app/MiTVStat/MiTVStat.apk=com.xiaomi.statistic
package:/system/priv-app/Arbitratorservice/Arbitratorservice.apk=com.mediatek.tvinputservice.arbitratorservice
package:/data/app/com.neotv.app-plN0l4kmkjv3CrBYd1_lUw==/base.apk=com.neotv.app
package:/system/product/priv-app/SssAuthbridgePrebuilt/SssAuthbridgePrebuilt.apk=com.google.android.sss.authbridge
package:/system/app/CtsShimPrebuilt/CtsShimPrebuilt.apk=com.android.cts.ctsshim
package:/system/app/MtkNetworkMonitorService/MtkNetworkMonitorService.apk=com.mediatek.network
package:/data/app/com.google.android.youtube.tv-rfAYM5PZMIrnzCq87r84Sw==/base.apk=com.google.android.youtube.tv
package:/system/priv-app/VpnDialogs/VpnDialogs.apk=com.android.vpndialogs
package:/system/product/app/FrameworkPackageStubs/FrameworkPackageStubs.apk=com.google.android.tv.frameworkpackagestubs
package:/system/priv-app/Shell/Shell.apk=com.android.shell
package:/system/app/WallpaperBackup/WallpaperBackup.apk=com.android.wallpaperbackup
package:/system/priv-app/MiLink/MiLink.apk=com.mitv.milinkservice
package:/system/priv-app/UserDictionaryProvider/UserDictionaryProvider.apk=com.android.providers.userdictionary
package:/data/app/com.google.android.tv.remote.service-fsadzAGh5A-fKHQp9UZFEA==/base.apk=com.google.android.tv.remote.service
package:/system/priv-app/FusedLocation/FusedLocation.apk=com.android.location.fused
package:/system/product/app/DeskClock/DeskClock.apk=com.android.deskclock
package:/system/priv-app/SystemUI/SystemUI.apk=com.android.systemui
package:/system/priv-app/MiTVDream/MiTVDream.apk=com.mitv.dream
package:/system/product/app/mtktvapi_agent/mtktvapi_agent.apk=com.mediatek.tv.agent
package:/data/app/com.mitv.tvhome.michannel-c5rl4mJVTVWgDtBqaRBP1Q==/base.apk=com.mitv.tvhome.michannel
package:/product/overlay/framework-res__auto_generated_rro_product.apk=android.auto_generated_rro_product__
package:/data/app/com.google.android.play.games-Ei4qRhPoFffDtiCa8HO7MQ==/base.apk=com.google.android.play.games
package:/data/app/com.netflix.ninja-nTJ3URmSaFLn0FHN5DlAYA==/base.apk=com.netflix.ninja
package:/system/app/Bluetooth/Bluetooth.apk=com.android.bluetooth
package:/system/priv-app/ContactsProvider/ContactsProvider.apk=com.android.providers.contacts
package:/system/app/PlatformCaptivePortalLogin/PlatformCaptivePortalLogin.apk=com.android.captiveportallogin
package:/data/app/com.google.android.inputmethod.latin-VYj3BDC_o7G01cku8qT2Sg==/base.apk=com.google.android.inputmethod.latin
package:/vendor/overlay/framework-res__auto_generated_rro_vendor.apk=android.auto_generated_rro_vendor__
package:/system/priv-app/TVManager/TVManager.apk=com.xiaomi.mitv.tvmanager
+25
View File
@@ -0,0 +1,25 @@
package:com.xiaomi.floatingframe
package:com.xm.webcontent
package:com.xiaomi.amazon.alexa
package:com.ipcloud.ashokadigital
package:com.mitv.tvhome.atv
package:android.autoinstalls.config.xiaomi.amelie
package:com.google.android.apps.tachyon
package:com.xiaomi.mitv.updateservice
package:com.google.android.tvrecommendations
package:com.google.android.leanbacklauncher.recommendations
package:fusion.android.tv.demo
package:com.xiaomi.mitv.smartshare
package:com.xiaomi.mimusic2
package:com.google.android.tvlauncher
package:com.google.android.youtube.tvmusic
package:com.google.android.videos
package:com.mitv.tvhome.mitvplus
package:com.google.android.feedback
package:com.miui.tv.analytics
package:com.xiaomi.statistic
package:com.neotv.app
package:com.mitv.milinkservice
package:com.mitv.tvhome.michannel
package:com.google.android.play.games
package:com.xiaomi.mitv.tvmanager
+2
View File
@@ -0,0 +1,2 @@
package:com.google.android.youtube.tvmusic
package:com.google.android.videos
+128
View File
@@ -0,0 +1,128 @@
package:mitv.service
package:com.android.cts.priv.ctsshim
package:com.xiaomi.android.tvsetup.partnercustomizer
package:com.google.android.ext.services
package:com.google.android.apps.mediashell
package:com.google.android.katniss
package:com.android.dynsystem
package:com.duokan.factorytest
package:com.android.providers.calendar
package:com.android.tv.settings
package:com.android.providers.media
package:com.google.android.onetimeinitializer
package:com.google.android.ext.shared
package:com.mitv.tvlock
package:com.android.externalstorage
package:com.android.htmlviewer
package:com.android.companiondevicemanager
package:com.android.providers.downloads
package:com.phlox.tvwebbrowser
package:com.android.providers.tv
package:com.android.bluetooth.auto_generated_rro_product__
package:com.android.networkstack.inprocess
package:com.xiaomi.floatingframe
package:com.google.android.backdrop
package:com.mediatek.tvinput
package:com.aha.mitv
package:com.mediatek.wwtv.setupwizard
package:com.google.android.tungsten.setupwraith
package:com.xm.webcontent
package:com.android.vending
package:com.android.pacprocessor
package:com.android.modulemetadata
package:com.android.certinstaller
package:com.google.android.marvin.talkback
package:com.xiaomi.amazon.alexa
package:android
package:com.sonyliv
package:com.ipcloud.ashokadigital
package:com.mitv.tvhome.atv
package:com.android.backupconfirm
package:android.autoinstalls.config.xiaomi.amelie
package:com.android.statementservice
package:in.startv.hotstar
package:com.google.android.apps.tachyon
package:com.android.settings.intelligence
package:com.xiaomi.mitv.mediaexplorer
package:com.google.android.permissioncontroller
package:mediatek.factorymenu.ui
package:com.amazon.amazonvideo.livingroom
package:com.android.providers.settings
package:com.android.sharedstoragebackup
package:com.android.printspooler
package:com.xiaomi.mitv.updateservice
package:com.mitv.videoplayer
package:com.android.dreams.basic
package:com.android.providers.settings.auto_generated_rro_product__
package:com.android.se
package:com.mstar.android.tv.disclaimercustomization
package:com.android.inputdevices
package:ahaflix.tv
package:com.google.android.tvrecommendations
package:com.google.android.leanbacklauncher.recommendations
package:fusion.android.tv.demo
package:com.roja.rojadigital
package:com.xiaomi.mitv.smartshare
package:com.android.bluetooth.auto_generated_rro_vendor__
package:com.xiaomi.mimusic2
package:com.android.providers.settings.auto_generated_rro_vendor__
package:com.google.android.tvlauncher
package:com.google.android.webview
package:com.google.android.syncadapters.contacts
package:com.android.keychain
package:com.mediatek.tv.factory
package:com.google.android.boot.appsplashscreen
package:com.google.android.packageinstaller
package:com.mediatek.hotkey.dispatcher
package:com.xiaomi.mitv.res
package:com.google.android.gms
package:com.google.android.gsf
package:com.google.android.tts
package:com.google.android.partnersetup
package:com.android.localtransport
package:com.xiaomo.tv.milegal
package:com.xiaomitv.sonyliv
package:com.graymatrix.did
package:com.android.proxyhandler
package:com.mediatek.wwtv.webview
package:com.mitv.tvhome.mitvplus
package:com.android.providers.contacts.auto_generated_rro_product__
package:com.google.android.feedback
package:com.google.android.syncadapters.calendar
package:com.mstar.netflixobserver
package:com.google.android.tv.bugreportsender
package:com.mediatek.wwtv.tvcenter
package:com.mitv.gallery
package:com.miui.tv.analytics
package:com.google.android.leanbacklauncher
package:com.xiaomi.wfdsinkhelperservice
package:com.android.networkstack.permissionconfig
package:com.xiaomi.statistic
package:com.mediatek.tvinputservice.arbitratorservice
package:com.neotv.app
package:com.google.android.sss.authbridge
package:com.android.cts.ctsshim
package:com.mediatek.network
package:com.google.android.youtube.tv
package:com.android.vpndialogs
package:com.google.android.tv.frameworkpackagestubs
package:com.android.shell
package:com.android.wallpaperbackup
package:com.mitv.milinkservice
package:com.android.providers.userdictionary
package:com.google.android.tv.remote.service
package:com.android.location.fused
package:com.android.deskclock
package:com.android.systemui
package:com.mitv.dream
package:com.mediatek.tv.agent
package:com.mitv.tvhome.michannel
package:android.auto_generated_rro_product__
package:com.google.android.play.games
package:com.netflix.ninja
package:com.android.bluetooth
package:com.android.providers.contacts
package:com.android.captiveportallogin
package:com.google.android.inputmethod.latin
package:android.auto_generated_rro_vendor__
package:com.xiaomi.mitv.tvmanager
+13
View File
@@ -0,0 +1,13 @@
package:com.phlox.tvwebbrowser
package:com.aha.mitv
package:com.xiaomi.amazon.alexa
package:com.sonyliv
package:com.ipcloud.ashokadigital
package:in.startv.hotstar
package:com.google.android.apps.tachyon
package:ahaflix.tv
package:com.roja.rojadigital
package:com.xiaomitv.sonyliv
package:com.graymatrix.did
package:com.mitv.tvhome.mitvplus
package:com.neotv.app
+36
View File
@@ -0,0 +1,36 @@
# Packages disabled on the Mi TV (MiTV-MOOQ1, Android TV 10) on 2026-10-17.
# Applied with: tools/tv.py --batch < debloat.txt (after prefixing each line with "pm disable-user --user 0 ")
# Everything here is DISABLED, not uninstalled. Reverse any line with: pm enable <package>
# Xiaomi telemetry
com.miui.tv.analytics
com.xiaomi.statistic
# PatchWall + Mi content rows
com.mitv.tvhome.atv
com.mitv.tvhome.michannel
com.mitv.tvhome.mitvplus
com.xm.webcontent
com.xiaomi.floatingframe
# Xiaomi background services
com.xiaomi.mitv.tvmanager
com.xiaomi.mitv.smartshare
com.mitv.milinkservice
com.xiaomi.mitv.updateservice # re-enable to check for firmware updates
com.xiaomi.amazon.alexa
android.autoinstalls.config.xiaomi.amelie
# Preloaded apps not used
com.xiaomi.mimusic2
com.google.android.play.games
com.google.android.feedback
com.google.android.apps.tachyon # Google Duo
fusion.android.tv.demo
com.ipcloud.ashokadigital
com.neotv.app
# Google TV launcher, replaced by the custom launcher in launcher/ (com.gathin.tvhome)
com.google.android.tvlauncher
com.google.android.tvrecommendations
com.google.android.leanbacklauncher.recommendations
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

+93
View File
@@ -0,0 +1,93 @@
Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+32
View File
@@ -0,0 +1,32 @@
plugins {
id 'com.android.application'
}
android {
namespace 'com.gathin.tvhome'
compileSdk 34
defaultConfig {
applicationId 'com.gathin.tvhome'
minSdk 26
targetSdk 29
versionCode 2
versionName '2.0'
}
buildTypes {
release {
minifyEnabled false
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
dependencies {
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.dynamicanimation:dynamicanimation:1.0.0'
}
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Needed on Android 10 to read the Wi-Fi network name; granted over adb at install time. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:banner="@drawable/banner"
android:theme="@style/Theme.TvHome"
android:allowBackup="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:stateNotNeeded="true"
android:excludeFromRecents="true"
android:clearTaskOnLaunch="true"
android:configChanges="orientation|screenSize|keyboard|keyboardHidden|uiMode"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,282 @@
package com.gathin.tvhome;
import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Outline;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.provider.Settings;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import android.view.animation.OvershootInterpolator;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* App tiles. The first {@link MainActivity#COLUMNS} positions are the pinned dock (favourites, fixed order),
* the rest is alphabetical.
*/
public class AppAdapter extends RecyclerView.Adapter<AppAdapter.Holder> {
interface TileListener {
void onTileFocused(int position, View card);
/** Called for D-pad Up/Down on a tile. Return true when handled. */
boolean onTileVerticalKey(int position, int keyCode);
}
static final class Entry {
final String label;
final ComponentName component;
final String pkg;
final Drawable banner;
final Drawable icon;
int ambient;
Entry(String label, ComponentName component, Drawable banner, Drawable icon) {
this.label = label;
this.component = component;
this.pkg = component.getPackageName();
this.banner = banner;
this.icon = icon;
}
}
/** Dock order, tvOS style top row. */
static final List<String> FAVOURITES = Arrays.asList(
"com.netflix.ninja",
"com.amazon.amazonvideo.livingroom",
"com.google.android.youtube.tv",
"in.startv.hotstar"
);
/** Apps that have their own button on the home screen or are not wanted as tiles. */
private static final Set<String> HIDDEN = new HashSet<>(Arrays.asList(
"com.mediatek.wwtv.tvcenter", // Live TV: reachable via the Inputs button
"com.mitv.gallery" // Mi Gallery
));
static final float FOCUS_SCALE = 1.10f;
private final Context ctx;
private final TileListener listener;
private final int dockSize;
private final List<Entry> items = new ArrayList<>();
AppAdapter(Context ctx, TileListener listener, int dockSize) {
this.ctx = ctx;
this.listener = listener;
this.dockSize = dockSize;
}
void reload() {
PackageManager pm = ctx.getPackageManager();
Intent probe = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER);
List<ResolveInfo> found = pm.queryIntentActivities(probe, 0);
List<Entry> fresh = new ArrayList<>();
for (ResolveInfo ri : found) {
if (ri.activityInfo == null) continue;
String pkg = ri.activityInfo.packageName;
if (pkg.equals(ctx.getPackageName()) || HIDDEN.contains(pkg)) continue;
Drawable banner = ri.activityInfo.loadBanner(pm);
if (banner == null) banner = ri.activityInfo.applicationInfo.loadBanner(pm);
Drawable icon = ri.loadIcon(pm);
String label = String.valueOf(ri.loadLabel(pm));
fresh.add(new Entry(label, new ComponentName(pkg, ri.activityInfo.name), banner, icon));
}
Collections.sort(fresh, (a, b) -> {
int fa = FAVOURITES.indexOf(a.pkg), fb = FAVOURITES.indexOf(b.pkg);
if (fa >= 0 || fb >= 0) {
if (fa < 0) return 1;
if (fb < 0) return -1;
return Integer.compare(fa, fb);
}
return a.label.compareToIgnoreCase(b.label);
});
items.clear();
items.addAll(fresh);
notifyDataSetChanged();
}
@NonNull
@Override
public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_app, parent, false);
return new Holder(v, listener);
}
@Override
public void onBindViewHolder(@NonNull Holder h, int position) {
Entry e = items.get(position);
h.name.setText(e.label);
h.name.setVisibility(position < dockSize ? View.INVISIBLE : View.VISIBLE); // the dock has no labels
h.name.setAlpha(0f);
h.card.setScaleX(1f);
h.card.setScaleY(1f);
if (e.banner != null) {
h.banner.setImageDrawable(e.banner);
h.banner.setVisibility(View.VISIBLE);
h.icon.setVisibility(View.GONE);
h.label.setVisibility(View.GONE);
} else {
h.banner.setImageDrawable(null);
h.banner.setVisibility(View.GONE);
h.icon.setImageDrawable(e.icon);
h.icon.setVisibility(View.VISIBLE);
h.label.setText(e.label);
h.label.setVisibility(View.VISIBLE);
}
h.card.setOnClickListener(v -> launch(e));
h.card.setOnLongClickListener(v -> {
Intent i = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + e.pkg));
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try {
ctx.startActivity(i);
} catch (ActivityNotFoundException ignored) {
}
return true;
});
}
private void launch(Entry e) {
Intent i = new Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER)
.setComponent(e.component)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
try {
ctx.startActivity(i);
} catch (Exception ex) {
Toast.makeText(ctx, "Can't open " + e.label, Toast.LENGTH_SHORT).show();
}
}
@Override
public int getItemCount() {
return items.size();
}
String labelAt(int position) {
return position >= 0 && position < items.size() ? items.get(position).label : "";
}
/** A vivid colour sampled from the app's banner (or icon), cached per entry. */
int ambientColorAt(int position) {
if (position < 0 || position >= items.size()) return 0xFF444444;
Entry e = items.get(position);
if (e.ambient == 0) e.ambient = vividColor(e.banner != null ? e.banner : e.icon);
return e.ambient;
}
private static int vividColor(Drawable live) {
if (live == null) return 0xFF444444;
// Draw a private copy: changing the bounds of the drawable an ImageView shows would blank the tile.
Drawable d = live.getConstantState() != null ? live.getConstantState().newDrawable().mutate() : live;
Rect saved = new Rect(d.getBounds());
int n = 12;
Bitmap bmp = Bitmap.createBitmap(n, n, Bitmap.Config.ARGB_8888);
Canvas c = new Canvas(bmp);
d.setBounds(0, 0, n, n);
d.draw(c);
d.setBounds(saved);
float[] hsv = new float[3];
int best = 0xFF444444;
float bestScore = -1f;
long r = 0, g = 0, b = 0;
for (int y = 0; y < n; y++) {
for (int x = 0; x < n; x++) {
int p = bmp.getPixel(x, y);
r += Color.red(p);
g += Color.green(p);
b += Color.blue(p);
Color.colorToHSV(p, hsv);
float score = hsv[1] * Math.min(1f, hsv[2] * 1.4f); // saturated and not too dark
if (score > bestScore) {
bestScore = score;
best = p;
}
}
}
bmp.recycle();
if (bestScore < 0.25f) { // nearly monochrome banner (e.g. white): use its average, lifted
int avg = Color.rgb((int) (r / (n * n)), (int) (g / (n * n)), (int) (b / (n * n)));
Color.colorToHSV(avg, hsv);
hsv[2] = Math.max(hsv[2], 0.55f);
return Color.HSVToColor(hsv);
}
Color.colorToHSV(best, hsv);
hsv[1] = Math.min(1f, hsv[1] * 1.1f);
hsv[2] = Math.max(0.5f, hsv[2]);
return Color.HSVToColor(hsv);
}
static final class Holder extends RecyclerView.ViewHolder {
final View card;
final ImageView banner;
final ImageView icon;
final TextView label;
final TextView name;
Holder(View v, TileListener listener) {
super(v);
card = v.findViewById(R.id.card);
banner = v.findViewById(R.id.banner);
icon = v.findViewById(R.id.icon);
label = v.findViewById(R.id.label);
name = v.findViewById(R.id.name);
final float density = v.getResources().getDisplayMetrics().density;
final float radius = density * 16f;
card.setOutlineProvider(new ViewOutlineProvider() {
@Override
public void getOutline(View view, Outline outline) {
outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), radius);
}
});
card.setClipToOutline(true);
card.setOnKeyListener((view, keyCode, event) -> {
if (event.getAction() != KeyEvent.ACTION_DOWN) return false;
if (keyCode != KeyEvent.KEYCODE_DPAD_UP && keyCode != KeyEvent.KEYCODE_DPAD_DOWN) return false;
int pos = getBindingAdapterPosition();
return listener != null && pos != RecyclerView.NO_POSITION && listener.onTileVerticalKey(pos, keyCode);
});
card.setOnFocusChangeListener((view, focused) -> {
float s = focused ? FOCUS_SCALE : 1.0f;
view.animate().scaleX(s).scaleY(s)
.setDuration(focused ? 260 : 180)
.setInterpolator(new OvershootInterpolator(focused ? 1.6f : 0f))
.start();
name.animate().alpha(focused ? 1f : 0f)
.translationY(focused ? density * 4f : -density * 6f)
.setDuration(220).start();
int pos = getBindingAdapterPosition();
if (focused && listener != null && pos != RecyclerView.NO_POSITION) {
listener.onTileFocused(pos, view);
}
});
}
}
}
@@ -0,0 +1,67 @@
package com.gathin.tvhome;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
/** Draws the tvOS style lighter band behind the first (dock) row and adds a gap below it. */
public class DockDecoration extends RecyclerView.ItemDecoration {
private final int dockSize;
private final float density;
private final Paint band = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint edge = new Paint(Paint.ANTI_ALIAS_FLAG);
private final RectF rect = new RectF();
DockDecoration(int dockSize, float density) {
this.dockSize = dockSize;
this.density = density;
band.setColor(0xFF141416);
edge.setStyle(Paint.Style.STROKE);
edge.setStrokeWidth(density);
edge.setColor(0x1FFFFFFF);
}
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent,
@NonNull RecyclerView.State state) {
int pos = parent.getChildAdapterPosition(view);
if (pos >= 0 && pos < dockSize) {
outRect.top = (int) (6 * density);
outRect.bottom = (int) (12 * density);
}
}
@Override
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
boolean any = false;
for (int i = 0; i < parent.getChildCount(); i++) {
View child = parent.getChildAt(i);
int pos = parent.getChildAdapterPosition(child);
if (pos < 0 || pos >= dockSize) continue;
View card = child.findViewById(R.id.card);
if (card == null) continue;
float l = child.getLeft() + card.getLeft();
float t = child.getTop() + card.getTop() + child.getTranslationY();
float r = l + card.getWidth();
float b = t + card.getHeight();
if (!any) {
rect.set(l, t, r, b);
any = true;
} else {
rect.union(l, t, r, b);
}
}
if (!any) return;
float pad = 14 * density;
rect.inset(-pad, -pad);
float radius = 26 * density;
c.drawRoundRect(rect, radius, radius, band);
c.drawRoundRect(rect, radius, radius, edge);
}
}
@@ -0,0 +1,74 @@
package com.gathin.tvhome;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.graphics.Shader;
import android.util.AttributeSet;
import android.view.View;
/**
* A translucent glass slab: soft sheen fill, bright top specular, crisp rim and a faint outer glow.
* One instance floats over the grid and is moved with spring animations between tiles.
*/
public class GlassView extends View {
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint specular = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint rim = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG);
private final RectF rect = new RectF();
private final Path clip = new Path();
private final float density;
private float radius;
public GlassView(Context context, AttributeSet attrs) {
super(context, attrs);
density = getResources().getDisplayMetrics().density;
radius = 20f * density;
rim.setStyle(Paint.Style.STROKE);
rim.setStrokeWidth(1.5f * density);
rim.setColor(0xCCFFFFFF);
glow.setStyle(Paint.Style.STROKE);
}
void setRadius(float px) {
radius = px;
invalidate();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
fill.setShader(new LinearGradient(0, 0, 0, h, 0x3DFFFFFF, 0x12FFFFFF, Shader.TileMode.CLAMP));
specular.setShader(new LinearGradient(0, 0, 0, h * 0.45f, 0x66FFFFFF, 0x00FFFFFF, Shader.TileMode.CLAMP));
}
@Override
protected void onDraw(Canvas canvas) {
float inset = 6f * density; // room for the glow
rect.set(inset, inset, getWidth() - inset, getHeight() - inset);
// Outer glow: three widening, fading strokes.
for (int i = 1; i <= 3; i++) {
glow.setStrokeWidth(i * 2f * density);
glow.setColor((0x30 / i) << 24 | 0xFFFFFF);
float o = i * density;
canvas.drawRoundRect(rect.left - o, rect.top - o, rect.right + o, rect.bottom + o, radius + o, radius + o, glow);
}
canvas.drawRoundRect(rect, radius, radius, fill);
clip.reset();
clip.addRoundRect(rect, radius, radius, Path.Direction.CW);
canvas.save();
canvas.clipPath(clip);
canvas.drawRect(rect.left, rect.top, rect.right, rect.top + rect.height() * 0.45f, specular);
canvas.restore();
canvas.drawRoundRect(rect, radius, radius, rim);
}
}
@@ -0,0 +1,481 @@
package com.gathin.tvhome;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.GradientDrawable;
import android.media.tv.TvContract;
import android.media.tv.TvInputInfo;
import android.media.tv.TvInputManager;
import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.net.wifi.WifiInfo;
import android.net.wifi.WifiManager;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.provider.Settings;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.AnimationUtils;
import android.view.animation.LayoutAnimationController;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.PopupWindow;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.dynamicanimation.animation.DynamicAnimation;
import androidx.dynamicanimation.animation.SpringAnimation;
import androidx.dynamicanimation.animation.SpringForce;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.List;
/**
* tvOS style home: black, a pinned dock row, an app grid, and one glass highlight that springs between tiles.
*/
public class MainActivity extends Activity implements AppAdapter.TileListener {
static final int COLUMNS = 4;
private static final String LIVE_TV_PKG = "com.mediatek.wwtv.tvcenter";
private static final long MINUTE_MS = 60_000L;
private AppAdapter adapter;
private RecyclerView grid;
private FrameLayout root;
private GlassView glass;
private View ambient;
private TextView heroName;
private GradientDrawable ambientGlow;
private int ambientColor;
private boolean heroShown;
private TextView greeting;
private TextView btnWifi;
private SpringAnimation springX, springY;
private float glassTargetX, glassTargetY;
private boolean glassShown;
private int lastFocused = 0;
private float density;
private final Handler handler = new Handler(Looper.getMainLooper());
private final Runnable minuteTick = new Runnable() {
@Override
public void run() {
updateGreeting();
updateWifiLabel();
handler.postDelayed(this, MINUTE_MS);
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
density = getResources().getDisplayMetrics().density;
root = findViewById(R.id.root);
glass = findViewById(R.id.glass);
ambient = findViewById(R.id.ambient);
heroName = findViewById(R.id.heroName);
ambientGlow = new GradientDrawable();
ambientGlow.setGradientType(GradientDrawable.RADIAL_GRADIENT);
ambientGlow.setGradientCenter(0.32f, 0.42f);
ambient.setBackground(ambientGlow);
ambient.addOnLayoutChangeListener((v, l, t, r, b, ol, ot, or, ob) ->
ambientGlow.setGradientRadius((r - l) * 0.62f));
greeting = findViewById(R.id.greeting);
btnWifi = findViewById(R.id.btnWifi);
grid = findViewById(R.id.grid);
grid.setLayoutManager(new GridLayoutManager(this, COLUMNS));
grid.setHasFixedSize(true);
grid.setItemAnimator(null);
grid.addItemDecoration(new DockDecoration(COLUMNS, density));
grid.setLayoutAnimation(new LayoutAnimationController(
AnimationUtils.loadAnimation(this, R.anim.tile_enter), 0.06f));
adapter = new AppAdapter(this, this, COLUMNS);
grid.setAdapter(adapter);
// tvOS layout: push the dock row to the bottom of the screen; the grid hides below until you press Down.
grid.addOnLayoutChangeListener((v, l, t, r, b, ol, ot, or, ob) -> {
int h = b - t;
int dockRow = (int) ((145 + 6 + 12) * density); // item height plus DockDecoration offsets
int want = Math.max(0, h - dockRow - (int) (30 * density));
if (grid.getPaddingTop() != want) {
grid.setPadding(grid.getPaddingLeft(), want, grid.getPaddingRight(), grid.getPaddingBottom());
}
});
// Keep the glass glued to its tile while the grid scrolls.
grid.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) {
if (!glassShown) return;
glassTargetY -= dy;
if (springY.isRunning()) springY.animateToFinalPosition(glassTargetY);
else glass.setTranslationY(glassTargetY);
}
});
// Liquid feel: soft springs on the highlight's position.
springX = new SpringAnimation(glass, DynamicAnimation.TRANSLATION_X);
springY = new SpringAnimation(glass, DynamicAnimation.TRANSLATION_Y);
for (SpringAnimation s : new SpringAnimation[]{springX, springY}) {
s.setSpring(new SpringForce().setStiffness(260f).setDampingRatio(0.58f));
}
View btnInputs = findViewById(R.id.btnInputs);
View btnSettings = findViewById(R.id.btnSettings);
btnInputs.setOnClickListener(v -> showInputs());
btnWifi.setOnClickListener(v -> open(new Intent(Settings.ACTION_WIFI_SETTINGS)));
btnSettings.setOnClickListener(v -> open(new Intent(Settings.ACTION_SETTINGS)));
// Keep focus on the top bar at its ends and at the top: Android's focus finder would otherwise
// jump to a tile far below when there is nothing in that direction.
View.OnKeyListener pillKeys = (v, keyCode, event) -> {
if (event.getAction() != KeyEvent.ACTION_DOWN) return false;
if (keyCode == KeyEvent.KEYCODE_DPAD_UP) return true;
if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT && v.getId() == R.id.btnInputs) return true;
return keyCode == KeyEvent.KEYCODE_DPAD_RIGHT && v.getId() == R.id.btnSettings;
};
btnInputs.setOnKeyListener(pillKeys);
btnWifi.setOnKeyListener(pillKeys);
btnSettings.setOnKeyListener(pillKeys);
// Hide the glass when focus leaves the tiles.
getWindow().getDecorView().getViewTreeObserver().addOnGlobalFocusChangeListener((oldF, newF) -> {
boolean onTile = newF != null && newF.getId() == R.id.card;
if (!onTile) hideGlass();
});
}
@Override
protected void onResume() {
super.onResume();
glassShown = false;
glass.setAlpha(0f);
heroShown = false;
heroName.setAlpha(0f);
ambient.setAlpha(0f);
adapter.reload();
grid.scheduleLayoutAnimation();
updateGreeting();
updateWifiLabel();
handler.postDelayed(minuteTick, MINUTE_MS);
// Return to the tile we left from, or the first one.
grid.post(() -> {
View focused = getCurrentFocus();
if (focused != null && grid.findContainingViewHolder(focused) != null) return;
focusTile(lastFocused);
});
}
@Override
protected void onPause() {
super.onPause();
handler.removeCallbacks(minuteTick);
}
@Override
public void onBackPressed() {
// Home screen: Back goes nowhere.
}
// ---- glass highlight -------------------------------------------------------------------------
@Override
public void onTileFocused(int position, View card) {
lastFocused = position;
// Tile rect in root coordinates, using layout positions (ignores the scale animation).
Rect r = new Rect(0, 0, card.getWidth(), card.getHeight());
root.offsetDescendantRectToMyCoords(card, r);
float pad = 6f * density; // matches GlassView's glow inset
float w = card.getWidth() * AppAdapter.FOCUS_SCALE + 2 * pad;
float h = card.getHeight() * AppAdapter.FOCUS_SCALE + 2 * pad;
float x = r.exactCenterX() - w / 2f;
float y = r.exactCenterY() - h / 2f;
ViewGroup.LayoutParams lp = glass.getLayoutParams();
if (lp.width != (int) w || lp.height != (int) h) {
lp.width = (int) w;
lp.height = (int) h;
glass.setLayoutParams(lp);
glass.setRadius(16f * density * AppAdapter.FOCUS_SCALE + pad);
}
glassTargetX = x;
glassTargetY = y;
if (!glassShown) {
springX.cancel();
springY.cancel();
glass.setTranslationX(x);
glass.setTranslationY(y);
glass.animate().alpha(1f).setDuration(180).start();
glassShown = true;
} else {
springX.animateToFinalPosition(x);
springY.animateToFinalPosition(y);
}
View item = (View) card.getParent();
if (position < COLUMNS) {
// Dock: park it at the bottom of the screen and show the top shelf for this app.
int decoratedTop = item.getTop() - (int) (6 * density);
int dy = decoratedTop - grid.getPaddingTop();
if (Math.abs(dy) > 1) grid.smoothScrollBy(0, dy);
showHero(position, r.top - dy);
} else {
hideHero();
// Scroll just enough so the whole glass (not only the tile) is visible.
Rect want = new Rect(0, -(int) pad * 2, item.getWidth(), item.getHeight() + (int) pad);
grid.requestChildRectangleOnScreen(item, want, false);
}
}
// ---- top shelf -------------------------------------------------------------------------------
private void showHero(int position, float dockTopInRoot) {
String label = adapter.labelAt(position);
int color = adapter.ambientColorAt(position);
float y = dockTopInRoot - 14 * density - 26 * density - heroName.getHeight();
boolean changed = !label.contentEquals(heroName.getText());
if (!heroShown) {
heroName.setText(label);
heroName.setY(y);
heroName.setTranslationX(0f);
heroName.animate().alpha(1f).setDuration(260).start();
setAmbient(color);
ambient.animate().alpha(1f).setDuration(600).start();
heroShown = true;
} else if (changed) {
heroName.animate().alpha(0f).translationX(-12 * density).setDuration(120).withEndAction(() -> {
heroName.setText(label);
heroName.setY(y);
heroName.setTranslationX(12 * density);
heroName.animate().alpha(1f).translationX(0f).setDuration(200).start();
}).start();
if (color != ambientColor) {
ambient.animate().alpha(0.35f).setDuration(200).withEndAction(() -> {
setAmbient(color);
ambient.animate().alpha(1f).setDuration(500).start();
}).start();
}
} else {
heroName.setY(y);
}
}
private void setAmbient(int color) {
ambientColor = color;
int c = color & 0x00FFFFFF;
ambientGlow.setColors(new int[]{0x5C000000 | c, 0x26000000 | c, 0x00000000 | c});
}
private void hideHero() {
if (!heroShown) return;
heroShown = false;
heroName.animate().alpha(0f).setDuration(160).start();
ambient.animate().alpha(0f).setDuration(300).start();
}
private void hideGlass() {
if (!glassShown) return;
glassShown = false;
glass.animate().alpha(0f).setDuration(160).start();
}
// ---- navigation ------------------------------------------------------------------------------
@Override
public boolean onTileVerticalKey(int position, int keyCode) {
int count = adapter.getItemCount();
int target;
if (keyCode == KeyEvent.KEYCODE_DPAD_UP) {
if (position < COLUMNS) return false; // dock row: let focus move to the top bar
target = position - COLUMNS;
} else {
int lastRowStart = ((count - 1) / COLUMNS) * COLUMNS;
if (position >= lastRowStart) return true; // already on the last row: stay put
target = Math.min(position + COLUMNS, count - 1);
}
focusTile(target);
return true;
}
private void focusTile(int position) {
if (adapter.getItemCount() == 0) return;
final int pos = Math.max(0, Math.min(position, adapter.getItemCount() - 1));
RecyclerView.ViewHolder vh = grid.findViewHolderForAdapterPosition(pos);
if (vh != null) {
vh.itemView.findViewById(R.id.card).requestFocus();
return;
}
// Not laid out yet: scroll there, then focus once the scroll settles.
grid.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView rv, int newState) {
if (newState != RecyclerView.SCROLL_STATE_IDLE) return;
rv.removeOnScrollListener(this);
RecyclerView.ViewHolder h = rv.findViewHolderForAdapterPosition(pos);
View target = h != null ? h.itemView.findViewById(R.id.card) : rv.getChildAt(0);
if (target != null) target.requestFocus();
}
});
grid.smoothScrollToPosition(pos);
}
// ---- top bar ---------------------------------------------------------------------------------
private void updateGreeting() {
int hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY);
int res;
if (hour < 5) res = R.string.good_night;
else if (hour < 12) res = R.string.good_morning;
else if (hour < 17) res = R.string.good_afternoon;
else if (hour < 22) res = R.string.good_evening;
else res = R.string.good_night;
greeting.setText(res);
}
/** Shows the Wi-Fi network name on the button when we can read it, otherwise the connection type. */
private void updateWifiLabel() {
String text = getString(R.string.wifi);
try {
ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
Network net = cm != null ? cm.getActiveNetwork() : null;
NetworkCapabilities caps = net != null ? cm.getNetworkCapabilities(net) : null;
if (caps == null) {
text = getString(R.string.offline);
} else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
text = getString(R.string.ethernet);
} else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
WifiManager wm = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE);
WifiInfo info = wm != null ? wm.getConnectionInfo() : null;
String ssid = info != null ? info.getSSID() : null;
if (ssid != null) {
ssid = ssid.replace("\"", "").trim();
if (!ssid.isEmpty() && !ssid.contains("unknown") && !ssid.equals("0x")) text = ssid;
}
}
} catch (Exception ignored) {
}
btnWifi.setText(text);
}
private void open(Intent i) {
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try {
startActivity(i);
} catch (ActivityNotFoundException e) {
Toast.makeText(this, "Not available on this TV", Toast.LENGTH_SHORT).show();
}
}
/** Dropdown menu under the Inputs pill: HDMI ports (with connection state), AV and Live TV. */
private void showInputs() {
View anchor = findViewById(R.id.btnInputs);
View panel = getLayoutInflater().inflate(R.layout.popup_inputs, root, false);
LinearLayout rows = panel.findViewById(R.id.rows);
// Compact: as wide as the Inputs pill (a touch wider so "HDMI 1" fits), hanging right below it.
int width = Math.max(anchor.getWidth(), (int) (150 * density));
final PopupWindow popup = new PopupWindow(panel, width, ViewGroup.LayoutParams.WRAP_CONTENT, true);
popup.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); // needed for Back to dismiss
popup.setOutsideTouchable(true);
popup.setAnimationStyle(0);
popup.setElevation(0f);
TvInputManager tim = (TvInputManager) getSystemService(TV_INPUT_SERVICE);
boolean sawNonHdmi = false;
if (tim != null) {
List<TvInputInfo> inputs = new ArrayList<>(tim.getTvInputList());
// HDMI ports first in port order, then AV and the rest.
Collections.sort(inputs, (a, b) -> {
boolean ha = a.getType() == TvInputInfo.TYPE_HDMI, hb = b.getType() == TvInputInfo.TYPE_HDMI;
if (ha != hb) return ha ? -1 : 1;
return String.valueOf(a.loadLabel(this)).compareToIgnoreCase(String.valueOf(b.loadLabel(this)));
});
for (TvInputInfo info : inputs) {
if (!info.isPassthroughInput()) continue;
CharSequence l = info.loadLabel(this);
String label = l == null ? info.getId() : l.toString();
boolean hdmi = info.getType() == TvInputInfo.TYPE_HDMI;
if (hdmi && !label.toUpperCase().contains("HDMI")) label = "HDMI " + label;
final String id = info.getId();
int state = tim.getInputState(id);
if (!hdmi && rows.getChildCount() > 0 && !sawNonHdmi) addSeparator(rows);
if (!hdmi) sawNonHdmi = true;
addInputRow(rows, popup, hdmi ? R.drawable.ic_input : R.drawable.ic_av, label, state,
() -> open(new Intent(Intent.ACTION_VIEW).setDataAndType(
TvContract.buildChannelUriForPassthroughInput(id), "vnd.android.cursor.item/channel")));
}
}
Intent liveTv = getPackageManager().getLeanbackLaunchIntentForPackage(LIVE_TV_PKG);
if (liveTv != null) {
if (rows.getChildCount() > 0) addSeparator(rows);
addInputRow(rows, popup, R.drawable.ic_livetv, getString(R.string.live_tv), -1, () -> open(liveTv));
}
if (rows.getChildCount() == 0) {
Toast.makeText(this, R.string.no_inputs, Toast.LENGTH_SHORT).show();
return;
}
// Drop in: fade and unfold from the top edge.
panel.setAlpha(0f);
panel.setPivotY(0f);
panel.setScaleY(0.92f);
panel.setTranslationY(-8 * density);
popup.showAsDropDown(anchor, 0, (int) (10 * density), Gravity.START);
panel.animate().alpha(1f).scaleY(1f).translationY(0f).setDuration(220)
.setInterpolator(new android.view.animation.DecelerateInterpolator(1.6f)).start();
rows.getChildAt(0).requestFocus();
}
private void addSeparator(LinearLayout rows) {
View line = new View(this);
line.setBackgroundColor(0x1FFFFFFF);
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, (int) density);
int m = (int) (6 * density), h = (int) (12 * density);
lp.setMargins(h, m, h, m);
rows.addView(line, lp);
}
private void addInputRow(LinearLayout rows, PopupWindow popup, int iconRes, String title, int state, Runnable action) {
View row = getLayoutInflater().inflate(R.layout.item_input, rows, false);
((ImageView) row.findViewById(R.id.icon)).setImageResource(iconRes);
((TextView) row.findViewById(R.id.title)).setText(title);
TextView status = row.findViewById(R.id.status);
View dot = row.findViewById(R.id.dot);
// This TV reports "standby" for every port whether or not something is plugged in, so only
// definite states are shown.
if (state == TvInputManager.INPUT_STATE_CONNECTED) {
status.setText(R.string.connected);
dot.setVisibility(View.VISIBLE);
} else if (state == TvInputManager.INPUT_STATE_DISCONNECTED) {
status.setText(R.string.not_connected);
dot.setVisibility(View.GONE);
} else {
status.setVisibility(View.GONE);
dot.setVisibility(View.GONE);
}
row.setOnClickListener(v -> {
popup.dismiss();
action.run();
});
rows.addView(row);
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:interpolator/decelerate_cubic"
android:duration="420">
<alpha android:fromAlpha="0.0" android:toAlpha="1.0" />
<translate android:fromYDelta="22%" android:toYDelta="0%" />
<scale
android:fromXScale="0.94" android:toXScale="1.0"
android:fromYScale="0.94" android:toYScale="1.0"
android:pivotX="50%" android:pivotY="50%" />
</set>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<gradient android:angle="270" android:startColor="#F22A2A2E" android:endColor="#F5161618" />
<stroke android:width="1dp" android:color="#33FFFFFF" />
<corners android:radius="20dp" />
</shape>
</item>
</layer-list>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp" android:height="20dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF"
android:pathData="M5,2c0,-0.55 -0.45,-1 -1,-1S3,1.45 3,2v4H1v6c0,1.3 0.84,2.4 2,2.82V23h2v-8.18C6.16,14.4 7,13.3 7,12V6H5V2zM13,2c0,-0.55 -0.45,-1 -1,-1s-1,0.45 -1,1v4H9v6c0,1.3 0.84,2.4 2,2.82V23h2v-8.18c1.16,-0.41 2,-1.51 2,-2.82V6h-2V2zM21,2c0,-0.55 -0.45,-1 -1,-1s-1,0.45 -1,1v4h-2v6c0,1.3 0.84,2.4 2,2.82V23h2v-8.18c1.16,-0.41 2,-1.51 2,-2.82V6h-2V2z" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp" android:height="22dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF"
android:pathData="M18,7V4c0,-1.1 -0.9,-2 -2,-2H8C6.9,2 6,2.9 6,4v3H5v6l3,6v3h8v-3l3,-6V7H18zM8,4h8v3h-2V5h-1v2h-2V5h-1v2H8V4z" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp" android:height="20dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF"
android:pathData="M21,6h-7.59l3.29,-3.29L16,2l-4,4l-4,-4l-0.71,0.71L10.59,6H3c-1.1,0 -2,0.89 -2,2v12c0,1.1 0.9,2 2,2h18c1.1,0 2,-0.89 2,-2V8C23,6.89 22.1,6 21,6zM21,20H3V8h18V20zM9,10v8l7,-4L9,10z" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp" android:height="22dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF"
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp" android:height="22dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF"
android:pathData="M1,9l2,2c4.97,-4.97 13.03,-4.97 18,0l2,-2C16.93,2.93 7.08,2.93 1,9zM9,17l3,3 3,-3c-1.65,-1.66 -4.34,-1.66 -6,0zM5,13l2,2c2.76,-2.76 7.24,-2.76 10,0l2,-2C15.14,9.14 8.87,9.14 5,13z" />
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="#F5FFFFFF" />
<corners android:radius="13dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#00000000" />
<corners android:radius="13dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="#F5FFFFFF" />
<corners android:radius="21dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<gradient android:angle="270" android:startColor="#2EFFFFFF" android:endColor="#14FFFFFF" />
<stroke android:width="1dp" android:color="#38FFFFFF" />
<corners android:radius="21dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true" android:color="#FF111111" />
<item android:color="#FFFFFFFF" />
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true" android:color="#99111111" />
<item android:color="#73FFFFFF" />
</selector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="#FF34C759" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FF1C1C1E" />
<corners android:radius="16dp" />
</shape>
Binary file not shown.
@@ -0,0 +1,182 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FF000000">
<!-- Ambient glow in the colour of the focused dock app. -->
<View
android:id="@+id/ambient"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:alpha="0" />
<!-- Top shelf: the focused dock app's name, positioned above the dock in code. -->
<TextView
android:id="@+id/heroName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="68dp"
android:alpha="0"
android:textColor="#FFFFFFFF"
android:textSize="48sp"
android:fontFamily="@font/outfit"
android:fontVariationSettings="'wght' 300"
android:letterSpacing="-0.02"
android:includeFontPadding="false"
android:singleLine="true" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="56dp"
android:paddingEnd="56dp"
android:paddingTop="26dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:id="@+id/leftBlock"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:orientation="vertical">
<TextView
android:id="@+id/greeting"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:textColor="#8CFFFFFF"
android:textSize="12sp"
android:fontFamily="@font/outfit"
android:fontVariationSettings="'wght' 500"
android:letterSpacing="0.22"
android:textAllCaps="true" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:baselineAligned="true">
<TextClock
android:id="@+id/clock"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:format12Hour="h:mm"
android:format24Hour="HH:mm"
android:textColor="#FFFFFFFF"
android:textSize="52sp"
android:fontFamily="@font/outfit"
android:fontVariationSettings="'wght' 300"
android:letterSpacing="-0.04"
android:includeFontPadding="false" />
<TextClock
android:id="@+id/ampm"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_gravity="bottom"
android:layout_marginBottom="8dp"
android:format12Hour="a"
android:format24Hour=""
android:textColor="#B3FFFFFF"
android:textSize="15sp"
android:fontFamily="@font/outfit"
android:fontVariationSettings="'wght' 500"
android:letterSpacing="0.08"
android:textAllCaps="true" />
<View
android:layout_width="1dp"
android:layout_height="26dp"
android:layout_gravity="bottom"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="8dp"
android:background="#33FFFFFF" />
<TextClock
android:id="@+id/date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_marginBottom="9dp"
android:format12Hour="EEEE d MMMM"
android:format24Hour="EEEE d MMMM"
android:textColor="#A6FFFFFF"
android:textSize="14sp"
android:fontFamily="@font/outfit"
android:fontVariationSettings="'wght' 400"
android:letterSpacing="0.14"
android:textAllCaps="true" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/topButtons"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignBottom="@id/leftBlock"
android:layout_marginBottom="6dp"
android:orientation="horizontal">
<TextView
android:id="@+id/btnInputs"
style="@style/TopButton"
android:text="@string/inputs"
android:drawableStart="@drawable/ic_input"
android:nextFocusDown="@+id/grid" />
<TextView
android:id="@+id/btnWifi"
style="@style/TopButton"
android:text="@string/wifi"
android:drawableStart="@drawable/ic_wifi"
android:nextFocusDown="@+id/grid" />
<TextView
android:id="@+id/btnSettings"
style="@style/TopButton"
android:text="@string/settings"
android:drawableStart="@drawable/ic_settings"
android:nextFocusDown="@+id/grid" />
</LinearLayout>
</RelativeLayout>
<!-- The wrapper clips at the grid's edge; the grid itself must not clip so scaled tiles can overflow their cells. -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="10dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/grid"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="10dp"
android:paddingBottom="40dp"
android:clipToPadding="false"
android:clipChildren="false"
android:overScrollMode="never"
android:scrollbars="none"
android:descendantFocusability="afterDescendants" />
</FrameLayout>
</LinearLayout>
<!-- The single floating glass highlight that springs between tiles. -->
<com.gathin.tvhome.GlassView
android:id="@+id/glass"
android:layout_width="10dp"
android:layout_height="10dp"
android:alpha="0"
android:importantForAccessibility="no" />
</FrameLayout>
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:clipChildren="false"
android:clipToPadding="false">
<FrameLayout
android:id="@+id/card"
android:layout_width="180dp"
android:layout_height="101dp"
android:layout_gravity="center_horizontal|top"
android:background="@drawable/tile_bg"
android:focusable="true"
android:clickable="true">
<ImageView
android:id="@+id/banner"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:contentDescription="@null" />
<ImageView
android:id="@+id/icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:layout_marginBottom="14dp"
android:visibility="gone"
android:contentDescription="@null" />
<TextView
android:id="@+id/label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:paddingStart="10dp"
android:paddingEnd="10dp"
android:paddingBottom="8dp"
android:gravity="center"
android:singleLine="true"
android:ellipsize="end"
android:textColor="#FFFFFFFF"
android:textSize="13sp"
android:fontFamily="@font/outfit"
android:visibility="gone" />
</FrameLayout>
<TextView
android:id="@+id/name"
android:layout_width="180dp"
android:layout_height="22dp"
android:layout_gravity="center_horizontal|top"
android:layout_marginTop="108dp"
android:gravity="center"
android:singleLine="true"
android:ellipsize="end"
android:alpha="0"
android:translationY="-6dp"
android:textColor="#F2FFFFFF"
android:textSize="14sp"
android:fontFamily="@font/outfit"
android:fontVariationSettings="'wght' 500"
android:letterSpacing="0.02" />
</FrameLayout>
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="42dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:background="@drawable/input_row_bg"
android:focusable="true"
android:clickable="true">
<ImageView
android:id="@+id/icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:tint="@drawable/pill_text"
android:duplicateParentState="true"
android:contentDescription="@null" />
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="10dp"
android:layout_marginEnd="4dp"
android:textColor="@drawable/pill_text"
android:duplicateParentState="true"
android:textSize="15sp"
android:fontFamily="@font/outfit"
android:fontVariationSettings="'wght' 500"
android:singleLine="true" />
<View
android:id="@+id/dot"
android:layout_width="7dp"
android:layout_height="7dp"
android:layout_marginEnd="8dp"
android:background="@drawable/status_dot" />
<TextView
android:id="@+id/status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@drawable/pill_text_dim"
android:duplicateParentState="true"
android:textSize="11sp"
android:fontFamily="@font/outfit"
android:letterSpacing="0.04" />
</LinearLayout>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/panel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/glass_panel"
android:padding="6dp"
android:elevation="12dp">
<LinearLayout
android:id="@+id/rows"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</LinearLayout>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Home</string>
<string name="inputs">Inputs</string>
<string name="wifi">Wi-Fi</string>
<string name="settings">Settings</string>
<string name="choose_input">Inputs</string>
<string name="connected">Connected</string>
<string name="standby">Standby</string>
<string name="not_connected">No device</string>
<string name="live_tv">Live TV</string>
<string name="no_inputs">No inputs found</string>
<string name="good_morning">Good morning</string>
<string name="good_afternoon">Good afternoon</string>
<string name="good_evening">Good evening</string>
<string name="good_night">Good night</string>
<string name="ethernet">Ethernet</string>
<string name="offline">Offline</string>
</resources>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.TvHome" parent="android:Theme.Material.NoActionBar.Fullscreen">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:colorBackground">@android:color/black</item>
<item name="android:windowAnimationStyle">@null</item>
</style>
<!-- Frosted pill, tvOS style. -->
<style name="TopButton">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">42dp</item>
<item name="android:layout_marginStart">10dp</item>
<item name="android:paddingStart">16dp</item>
<item name="android:paddingEnd">20dp</item>
<item name="android:gravity">center</item>
<item name="android:background">@drawable/pill_bg</item>
<item name="android:textColor">@drawable/pill_text</item>
<item name="android:textSize">15sp</item>
<item name="android:fontFamily">@font/outfit</item>
<item name="android:fontVariationSettings">\'wght\' 500</item>
<item name="android:letterSpacing">0.01</item>
<item name="android:drawablePadding">9dp</item>
<item name="android:drawableTint">@drawable/pill_text</item>
<item name="android:focusable">true</item>
<item name="android:clickable">true</item>
</style>
</resources>
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Build the launcher APK and install it on the TV as the default home screen.
set -euo pipefail
cd "$(dirname "$0")"
export JAVA_HOME=/opt/homebrew/opt/openjdk@17
export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools
export PATH="$JAVA_HOME/bin:$PATH"
[ -f local.properties ] || echo "sdk.dir=$ANDROID_HOME" > local.properties
./gradlew assembleDebug -q --console=plain
PY=../tools/venv/bin/python
APK=app/build/outputs/apk/debug/app-debug.apk
"$PY" -I ../tools/b64push.py "$APK" /data/local/tmp/tvhome.apk
printf '%s\n' \
"pm install -r /data/local/tmp/tvhome.apk" \
"rm -f /data/local/tmp/tvhome.apk" \
"pm grant com.gathin.tvhome android.permission.ACCESS_FINE_LOCATION 2>/dev/null; echo location-granted" \
"cmd package set-home-activity --user 0 com.gathin.tvhome" \
"pm disable-user --user 0 com.google.android.tvlauncher" \
"input keyevent KEYCODE_HOME" \
| "$PY" -I ../tools/tv.py --batch
+3
View File
@@ -0,0 +1,3 @@
plugins {
id 'com.android.application' version '8.5.2' apply false
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+112
View File
@@ -0,0 +1,112 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
@rem Catch executions from older scripts and ensure they exit cleanly.
@rem This can be removed once we can be reasonably confident that few people
@rem will be migrating directly to this new wrapper.
goto afterSafetyNet
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto exitWithErrorLevel
:afterSafetyNet
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+16
View File
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "tvhome"
include ':app'
Executable
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Undo the debloat: re-enable every package listed in debloat.txt and restore the Google TV launcher.
# Usage: ./restore.sh (needs tools/venv, see README.md)
set -euo pipefail
cd "$(dirname "$0")"
PY=tools/venv/bin/python
{
grep -vE '^\s*(#|$)' debloat.txt | sed -E 's/\s+#.*$//' | awk '{print "pm enable " $1}'
echo "cmd package set-home-activity --user 0 com.google.android.tvlauncher"
echo "settings put global window_animation_scale 1.0"
echo "settings put global transition_animation_scale 1.0"
echo "settings put global animator_duration_scale 1.0"
echo "pm list packages -d"
} | "$PY" -I tools/tv.py --batch
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Fallback file transfer: send a file to the TV as base64 chunks through plain shell commands.
Usage: b64push.py <local file> <remote path>
"""
import base64
import hashlib
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import tv # noqa: E402
CHUNK = 48 * 1024 # raw bytes per shell command (base64 grows it by 4/3)
def main():
local, remote = sys.argv[1], sys.argv[2]
data = open(local, "rb").read()
want = hashlib.md5(data).hexdigest()
dev = tv.connect()
tmp = remote + ".b64"
dev.shell(f"rm -f {remote} {tmp}")
n = (len(data) + CHUNK - 1) // CHUNK
for i in range(n):
part = base64.b64encode(data[i * CHUNK:(i + 1) * CHUNK]).decode()
out = dev.shell(f"echo '{part}' >> {tmp} && echo ok{i}", read_timeout_s=60.0)
if f"ok{i}" not in out:
print(f"chunk {i} failed: {out}")
sys.exit(1)
print(f"\rchunk {i + 1}/{n}", end="", flush=True)
print()
out = dev.shell(f"base64 -d {tmp} > {remote} && rm {tmp} && md5sum {remote}", read_timeout_s=120.0)
got = out.split()[0] if out.strip() else ""
print("md5 remote:", got, "local:", want, "MATCH" if got == want else "MISMATCH")
dev.close()
sys.exit(0 if got == want else 1)
if __name__ == "__main__":
main()
+3
View File
@@ -0,0 +1,3 @@
adb-shell
rsa
cryptography
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Minimal ADB-over-TCP shell runner for the Mi TV using adb_shell (no adb binary needed).
Usage: tv.py <shell command...>
tv.py --probe (connect + print model/android version)
"""
import os
import sys
from adb_shell.adb_device import AdbDeviceTcp
from adb_shell.auth.keygen import keygen
from adb_shell.auth.sign_pythonrsa import PythonRSASigner
HOST = os.environ.get("TV_HOST", "192.168.0.131")
PORT = 5555
KEYDIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "adbkey")
KEY = os.path.join(KEYDIR, "adbkey")
def get_signer():
os.makedirs(KEYDIR, exist_ok=True)
if not os.path.exists(KEY):
keygen(KEY)
with open(KEY) as f:
priv = f.read()
with open(KEY + ".pub") as f:
pub = f.read()
return PythonRSASigner(pub, priv)
def connect(timeout=120):
dev = AdbDeviceTcp(HOST, PORT, default_transport_timeout_s=float(timeout))
dev.connect(rsa_keys=[get_signer()], transport_timeout_s=float(timeout), auth_timeout_s=timeout)
return dev
def main():
args = sys.argv[1:]
dev = connect()
if not args or args == ["--probe"]:
for p in ("ro.product.model", "ro.product.device", "ro.build.version.release",
"ro.build.version.sdk", "ro.build.display.id", "ro.product.manufacturer"):
print(f"{p} = {dev.shell(f'getprop {p}').strip()}")
elif args[0] == "--push" and len(args) == 3:
dev.push(args[1], args[2], transport_timeout_s=300.0, read_timeout_s=300.0)
print(f"pushed {args[1]} -> {args[2]}")
elif args[0] == "--pull" and len(args) == 3:
dev.pull(args[1], args[2])
print(f"pulled {args[1]} -> {args[2]}")
elif args[0] == "--batch":
# one shell command per stdin line, each run separately on the same connection
for line in sys.stdin:
line = line.strip()
if not line or line.startswith("#"):
continue
out = dev.shell(line, read_timeout_s=120.0)
sys.stdout.write(f"$ {line}\n{out}")
if out and not out.endswith("\n"):
sys.stdout.write("\n")
sys.stdout.flush()
else:
out = dev.shell(" ".join(args), read_timeout_s=120.0)
sys.stdout.write(out)
dev.close()
if __name__ == "__main__":
main()