feat(android): 创建 Android WebView 壳应用

- 添加 MainActivity 和 WebView 配置
- 添加 AssetReader JS 接口用于读取本地文件
- 支持全屏、横屏模式
- 添加错误页面和测试页面
- 添加 Gradle 构建配置和一键构建脚本
This commit is contained in:
李岩岩 2026-02-05 12:41:33 +08:00
parent b7f16e1444
commit 327f03c562
21 changed files with 616 additions and 0 deletions

71
android/README.md Normal file
View File

@ -0,0 +1,71 @@
# Android WebView 壳
## 目录结构
```
android/
├── app/src/main/
│ ├── java/com/iptv/app/
│ │ └── MainActivity.java # WebView 主活动
│ ├── res/
│ │ ├── layout/activity_main.xml
│ │ ├── values/
│ │ │ ├── strings.xml
│ │ │ ├── styles.xml
│ │ │ └── colors.xml
│ │ └── mipmap-*/ # 图标资源
│ ├── assets/
│ │ └── www/ # 打包的 Web 资源 (从 ui/dist-web 复制)
│ └── AndroidManifest.xml
├── build.gradle # 项目级构建配置
├── settings.gradle
└── gradle.properties
```
## 构建步骤
### 1. 构建 Web UI
```bash
cd ../ui
npm install
npm run build:web
```
### 2. 复制资源到 Android
```bash
# 将构建好的 web 资源复制到 Android assets
cp -r ../ui/dist-web/* app/src/main/assets/www/
```
### 3. 构建 APK
```bash
./gradlew assembleDebug
```
APK 输出位置: `app/build/outputs/apk/debug/app-debug.apk`
## 开发模式
如需连接开发服务器测试,修改 `MainActivity.java`:
```java
private static final String LOAD_MODE = "remote";
private static final String REMOTE_URL = "http://你的IP:5173";
```
## 功能特性
- ✅ WebView 加载 Web UI
- ✅ 全屏无标题栏
- ✅ 下拉刷新
- ✅ 返回键支持页面后退
- ✅ 视频全屏自动横屏
- ✅ 暗色主题
## 权限
- `INTERNET` - 网络访问
- `ACCESS_NETWORK_STATE` - 网络状态检测

33
android/app/build.gradle Normal file
View File

@ -0,0 +1,33 @@
plugins {
id 'com.android.application'
}
android {
namespace 'com.iptv.app'
compileSdk 34
defaultConfig {
applicationId "com.iptv.app"
minSdk 21
targetSdk 34
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
}

1
android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1 @@
# ProGuard rules

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.iptv.app">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>加载错误</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0a0a;
color: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
padding: 20px;
text-align: center;
}
.icon {
font-size: 64px;
margin-bottom: 20px;
}
h1 {
font-size: 24px;
margin-bottom: 12px;
color: #ff6b6b;
}
p {
font-size: 14px;
color: #888;
margin-bottom: 8px;
line-height: 1.6;
}
.tips {
margin-top: 30px;
padding: 16px;
background: rgba(255,255,255,0.05);
border-radius: 8px;
max-width: 400px;
}
.tips h2 {
font-size: 14px;
margin-bottom: 10px;
color: #fff;
}
.tips ul {
text-align: left;
padding-left: 20px;
}
.tips li {
font-size: 13px;
color: #aaa;
margin-bottom: 6px;
}
</style>
</head>
<body>
<div class="icon">⚠️</div>
<h1>页面加载失败</h1>
<p>无法加载应用资源,可能是以下原因:</p>
<div class="tips">
<h2>可能的解决方案:</h2>
<ul>
<li>检查网络连接是否正常</li>
<li>清除应用数据后重试</li>
<li>重新安装应用</li>
<li>如果是开发模式,请检查远程服务器地址</li>
</ul>
</div>
</body>
</html>

View File

