安卓tv打包
This commit is contained in:
@@ -3,3 +3,6 @@
|
||||
public/screenshot1.png
|
||||
public/screenshot2.png
|
||||
public/screenshot3.png
|
||||
|
||||
# Android/TV native app sources are not needed in Docker image builds
|
||||
apps/
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
name: Build Android TV APK
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
base_url:
|
||||
description: '服务端 Base URL,不需要带 /tv,例如 https://example.com 或 http://192.168.1.10:3000'
|
||||
required: true
|
||||
default: 'http://192.168.1.10:3000'
|
||||
type: string
|
||||
app_name:
|
||||
description: 'Android TV 应用名称'
|
||||
required: true
|
||||
default: 'MoonTVPlus TV'
|
||||
type: string
|
||||
version_name:
|
||||
description: '版本名'
|
||||
required: false
|
||||
default: '1.0.0'
|
||||
type: string
|
||||
version_code:
|
||||
description: '版本号,必须是整数'
|
||||
required: false
|
||||
default: '1'
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Android TV APK
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
sdkmanager "platforms;android-35" "build-tools;35.0.0"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
gradle-version: '8.10.2'
|
||||
|
||||
- name: Prepare Android TV icon from public/logo.png
|
||||
run: |
|
||||
cp public/logo.png apps/android-tv/app/src/main/res/drawable/logo.png
|
||||
|
||||
- name: Decode Android signing keystore
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
|
||||
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/android-tv-release.keystore
|
||||
else
|
||||
echo "ANDROID_KEYSTORE_BASE64 is not set, release APK will be unsigned."
|
||||
fi
|
||||
|
||||
- name: Build APK
|
||||
working-directory: apps/android-tv
|
||||
env:
|
||||
BASE_URL: ${{ inputs.base_url }}
|
||||
APP_NAME: ${{ inputs.app_name }}
|
||||
VERSION_NAME: ${{ inputs.version_name }}
|
||||
VERSION_CODE: ${{ inputs.version_code }}
|
||||
ANDROID_KEYSTORE_PATH: /tmp/android-tv-release.keystore
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
gradle assembleDebug assembleRelease \
|
||||
-PBASE_URL="$BASE_URL" \
|
||||
-PAPP_NAME="$APP_NAME" \
|
||||
-PVERSION_NAME="$VERSION_NAME" \
|
||||
-PVERSION_CODE="$VERSION_CODE"
|
||||
|
||||
- name: Collect APK artifacts
|
||||
run: |
|
||||
mkdir -p /tmp/android-tv-apks
|
||||
cp apps/android-tv/app/build/outputs/apk/debug/app-debug.apk "/tmp/android-tv-apks/moontvplus-tv-${{ inputs.version_name }}-debug.apk"
|
||||
if [ -f apps/android-tv/app/build/outputs/apk/release/app-release.apk ]; then
|
||||
cp apps/android-tv/app/build/outputs/apk/release/app-release.apk "/tmp/android-tv-apks/moontvplus-tv-${{ inputs.version_name }}-release-signed.apk"
|
||||
fi
|
||||
if [ -f apps/android-tv/app/build/outputs/apk/release/app-release-unsigned.apk ]; then
|
||||
cp apps/android-tv/app/build/outputs/apk/release/app-release-unsigned.apk "/tmp/android-tv-apks/moontvplus-tv-${{ inputs.version_name }}-release-unsigned.apk"
|
||||
fi
|
||||
ls -lh /tmp/android-tv-apks
|
||||
|
||||
- name: Upload APK artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: moontvplus-android-tv-${{ inputs.version_name }}
|
||||
path: /tmp/android-tv-apks/*.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,25 @@
|
||||
# MoonTVPlus Android TV
|
||||
|
||||
这是一个极简 Android TV WebView 壳工程,用于打开 MoonTVPlus Web TV 页面。
|
||||
|
||||
## 构建参数
|
||||
|
||||
- `BASE_URL`: 服务端 Base URL,不需要带 `/tv`,例如 `https://example.com` 或 `http://192.168.1.10:3000`
|
||||
- `APP_NAME`: Android TV 桌面显示名称
|
||||
- `VERSION_NAME`: APK 版本名
|
||||
- `VERSION_CODE`: APK 版本号,整数
|
||||
|
||||
App 启动时会自动打开:
|
||||
|
||||
```text
|
||||
BASE_URL 去掉末尾 / 后 + /tv
|
||||
```
|
||||
|
||||
## 特性
|
||||
|
||||
- 锁定横屏
|
||||
- 支持 Android TV Launcher
|
||||
- 允许 HTTP 明文访问
|
||||
- 允许 HTTPS 页面加载 HTTP 视频/图片等混合内容
|
||||
- 使用 `public/logo.png` 作为图标来源
|
||||
- User-Agent 追加 `MoonTVPlusAndroidTV`
|
||||
@@ -0,0 +1,70 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
}
|
||||
|
||||
fun propOrEnv(propName: String, envName: String, defaultValue: String): String {
|
||||
return (project.findProperty(propName) as String?)
|
||||
?: System.getenv(envName)
|
||||
?: defaultValue
|
||||
}
|
||||
|
||||
val rawBaseUrl = propOrEnv("BASE_URL", "BASE_URL", "http://192.168.1.10:3000")
|
||||
val appDisplayName = propOrEnv("APP_NAME", "APP_NAME", "MoonTVPlus TV")
|
||||
val versionNameValue = propOrEnv("VERSION_NAME", "VERSION_NAME", "1.0.0")
|
||||
val versionCodeValue = propOrEnv("VERSION_CODE", "VERSION_CODE", "1").toIntOrNull() ?: 1
|
||||
|
||||
fun escapeJavaString(value: String): String = value
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
|
||||
fun escapeXmlString(value: String): String = value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("'", "\\'")
|
||||
.replace("\"", "\\\"")
|
||||
|
||||
android {
|
||||
namespace = "com.moontvplus.tv"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.moontvplus.tv"
|
||||
minSdk = 23
|
||||
targetSdk = 35
|
||||
versionCode = versionCodeValue
|
||||
versionName = versionNameValue
|
||||
|
||||
buildConfigField("String", "BASE_URL", "\"${escapeJavaString(rawBaseUrl)}\"")
|
||||
resValue("string", "app_name", escapeXmlString(appDisplayName))
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
val storeFilePath = System.getenv("ANDROID_KEYSTORE_PATH")
|
||||
if (!storeFilePath.isNullOrBlank() && file(storeFilePath).exists()) {
|
||||
storeFile = file(storeFilePath)
|
||||
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
||||
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
isDebuggable = true
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
val storeFilePath = System.getenv("ANDROID_KEYSTORE_PATH")
|
||||
if (!storeFilePath.isNullOrBlank() && file(storeFilePath).exists()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:banner="@drawable/logo"
|
||||
android:icon="@drawable/logo"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@drawable/logo"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
|
||||
android:exported="true"
|
||||
android:resizeableActivity="false"
|
||||
android:screenOrientation="landscape">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.moontvplus.tv;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.graphics.Bitmap;
|
||||
import android.net.http.SslError;
|
||||
import android.os.Bundle;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.Window;
|
||||
import android.view.WindowManager;
|
||||
import android.webkit.SslErrorHandler;
|
||||
import android.webkit.WebChromeClient;
|
||||
import android.webkit.WebResourceError;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
public class MainActivity extends Activity {
|
||||
private FrameLayout root;
|
||||
private WebView webView;
|
||||
private View customView;
|
||||
private WebChromeClient.CustomViewCallback customViewCallback;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
requestWindowFeature(Window.FEATURE_NO_TITLE);
|
||||
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);
|
||||
getWindow().getDecorView().setSystemUiVisibility(
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
);
|
||||
|
||||
root = new FrameLayout(this);
|
||||
setContentView(root);
|
||||
setupWebView();
|
||||
webView.loadUrl(buildTvUrl(BuildConfig.BASE_URL));
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private void setupWebView() {
|
||||
webView = new WebView(this);
|
||||
webView.setFocusable(true);
|
||||
webView.setFocusableInTouchMode(true);
|
||||
webView.requestFocus();
|
||||
root.addView(webView, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
));
|
||||
|
||||
WebSettings settings = webView.getSettings();
|
||||
settings.setJavaScriptEnabled(true);
|
||||
settings.setDomStorageEnabled(true);
|
||||
settings.setDatabaseEnabled(true);
|
||||
settings.setMediaPlaybackRequiresUserGesture(false);
|
||||
settings.setLoadWithOverviewMode(true);
|
||||
settings.setUseWideViewPort(true);
|
||||
settings.setAllowFileAccess(false);
|
||||
settings.setAllowContentAccess(true);
|
||||
settings.setSupportZoom(false);
|
||||
settings.setBuiltInZoomControls(false);
|
||||
settings.setDisplayZoomControls(false);
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
|
||||
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
|
||||
}
|
||||
settings.setUserAgentString(settings.getUserAgentString() + " MoonTVPlusAndroidTV");
|
||||
|
||||
webView.setWebViewClient(new WebViewClient() {
|
||||
@Override
|
||||
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
|
||||
super.onReceivedError(view, request, error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageStarted(WebView view, String url, Bitmap favicon) {
|
||||
super.onPageStarted(view, url, favicon);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
|
||||
// Do not bypass TLS certificate errors. HTTP is allowed by network security config;
|
||||
// invalid HTTPS certificates should still be blocked for safety.
|
||||
handler.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
webView.setWebChromeClient(new WebChromeClient() {
|
||||
@Override
|
||||
public void onShowCustomView(View view, CustomViewCallback callback) {
|
||||
if (customView != null) {
|
||||
callback.onCustomViewHidden();
|
||||
return;
|
||||
}
|
||||
customView = view;
|
||||
customViewCallback = callback;
|
||||
webView.setVisibility(View.GONE);
|
||||
root.addView(customView, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHideCustomView() {
|
||||
hideCustomView();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static String buildTvUrl(String baseUrl) {
|
||||
String url = baseUrl == null ? "" : baseUrl.trim();
|
||||
if (url.isEmpty()) {
|
||||
url = "http://192.168.1.10:3000";
|
||||
}
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
url = "http://" + url;
|
||||
}
|
||||
while (url.endsWith("/")) {
|
||||
url = url.substring(0, url.length() - 1);
|
||||
}
|
||||
if (url.endsWith("/tv")) {
|
||||
return url;
|
||||
}
|
||||
return url + "/tv";
|
||||
}
|
||||
|
||||
private void hideCustomView() {
|
||||
if (customView == null) {
|
||||
return;
|
||||
}
|
||||
root.removeView(customView);
|
||||
customView = null;
|
||||
webView.setVisibility(View.VISIBLE);
|
||||
if (customViewCallback != null) {
|
||||
customViewCallback.onCustomViewHidden();
|
||||
customViewCallback = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(KeyEvent event) {
|
||||
if (event.getKeyCode() == KeyEvent.KEYCODE_MENU && event.getAction() == KeyEvent.ACTION_UP) {
|
||||
webView.reload();
|
||||
return true;
|
||||
}
|
||||
return super.dispatchKeyEvent(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (customView != null) {
|
||||
hideCustomView();
|
||||
return;
|
||||
}
|
||||
if (webView != null && webView.canGoBack()) {
|
||||
webView.goBack();
|
||||
return;
|
||||
}
|
||||
super.onBackPressed();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
if (webView != null) {
|
||||
webView.onResume();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
if (webView != null) {
|
||||
webView.onPause();
|
||||
}
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
if (webView != null) {
|
||||
root.removeView(webView);
|
||||
webView.destroy();
|
||||
webView = null;
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 189 KiB |
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<color name="black">#000000</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:style/Theme.Material.NoActionBar">
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowActionBar">false</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:windowBackground">@color/black</item>
|
||||
<item name="android:fontFamily">sans</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=false
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "MoonTVPlusAndroidTV"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user