diff --git a/.dockerignore b/.dockerignore index d74733c..ea31912 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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/ diff --git a/.github/workflows/android-tv.yml b/.github/workflows/android-tv.yml new file mode 100644 index 0000000..6c44e09 --- /dev/null +++ b/.github/workflows/android-tv.yml @@ -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 diff --git a/apps/android-tv/README.md b/apps/android-tv/README.md new file mode 100644 index 0000000..59fa029 --- /dev/null +++ b/apps/android-tv/README.md @@ -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` diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts new file mode 100644 index 0000000..b3ba4d5 --- /dev/null +++ b/apps/android-tv/app/build.gradle.kts @@ -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") + } + } + } +} diff --git a/apps/android-tv/app/src/main/AndroidManifest.xml b/apps/android-tv/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..74e1431 --- /dev/null +++ b/apps/android-tv/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + diff --git a/apps/android-tv/app/src/main/java/com/moontvplus/tv/MainActivity.java b/apps/android-tv/app/src/main/java/com/moontvplus/tv/MainActivity.java new file mode 100644 index 0000000..c198be6 --- /dev/null +++ b/apps/android-tv/app/src/main/java/com/moontvplus/tv/MainActivity.java @@ -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(); + } +} diff --git a/apps/android-tv/app/src/main/res/drawable/logo.png b/apps/android-tv/app/src/main/res/drawable/logo.png new file mode 100644 index 0000000..19ab2cc Binary files /dev/null and b/apps/android-tv/app/src/main/res/drawable/logo.png differ diff --git a/apps/android-tv/app/src/main/res/values/colors.xml b/apps/android-tv/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..0aae89d --- /dev/null +++ b/apps/android-tv/app/src/main/res/values/colors.xml @@ -0,0 +1,3 @@ + + #000000 + diff --git a/apps/android-tv/app/src/main/res/values/styles.xml b/apps/android-tv/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..1ec5d55 --- /dev/null +++ b/apps/android-tv/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + diff --git a/apps/android-tv/app/src/main/res/xml/network_security_config.xml b/apps/android-tv/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..2439f15 --- /dev/null +++ b/apps/android-tv/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,4 @@ + + + + diff --git a/apps/android-tv/build.gradle.kts b/apps/android-tv/build.gradle.kts new file mode 100644 index 0000000..a0dd15f --- /dev/null +++ b/apps/android-tv/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("com.android.application") version "8.7.3" apply false +} diff --git a/apps/android-tv/gradle.properties b/apps/android-tv/gradle.properties new file mode 100644 index 0000000..ed2d02a --- /dev/null +++ b/apps/android-tv/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=false +android.nonTransitiveRClass=true diff --git a/apps/android-tv/settings.gradle.kts b/apps/android-tv/settings.gradle.kts new file mode 100644 index 0000000..66db2fd --- /dev/null +++ b/apps/android-tv/settings.gradle.kts @@ -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")