@ -0,0 +1,49 @@
package com.iptv.app;
import android.content.Context;
import android.webkit.JavascriptInterface;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
public class AssetReader {
private Context context;
public AssetReader(Context context) {
this.context = context;
}
@JavascriptInterface
public String readFile(String path) {
try {
// path : "www/api/result.txt"
InputStream is = context.getAssets().open(path);
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
}
reader.close();
return sb.toString();
} catch (IOException e) {
return "ERROR: " + e.getMessage();
}
}
@JavascriptInterface
public String readChannelData() {
return readFile("www/api/result.txt");
}
@JavascriptInterface
public boolean fileExists(String path) {
try {
context.getAssets().open(path).close();
return true;
} catch (IOException e) {
return false;
}
}
}

View File

@ -0,0 +1,231 @@
package com.iptv.app;
import android.annotation.SuppressLint;
import android.content.pm.ActivityInfo;
import android.graphics.Color;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.View;
import android.view.Window;
import android.view.WindowInsets;
import android.view.WindowInsetsController;
import android.view.WindowManager;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import androidx.appcompat.app.AppCompatActivity;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
public class MainActivity extends AppCompatActivity {
private static final String TAG = "IPTV";
private WebView webView;
private SwipeRefreshLayout swipeRefresh;
// 加载模式"local" 使用本地打包的web资源"remote" 使用远程服务器"test" 使用测试页面
private static final String LOAD_MODE = "local";
// 远程服务器地址开发测试用
private static final String REMOTE_URL = "http://192.168.1.100:5173";
@SuppressLint("SetJavaScriptEnabled")
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Log.d(TAG, "onCreate started");
// 必须在 setContentView 之前调用
requestWindowFeature(Window.FEATURE_NO_TITLE);
setContentView(R.layout.activity_main);
webView = findViewById(R.id.webview);
swipeRefresh = findViewById(R.id.swipe_refresh);
if (webView == null) {
Log.e(TAG, "WebView is null!");
return;
}
// 全屏设置 setContentView 之后
setFullscreen();
// 添加 JavaScript 接口用于读取本地文件
webView.addJavascriptInterface(new AssetReader(this), "AndroidAsset");
// WebView 设置
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setDatabaseEnabled(true);
settings.setCacheMode(WebSettings.LOAD_DEFAULT);
settings.setMediaPlaybackRequiresUserGesture(false);
settings.setAllowFileAccess(true);
settings.setAllowContentAccess(true);
settings.setAllowFileAccessFromFileURLs(true);
settings.setAllowUniversalAccessFromFileURLs(true);
// 支持缩放
settings.setSupportZoom(true);
settings.setBuiltInZoomControls(true);
settings.setDisplayZoomControls(false);
// 启用硬件加速
webView.setLayerType(View.LAYER_TYPE_HARDWARE, null);
// WebViewClient 处理页面跳转
webView.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
Log.d(TAG, "shouldOverrideUrlLoading: " + request.getUrl());
return false; // WebView 内打开链接
}
@Override
public void onPageStarted(WebView view, String url, android.graphics.Bitmap favicon) {
Log.d(TAG, "onPageStarted: " + url);
}
@Override
public void onPageFinished(WebView view, String url) {
Log.d(TAG, "onPageFinished: " + url);
if (swipeRefresh != null) {
swipeRefresh.setRefreshing(false);
}
}
@Override
public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
Log.e(TAG, "onReceivedError: " + errorCode + " - " + description + " - " + failingUrl);
}
});
// WebChromeClient 处理全屏视频和 JS 控制台
webView.setWebChromeClient(new WebChromeClient() {
private View customView;
private CustomViewCallback customViewCallback;
@Override
public boolean onConsoleMessage(android.webkit.ConsoleMessage consoleMessage) {
Log.d(TAG, "WebView Console: " + consoleMessage.message() + " at " + consoleMessage.sourceId() + ":" + consoleMessage.lineNumber());
return true;
}
@Override
public void onProgressChanged(WebView view, int newProgress) {
Log.d(TAG, "Loading progress: " + newProgress + "%");
}
@Override
public void onShowCustomView(View view, CustomViewCallback callback) {
customView = view;
customViewCallback = callback;
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
}
@Override
public void onHideCustomView() {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
if (customViewCallback != null) {
customViewCallback.onCustomViewHidden();
}
customView = null;
}
});
// 下拉刷新
if (swipeRefresh != null) {
swipeRefresh.setOnRefreshListener(() -> webView.reload());
swipeRefresh.setColorSchemeResources(android.R.color.holo_blue_bright);
}
// 延迟加载页面确保 WebView 完全初始化
new Handler(Looper.getMainLooper()).postDelayed(() -> {
loadContent();
}, 100);
Log.d(TAG, "onCreate finished");
}
private void loadContent() {
Log.d(TAG, "loadContent, mode: " + LOAD_MODE);
String url;
if ("test".equals(LOAD_MODE)) {
// 加载测试页面红色背景
url = "file:///android_asset/test.html";
} else if ("local".equals(LOAD_MODE)) {
// 加载本地打包的web资源
url = "file:///android_asset/www/index.html";
} else {
// 加载远程服务器开发测试
url = REMOTE_URL;
}
Log.d(TAG, "Loading URL: " + url);
webView.loadUrl(url);
}
private void setFullscreen() {
// 设置状态栏和导航栏颜色
getWindow().setStatusBarColor(Color.BLACK);
getWindow().setNavigationBarColor(Color.BLACK);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getWindow().setDecorFitsSystemWindows(false);
WindowInsetsController controller = getWindow().getInsetsController();
if (controller != null) {
controller.hide(WindowInsets.Type.statusBars() | WindowInsets.Type.navigationBars());
controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
} else {
getWindow().setFlags(
WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN
);
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_FULLSCREEN
);
}
}
@Override
public void onBackPressed() {
if (webView != null && webView.canGoBack()) {
webView.goBack();
} else {
super.onBackPressed();
}
}
@Override
protected void onResume() {
super.onResume();
if (webView != null) {
webView.onResume();
}
setFullscreen();
}
@Override
protected void onPause() {
super.onPause();
if (webView != null) {
webView.onPause();
}
}
@Override
protected void onDestroy() {
super.onDestroy();
if (webView != null) {
webView.destroy();
}
}
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M38,32 L38,76 L46,76 L46,58 L62,76 L74,76 L74,32 L62,32 L46,50 L46,32 Z"/>
</vector>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#0a0a0a">
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#0a0a0a" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0a0a0a</color>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">IPTV</string>
</resources>

View File

@ -0,0 +1,9 @@
<resources>
<style name="AppTheme" parent="Theme.Material3.Dark.NoActionBar">
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>

4
android/build.gradle Normal file
View File

@ -0,0 +1,4 @@
// Top-level build file
plugins {
id 'com.android.application' version '8.1.0' apply false
}

31
android/build.sh Executable file
View File

@ -0,0 +1,31 @@
#!/bin/bash
# Android WebView 壳打包脚本
set -e
echo "=== IPTV Android 构建 ==="
# 检查 UI 构建产物
if [ ! -d "../ui/dist-web" ]; then
echo "错误: 未找到 ../ui/dist-web 目录"
echo "请先构建 Web UI: cd ../ui && npm run build"
exit 1
fi
# 复制 Web 资源到 Android assets
echo "复制 Web 资源..."
mkdir -p app/src/main/assets/www
cp -r ../ui/dist-web/* app/src/main/assets/www/
# 统计文件
echo "已复制文件数量:"
find app/src/main/assets/www -type f | wc -l
# 构建 Debug APK
echo "构建 Debug APK..."
./gradlew assembleDebug
echo ""
echo "=== 构建完成 ==="
echo "APK 位置: app/build/outputs/apk/debug/app-debug.apk"

View File

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

17
android/gradlew vendored Executable file
View File

@ -0,0 +1,17 @@
#!/bin/sh
# Gradle Wrapper Startup Script
# Find the script's directory
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
WRAPPER_JAR="$SCRIPT_DIR/gradle/wrapper/gradle-wrapper.jar"
# Find Java
if [ -n "$JAVA_HOME" ] ; then
JAVA_CMD="$JAVA_HOME/bin/java"
else
JAVA_CMD="java"
fi
# Run Gradle wrapper
exec "$JAVA_CMD" -cp "$WRAPPER_JAR" org.gradle.wrapper.GradleWrapperMain "$@"

16
android/settings.gradle Normal file
View File

@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "IPTV App"
include ':app'