Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59a4096b37 | |||
| 156cecc848 | |||
| 98da628aee | |||
| e0aee44d5a | |||
| e7e215628b |
+13
-2
@@ -49,10 +49,21 @@ cleanup_project.sh
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/
|
||||
.vs/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Duplicate release directories
|
||||
XMBOX-Release/
|
||||
|
||||
# Tools and utilities (not part of the project)
|
||||
other/tools/bfg.jar
|
||||
other/tools/cleaner.bat
|
||||
other/
|
||||
tools/
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"java.configuration.updateBuildConfiguration": "automatic"
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
# 贡献指南
|
||||
|
||||
## 项目结构
|
||||
|
||||
这是一个Android应用项目,主要使用Java开发。
|
||||
|
||||
### 语言组成
|
||||
- **Java (78.4%)** - 主要应用代码
|
||||
- **JavaScript (9.9%)** - WebView内嵌脚本和爬虫引擎
|
||||
- **CSS (7.3%)** - WebView样式文件
|
||||
- **GLSL (2.5%)** - Media3视频渲染着色器
|
||||
- **Shell (1.1%)** - 构建和部署脚本
|
||||
- **HTML (0.8%)** - WebView页面
|
||||
|
||||
### 目录说明
|
||||
|
||||
```
|
||||
XMBOX/
|
||||
├── app/ # 主应用模块
|
||||
│ ├── src/main/ # 通用代码
|
||||
│ ├── src/leanback/ # 电视版UI代码
|
||||
│ └── src/mobile/ # 手机版UI代码
|
||||
├── catvod/ # 爬虫核心库
|
||||
├── quickjs/ # JavaScript引擎
|
||||
├── thunder/ # 迅雷下载模块
|
||||
├── forcetech/ # P2P模块
|
||||
├── jianpian/ # 减片模块
|
||||
├── tvbus/ # TVBus模块
|
||||
├── zlive/ # 直播模块
|
||||
└── docs/ # 文档
|
||||
|
||||
```
|
||||
|
||||
## 代码规范
|
||||
|
||||
### Java代码
|
||||
- 遵循Android开发规范
|
||||
- 使用驼峰命名法
|
||||
- 类名首字母大写
|
||||
- 方法和变量名首字母小写
|
||||
- 常量全大写,用下划线分隔
|
||||
|
||||
### 资源文件
|
||||
- JavaScript/CSS/HTML位于 `app/src/main/assets/`
|
||||
- 这些文件用于WebView解析和内容抓取,不可删除
|
||||
|
||||
### GLSL着色器
|
||||
- 由Media3库提供,用于视频渲染
|
||||
- 自动生成,不需要手动修改
|
||||
|
||||
## 清理项目
|
||||
|
||||
运行清理脚本:
|
||||
```bash
|
||||
./clean_project.sh
|
||||
```
|
||||
|
||||
这将清理:
|
||||
- 构建产物(build目录)
|
||||
- 临时文件
|
||||
- 系统文件(.DS_Store等)
|
||||
- IDE配置文件
|
||||
|
||||
## 提交代码
|
||||
|
||||
1. 清理项目:`./clean_project.sh`
|
||||
2. 查看改动:`git status`
|
||||
3. 添加文件:`git add .`
|
||||
4. 提交代码:`git commit -m "描述"`
|
||||
5. 推送代码:`git push`
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **不要删除assets目录**中的JS/CSS/HTML文件,这些是应用必需的
|
||||
2. **不要删除GLSL文件**,这些是视频播放器需要的
|
||||
3. 提交前运行 `./gradlew clean` 清理构建产物
|
||||
4. 确保新增的临时文件已添加到 `.gitignore`
|
||||
@@ -1,8 +1,8 @@
|
||||
<h1 align="center"> 📱 XMBOX - Android资源播放器
|
||||
<h1 align="center"> 📱 XMBOX - Android视频播放器
|
||||
</h1>
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -34,10 +34,11 @@
|
||||
- 🛡️ **稳定可靠** - 完善的错误处理和崩溃防护
|
||||
- 🌐 **网络优化** - 智能代理和DNS解析
|
||||
- 📱 **Material Design** - 现代化UI设计
|
||||
- ☁️ **WebDAV同步** - 支持观看记录和设置云端同步,支持账号模式和同步码模式
|
||||
|
||||
## 📥 下载安装
|
||||
|
||||
### 最新版本: v3.1.0
|
||||
### 最新版本: v3.1.1
|
||||
|
||||
| 平台 | ARM64-V8A | ARM V7A |
|
||||
|------|-----------|---------|
|
||||
@@ -45,6 +46,7 @@
|
||||
| **📺 TV版** | [下载 (34.5MB)](https://github.com/Tosencen/XMBOX-Release/raw/main/apk/release/v3.1.0/leanback-arm64_v8a-v3.1.0.apk) | [下载 (30.5MB)](https://github.com/Tosencen/XMBOX-Release/raw/main/apk/release/v3.1.0/leanback-armeabi_v7a-v3.1.0.apk) |
|
||||
|
||||
### 📁 版本历史
|
||||
- **v3.1.1**: [查看v3.1.1版本](https://github.com/Tosencen/XMBOX-Release/tree/main/apk/release/v3.1.1) - 新增WebDAV同步功能和更新安装器
|
||||
- **v3.1.0**: [查看v3.1.0版本](https://github.com/Tosencen/XMBOX-Release/tree/main/apk/release/v3.1.0) - 定时器优化和画中画修复版本
|
||||
- **v3.0.9**: [查看v3.0.9版本](https://github.com/Tosencen/XMBOX-Release/tree/main/apk/release/v3.0.9) - 新增直播开关控制和UI交互优化
|
||||
- **v3.0.8**: [查看v3.0.8版本](https://github.com/Tosencen/XMBOX-Release/tree/main/apk/release/v3.0.8) - UI交互体验全面优化
|
||||
@@ -134,6 +136,25 @@ XMBOX/
|
||||
|
||||
## 📝 更新日志
|
||||
|
||||
### v3.1.1 (2025-11-10)
|
||||
|
||||
#### ✨ 新功能
|
||||
* **WebDAV同步功能** - 新增WebDAV云端同步功能,支持观看记录和设置的多设备同步
|
||||
* 支持账号模式:使用WebDAV服务器账号密码进行同步
|
||||
* 支持同步码模式:无需账号,使用同步码即可实现多设备数据共享
|
||||
* 自动合并本地和远程数据,确保数据完整性
|
||||
* **更新安装器** - 新增UpdateInstaller工具类,优化应用更新安装体验
|
||||
* **自定义开关组件** - 新增CustomSwitch组件,提供更灵活的UI控制
|
||||
|
||||
#### 🎨 UI优化
|
||||
* **WebDAV配置界面** - 新增WebDAV配置对话框,支持账号模式和同步码模式切换
|
||||
* **设置页面优化** - 优化设置页面布局和交互体验
|
||||
|
||||
#### 🔧 技术改进
|
||||
* **代码同步管理** - 新增SyncCodeManager和WebDAVSyncManager,完善同步功能架构
|
||||
* **构建脚本优化** - 新增build_all_release.sh脚本,支持一键构建所有版本
|
||||
* **稳定性增强** - 优化同步逻辑,防止重复同步和数据冲突
|
||||
|
||||
### v3.1.0 (2025-10-28)
|
||||
|
||||
#### ✨ 新功能
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# XMBOX Release Files
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
apk/release/
|
||||
├── mobile.json # 最新版本信息 (手机版)
|
||||
├── leanback.json # 最新版本信息 (TV版)
|
||||
├── v3.0.7/ # v3.0.7版本文件
|
||||
│ ├── mobile.json # v3.0.7版本信息
|
||||
│ ├── leanback.json # v3.0.7版本信息
|
||||
│ ├── mobile-arm64_v8a.apk
|
||||
│ ├── mobile-armeabi_v7a.apk
|
||||
│ ├── leanback-arm64_v8a.apk
|
||||
│ └── leanback-armeabi_v7a.apk
|
||||
└── v3.0.8/ # v3.0.8版本文件
|
||||
├── mobile.json # v3.0.8版本信息
|
||||
├── leanback.json # v3.0.8版本信息
|
||||
├── mobile-arm64_v8a-v3.0.8.apk
|
||||
├── mobile-armeabi_v7a-v3.0.8.apk
|
||||
├── leanback-arm64_v8a-v3.0.8.apk
|
||||
└── leanback-armeabi_v7a-v3.0.8.apk
|
||||
```
|
||||
|
||||
## 📱 版本说明
|
||||
|
||||
### v3.0.8 (最新版本)
|
||||
- **发布时间**: 2025-10-14
|
||||
- **版本代码**: 308
|
||||
- **主要更新**: UI交互体验全面优化
|
||||
|
||||
### v3.0.7
|
||||
- **发布时间**: 2025-09-26
|
||||
- **版本代码**: 307
|
||||
- **主要更新**: 全面优化稳定性和用户体验
|
||||
|
||||
## 🔗 下载链接
|
||||
|
||||
### 最新版本 (v3.0.8)
|
||||
- **手机版 ARM64**: [mobile-arm64_v8a-v3.0.8.apk](v3.0.8/mobile-arm64_v8a-v3.0.8.apk)
|
||||
- **手机版 ARMv7**: [mobile-armeabi_v7a-v3.0.8.apk](v3.0.8/mobile-armeabi_v7a-v3.0.8.apk)
|
||||
- **TV版 ARM64**: [leanback-arm64_v8a-v3.0.8.apk](v3.0.8/leanback-arm64_v8a-v3.0.8.apk)
|
||||
- **TV版 ARMv7**: [leanback-armeabi_v7a-v3.0.8.apk](v3.0.8/leanback-armeabi_v7a-v3.0.8.apk)
|
||||
|
||||
### 历史版本
|
||||
- **v3.0.7**: [查看v3.0.7版本文件](v3.0.7/)
|
||||
|
||||
## 📋 版本信息
|
||||
|
||||
每个版本目录都包含对应的JSON配置文件,包含:
|
||||
- `name`: 版本号
|
||||
- `desc`: 版本描述和更新内容
|
||||
- `code`: 版本代码
|
||||
- `downloads`: 下载链接映射 (仅根目录文件)
|
||||
|
||||
## 🔐 签名信息
|
||||
|
||||
所有APK文件均使用多重签名保护:
|
||||
- ✅ v1 (JAR签名) - 最佳兼容性
|
||||
- ✅ v2 (APK签名方案v2) - 全文件签名
|
||||
- ✅ v3 (APK签名方案v3) - 支持密钥轮换
|
||||
- ✅ v4 (APK签名方案v4) - 增量签名
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "3.1.0",
|
||||
"desc": "XMBOX TV版 v3.1.0\n\n✨ 新功能:\n• 实现定时按钮倒计时显示功能\n• 适配pixel主题化图标展示\n\n🎨 UI优化:\n• 优化TimerDialog按钮宽度设计\n• 优化播放进度条交互体验\n• 改进界面视觉一致性\n\n🐛 修复:\n• 修复更新跳转链接,跳转到具体版本页面\n\n🔧 改进优化:\n• 提升定时功能用户体验\n• 优化内存使用\n• 提升播放稳定性\n\n📺 支持架构:ARM64-v8a | ARMv7a",
|
||||
"code": 310,
|
||||
"downloads": {
|
||||
"arm64_v8a": "v3.1.0/leanback-arm64_v8a-v3.1.0.apk",
|
||||
"armeabi_v7a": "v3.1.0/leanback-armeabi_v7a-v3.1.0.apk"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "3.1.0",
|
||||
"desc": "XMBOX 手机版 v3.1.0\n\n✨ 新功能:\n• 实现定时按钮倒计时显示功能\n• 适配pixel主题化图标展示\n\n🎨 UI优化:\n• 优化TimerDialog按钮宽度设计\n• 优化播放进度条交互体验\n• 改进界面视觉一致性\n\n🐛 修复:\n• 修复更新跳转链接,跳转到具体版本页面\n\n🔧 改进优化:\n• 提升定时功能用户体验\n• 优化内存使用\n• 提升播放稳定性\n\n📱 支持架构:ARM64-v8a | ARMv7a",
|
||||
"code": 310,
|
||||
"downloads": {
|
||||
"arm64_v8a": "v3.1.0/mobile-arm64_v8a-v3.1.0.apk",
|
||||
"armeabi_v7a": "v3.1.0/mobile-armeabi_v7a-v3.1.0.apk"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "3.0.7",
|
||||
"desc": "XMBOX TV版 v3.0.7 (Android TV/机顶盒专用)\n\n✨ UI优化:\n• 全新自定义开关按钮(黄色/黑色Material Design风格)\n• 优化电量百分比显示(16sp字号,2dp间距)\n• 精简设置页面,隐藏壁纸功能\n\n🔒 安全增强:\n• 启用v1/v2/v3/v4多重签名保护\n• 提升应用安全性和兼容性\n\n🔧 改进优化:\n• 修复设置页面崩溃问题\n• 优化大屏界面体验\n• 提升播放稳定性\n\n📺 专为电视优化:遥控器导航 | 10-foot UI | ARM64/ARMv7",
|
||||
"code": 307
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "3.0.7",
|
||||
"desc": "XMBOX 手机版 v3.0.7\n\n✨ UI优化:\n• 全新自定义开关按钮(黄色/黑色Material Design风格)\n• 优化电量百分比显示(16sp字号,2dp间距)\n• 精简设置页面,隐藏壁纸功能\n\n🔒 安全增强:\n• 启用v1/v2/v3/v4多重签名保护\n• 提升应用安全性和兼容性\n\n🔧 改进优化:\n• 修复设置页面崩溃问题\n• 优化内存使用\n• 提升播放稳定性\n\n📱 支持架构:ARM64-v8a | ARMv7a",
|
||||
"code": 307
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"name": "3.1.0",
|
||||
"desc": "XMBOX TV版 v3.1.0\n\n✨ 新功能:\n• 实现定时按钮倒计时显示功能\n• 适配pixel主题化图标展示\n\n🎨 UI优化:\n• 优化TimerDialog按钮宽度设计\n• 优化播放进度条交互体验\n• 改进界面视觉一致性\n\n🐛 修复:\n• 修复更新跳转链接,跳转到具体版本页面\n\n🔧 改进优化:\n• 提升定时功能用户体验\n• 优化内存使用\n• 提升播放稳定性\n\n📺 支持架构:ARM64-v8a | ARMv7a",
|
||||
"code": 310,
|
||||
"downloads": {
|
||||
"arm64_v8a": "v3.1.0/leanback-arm64_v8a-v3.1.0.apk",
|
||||
"armeabi_v7a": "v3.1.0/leanback-armeabi_v7a-v3.1.0.apk"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"name": "3.1.0",
|
||||
"desc": "XMBOX 手机版 v3.1.0\n\n✨ 新功能:\n• 实现定时按钮倒计时显示功能\n• 适配pixel主题化图标展示\n\n🎨 UI优化:\n• 优化TimerDialog按钮宽度设计\n• 优化播放进度条交互体验\n• 改进界面视觉一致性\n\n🐛 修复:\n• 修复更新跳转链接,跳转到具体版本页面\n\n🔧 改进优化:\n• 提升定时功能用户体验\n• 优化内存使用\n• 提升播放稳定性\n\n📱 支持架构:ARM64-v8a | ARMv7a",
|
||||
"code": 310,
|
||||
"downloads": {
|
||||
"arm64_v8a": "v3.1.0/mobile-arm64_v8a-v3.1.0.apk",
|
||||
"armeabi_v7a": "v3.1.0/mobile-armeabi_v7a-v3.1.0.apk"
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -27,8 +27,11 @@ android {
|
||||
minSdk 24
|
||||
//noinspection ExpiredTargetSdkVersion
|
||||
targetSdk 28
|
||||
versionCode 310
|
||||
versionName "3.1.0"
|
||||
versionCode 311
|
||||
versionName "3.1.1"
|
||||
// GitHub Token (可选,用于提高API请求限制)
|
||||
def githubToken = project.findProperty("GITHUB_TOKEN") ?: ""
|
||||
buildConfigField "String", "GITHUB_TOKEN", "\"${githubToken}\""
|
||||
javaCompileOptions {
|
||||
annotationProcessorOptions {
|
||||
arguments = ["room.schemaLocation": "$projectDir/schemas".toString(), "eventBusIndex": "com.fongmi.android.tv.event.EventIndex"]
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.fongmi.android.tv.utils.Download;
|
||||
import com.fongmi.android.tv.utils.FileUtil;
|
||||
import com.fongmi.android.tv.utils.Notify;
|
||||
import com.fongmi.android.tv.utils.ResUtil;
|
||||
import com.fongmi.android.tv.utils.UpdateInstaller;
|
||||
import com.github.catvod.net.OkHttp;
|
||||
import com.github.catvod.utils.Github;
|
||||
import com.github.catvod.utils.Logger;
|
||||
@@ -29,53 +30,34 @@ import java.util.Locale;
|
||||
public class Updater implements Download.Callback {
|
||||
|
||||
private DialogUpdateBinding binding;
|
||||
private final Download download;
|
||||
private Download download;
|
||||
private AlertDialog dialog;
|
||||
private boolean dev;
|
||||
private boolean forceCheck; // 是否为手动检查
|
||||
private boolean autoShow; // 是否自动显示更新对话框(用于自动检查)
|
||||
private String latestVersion; // 存储检测到的最新版本
|
||||
private String releaseApkUrl; // 从 GitHub Release 获取的 APK 下载链接
|
||||
private String releaseApkUrl; // 从 GitHub Release 获取的 APK 下载链接(jsDelivr CDN)
|
||||
private String fallbackApkUrl; // 备用下载链接(GitHub原始URL)
|
||||
|
||||
// 静态变量:记录上次检查时间(用于时间间隔限制)
|
||||
private static long lastCheckTime = 0;
|
||||
private static final long CHECK_INTERVAL = 60 * 60 * 1000; // 1小时(毫秒)
|
||||
|
||||
private File getFile() {
|
||||
return Path.root("Download", "XMBOX-update.apk");
|
||||
}
|
||||
|
||||
private String getJson() {
|
||||
return Github.getJson(dev, BuildConfig.FLAVOR_mode);
|
||||
// Android 10+ 无法直接访问外部存储的Download目录
|
||||
// 使用应用的cache目录,FileProvider可以正常访问
|
||||
return Path.cache("XMBOX-update.apk");
|
||||
}
|
||||
|
||||
private String getApk() {
|
||||
// 优先使用从 GitHub Release 获取的 APK URL
|
||||
// 使用从 GitHub Release 获取的 APK URL(jsDelivr CDN)
|
||||
if (releaseApkUrl != null && !releaseApkUrl.isEmpty()) {
|
||||
Logger.d("APK download URL from Release: " + releaseApkUrl);
|
||||
Logger.d("APK download URL from Release (jsDelivr): " + releaseApkUrl);
|
||||
return releaseApkUrl;
|
||||
}
|
||||
|
||||
// 使用JSON中指定的具体下载路径
|
||||
try {
|
||||
String response = OkHttp.string(getJson());
|
||||
JSONObject object = new JSONObject(response);
|
||||
JSONObject downloads = object.optJSONObject("downloads");
|
||||
if (downloads != null) {
|
||||
String abi = BuildConfig.FLAVOR_abi;
|
||||
String downloadPath = downloads.optString(abi);
|
||||
if (!downloadPath.isEmpty()) {
|
||||
// 直接构建完整URL,不通过Github.getApk()避免重复添加路径
|
||||
String baseUrl = Github.useCnMirror() ?
|
||||
"https://gitee.com/ochenoktochen/XMBOX-Release/raw/main" :
|
||||
"https://raw.githubusercontent.com/Tosencen/XMBOX-Release/main";
|
||||
String fullUrl = baseUrl + "/apk/" + (dev ? "dev" : "release") + "/" + downloadPath;
|
||||
Logger.d("APK download URL: " + fullUrl);
|
||||
return fullUrl;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.e("Failed to get download path from JSON: " + e.getMessage());
|
||||
}
|
||||
// 回退到原来的方式
|
||||
String fallbackUrl = Github.getApk(dev, BuildConfig.FLAVOR_mode + "-" + BuildConfig.FLAVOR_abi);
|
||||
Logger.d("APK fallback URL: " + fallbackUrl);
|
||||
return fallbackUrl;
|
||||
// 如果没有获取到URL,返回空(不应该发生)
|
||||
Logger.e("Updater: 未找到APK下载链接");
|
||||
return "";
|
||||
}
|
||||
|
||||
public static Updater create() {
|
||||
@@ -83,8 +65,9 @@ public class Updater implements Download.Callback {
|
||||
}
|
||||
|
||||
public Updater() {
|
||||
this.download = Download.create(getApk(), getFile(), this);
|
||||
this.forceCheck = false;
|
||||
this.autoShow = false;
|
||||
// download对象将在需要时创建
|
||||
}
|
||||
|
||||
public Updater force() {
|
||||
@@ -94,6 +77,15 @@ public class Updater implements Download.Callback {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置自动检查模式(应用启动时自动检查)
|
||||
*/
|
||||
public Updater auto() {
|
||||
this.forceCheck = false;
|
||||
this.autoShow = true; // 自动显示更新对话框
|
||||
return this;
|
||||
}
|
||||
|
||||
public Updater release() {
|
||||
this.dev = false;
|
||||
return this;
|
||||
@@ -110,6 +102,16 @@ public class Updater implements Download.Callback {
|
||||
}
|
||||
|
||||
public void start(Activity activity) {
|
||||
// 如果是自动检查,检查时间间隔
|
||||
if (autoShow && !forceCheck) {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long timeSinceLastCheck = currentTime - lastCheckTime;
|
||||
// 1小时内只检查一次
|
||||
if (lastCheckTime > 0 && timeSinceLastCheck < CHECK_INTERVAL) {
|
||||
Logger.d("Updater: 距离上次检查仅 " + (timeSinceLastCheck / 1000 / 60) + " 分钟,跳过本次检查");
|
||||
return;
|
||||
}
|
||||
}
|
||||
App.execute(() -> doInBackground(activity));
|
||||
}
|
||||
|
||||
@@ -119,48 +121,9 @@ public class Updater implements Download.Callback {
|
||||
|
||||
private void doInBackground(Activity activity) {
|
||||
Logger.d("Updater: Starting update check...");
|
||||
try {
|
||||
// 优先使用 JSON 方式检测更新(兼容性更好)
|
||||
String response = OkHttp.string(getJson());
|
||||
JSONObject object = new JSONObject(response);
|
||||
String name = object.optString("name");
|
||||
String desc = object.optString("desc");
|
||||
int code = object.optInt("code");
|
||||
|
||||
Logger.d("Updater: JSON Remote version: " + name + ", code: " + code);
|
||||
Logger.d("Updater: Local version: " + BuildConfig.VERSION_NAME + ", code: " + BuildConfig.VERSION_CODE);
|
||||
|
||||
// 使用 JSON 中的版本信息
|
||||
if (need(code, name)) {
|
||||
Logger.d("Updater: Update needed (from JSON), showing dialog");
|
||||
this.latestVersion = name; // 保存最新版本号
|
||||
|
||||
// 从 JSON 获取下载链接
|
||||
JSONObject downloads = object.optJSONObject("downloads");
|
||||
if (downloads != null) {
|
||||
String abi = BuildConfig.FLAVOR_abi;
|
||||
String downloadPath = downloads.optString(abi);
|
||||
if (!downloadPath.isEmpty()) {
|
||||
String baseUrl = Github.useCnMirror() ?
|
||||
"https://gitee.com/ochenoktochen/XMBOX-Release/raw/main" :
|
||||
"https://raw.githubusercontent.com/Tosencen/XMBOX-Release/main";
|
||||
this.releaseApkUrl = baseUrl + "/apk/" + (dev ? "dev" : "release") + "/" + downloadPath;
|
||||
Logger.d("Updater: APK URL from JSON: " + this.releaseApkUrl);
|
||||
}
|
||||
}
|
||||
|
||||
App.post(() -> show(activity, name, desc));
|
||||
} else {
|
||||
Logger.d("Updater: No update needed (from JSON)");
|
||||
if (forceCheck) {
|
||||
App.post(() -> Notify.show("已是最新版本 " + name));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.e("Updater: JSON check failed, trying GitHub API: " + e.getMessage());
|
||||
// JSON 检测失败,尝试使用 GitHub Releases API
|
||||
checkViaGitHubAPI(activity);
|
||||
}
|
||||
lastCheckTime = System.currentTimeMillis(); // 更新检查时间
|
||||
// 直接使用 GitHub Releases API 检查更新
|
||||
checkViaGitHubAPI(activity);
|
||||
}
|
||||
|
||||
private void checkViaGitHubAPI(Activity activity) {
|
||||
@@ -168,12 +131,42 @@ public class Updater implements Download.Callback {
|
||||
String releasesUrl = "https://api.github.com/repos/Tosencen/XMBOX/releases/latest";
|
||||
Logger.d("Updater: Trying GitHub Releases API: " + releasesUrl);
|
||||
|
||||
String response = OkHttp.string(releasesUrl);
|
||||
// 检查是否有GitHub Token
|
||||
String githubToken = BuildConfig.GITHUB_TOKEN;
|
||||
String response;
|
||||
if (githubToken != null && !githubToken.isEmpty()) {
|
||||
// 使用token进行认证请求(5000次/小时)
|
||||
java.util.Map<String, String> headers = new java.util.HashMap<>();
|
||||
headers.put("Authorization", "Bearer " + githubToken);
|
||||
headers.put("Accept", "application/vnd.github.v3+json");
|
||||
Logger.d("Updater: Using GitHub Token for authenticated request");
|
||||
response = OkHttp.string(releasesUrl, headers);
|
||||
} else {
|
||||
// 使用未认证请求(60次/小时)
|
||||
Logger.d("Updater: Using unauthenticated request (60 requests/hour limit)");
|
||||
response = OkHttp.string(releasesUrl);
|
||||
}
|
||||
|
||||
if (response.contains("rate limit exceeded")) {
|
||||
// 检查响应是否为空(可能是网络错误、VPN问题等)
|
||||
if (response == null || response.isEmpty()) {
|
||||
Logger.e("Updater: 网络请求失败,响应为空。可能是网络连接问题或VPN配置问题");
|
||||
if (forceCheck) {
|
||||
// 手动检查时,显示错误提示
|
||||
App.post(() -> {
|
||||
Notify.show("检查更新失败:网络连接异常,请检查网络设置或VPN配置");
|
||||
showVersionInfo(activity, BuildConfig.VERSION_NAME, "");
|
||||
});
|
||||
} else {
|
||||
Logger.w("Updater: 自动检查失败,网络不可用");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.contains("rate limit exceeded") || response.contains("API rate limit exceeded")) {
|
||||
Logger.e("Updater: Rate limit exceeded");
|
||||
if (forceCheck) {
|
||||
App.post(() -> Notify.show("检查更新失败:API请求过于频繁,请稍后重试"));
|
||||
// 手动检查时,显示版本信息弹窗(不显示错误提示)
|
||||
App.post(() -> showVersionInfo(activity, BuildConfig.VERSION_NAME, ""));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -181,7 +174,8 @@ public class Updater implements Download.Callback {
|
||||
if (response.contains("Not Found") || response.contains("404")) {
|
||||
Logger.e("Updater: Release not found");
|
||||
if (forceCheck) {
|
||||
App.post(() -> Notify.show("检查更新失败:无法连接到更新服务器"));
|
||||
// 手动检查时,显示版本信息弹窗(不显示错误提示)
|
||||
App.post(() -> showVersionInfo(activity, BuildConfig.VERSION_NAME, ""));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -196,26 +190,105 @@ public class Updater implements Download.Callback {
|
||||
// 从 assets 中查找 APK
|
||||
JSONArray assets = release.optJSONArray("assets");
|
||||
if (assets != null) {
|
||||
String targetApkName = BuildConfig.FLAVOR_mode + "-" + BuildConfig.FLAVOR_abi + "-v" + version + ".apk";
|
||||
for (int i = 0; i < assets.length(); i++) {
|
||||
String mode = BuildConfig.FLAVOR_mode;
|
||||
String abi = BuildConfig.FLAVOR_abi;
|
||||
|
||||
// 尝试多种文件名格式
|
||||
String[] possibleNames = {
|
||||
mode + "-" + abi + "-v" + version + ".apk", // leanback-arm64_v8a-v3.1.0.apk
|
||||
mode + "-" + abi + "-release.apk", // leanback-arm64_v8a-release.apk
|
||||
mode + "-" + abi + ".apk", // leanback-arm64_v8a.apk
|
||||
mode + "-" + abi + "-" + version + ".apk" // leanback-arm64_v8a-3.1.0.apk
|
||||
};
|
||||
|
||||
boolean found = false;
|
||||
for (int i = 0; i < assets.length() && !found; i++) {
|
||||
JSONObject asset = assets.getJSONObject(i);
|
||||
if (targetApkName.equals(asset.optString("name"))) {
|
||||
this.releaseApkUrl = asset.optString("browser_download_url");
|
||||
break;
|
||||
String assetName = asset.optString("name");
|
||||
|
||||
// 检查是否匹配任何可能的文件名格式
|
||||
for (String targetName : possibleNames) {
|
||||
if (targetName.equals(assetName)) {
|
||||
String githubUrl = asset.optString("browser_download_url");
|
||||
// jsDelivr无法访问GitHub Release文件,直接使用GitHub Release URL
|
||||
this.releaseApkUrl = githubUrl;
|
||||
this.fallbackApkUrl = githubUrl;
|
||||
Logger.d("Updater: 找到匹配的APK: " + assetName);
|
||||
Logger.d("Updater: APK URL (GitHub Release): " + this.releaseApkUrl);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果精确匹配失败,尝试模糊匹配(包含mode和abi的APK文件)
|
||||
if (!found) {
|
||||
Logger.w("Updater: 未找到精确匹配的APK,尝试模糊匹配...");
|
||||
for (int i = 0; i < assets.length(); i++) {
|
||||
JSONObject asset = assets.getJSONObject(i);
|
||||
String assetName = asset.optString("name");
|
||||
// 检查文件名是否包含mode和abi,且是APK文件
|
||||
if (assetName.endsWith(".apk") &&
|
||||
assetName.contains(mode) &&
|
||||
assetName.contains(abi.replace("_", "-"))) {
|
||||
String githubUrl = asset.optString("browser_download_url");
|
||||
// jsDelivr无法访问GitHub Release文件,直接使用GitHub Release URL
|
||||
this.releaseApkUrl = githubUrl;
|
||||
this.fallbackApkUrl = githubUrl;
|
||||
Logger.d("Updater: 找到模糊匹配的APK: " + assetName);
|
||||
Logger.d("Updater: APK URL (GitHub Release): " + this.releaseApkUrl);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
Logger.e("Updater: 在Release中未找到匹配的APK文件");
|
||||
Logger.e("Updater: 期望的格式: " + mode + "-" + abi + "-v" + version + ".apk");
|
||||
Logger.e("Updater: 可用的assets:");
|
||||
for (int i = 0; i < assets.length(); i++) {
|
||||
JSONObject asset = assets.getJSONObject(i);
|
||||
String assetName = asset.optString("name");
|
||||
if (assetName.endsWith(".apk")) {
|
||||
Logger.e("Updater: - " + assetName);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.e("Updater: Release中没有assets数组");
|
||||
}
|
||||
|
||||
if (needUpdate(version)) {
|
||||
this.latestVersion = version;
|
||||
// 有新版本时,自动显示或手动显示更新对话框
|
||||
App.post(() -> show(activity, version, body));
|
||||
} else if (forceCheck) {
|
||||
App.post(() -> Notify.show("已是最新版本 " + version));
|
||||
} else {
|
||||
// 没有新版本
|
||||
if (forceCheck) {
|
||||
// 手动检查时,显示版本信息弹窗
|
||||
App.post(() -> showVersionInfo(activity, version, body));
|
||||
} else if (autoShow) {
|
||||
// 自动检查时,不显示任何内容(静默检查)
|
||||
Logger.d("Updater: 自动检查完成,当前已是最新版本");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.e("Updater: GitHub API check failed: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
if (forceCheck) {
|
||||
App.post(() -> Notify.show("检查更新失败:无法连接到更新服务器"));
|
||||
// 手动检查时,显示错误提示
|
||||
String errorMsg = e.getMessage();
|
||||
if (errorMsg != null && (errorMsg.contains("network") || errorMsg.contains("timeout") || errorMsg.contains("connect"))) {
|
||||
App.post(() -> {
|
||||
Notify.show("检查更新失败:网络连接异常,请检查网络设置或VPN配置");
|
||||
showVersionInfo(activity, BuildConfig.VERSION_NAME, "");
|
||||
});
|
||||
} else {
|
||||
App.post(() -> showVersionInfo(activity, BuildConfig.VERSION_NAME, ""));
|
||||
}
|
||||
} else {
|
||||
Logger.w("Updater: 自动检查失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,42 +330,55 @@ public class Updater implements Download.Callback {
|
||||
binding.desc.setText(desc);
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示版本信息弹窗(无更新时)
|
||||
*/
|
||||
private void showVersionInfo(Activity activity, String remoteVersion, String desc) {
|
||||
binding = DialogUpdateBinding.inflate(LayoutInflater.from(activity));
|
||||
// 先设置所有内容,再显示对话框
|
||||
binding.version.setText("最新版本");
|
||||
binding.desc.setText(BuildConfig.VERSION_NAME); // 只显示当前版本号,不使用远程信息
|
||||
binding.confirm.setVisibility(View.GONE);
|
||||
binding.cancel.setText("确定");
|
||||
binding.cancel.setOnClickListener(v -> {
|
||||
if (dialog != null) dialog.dismiss();
|
||||
});
|
||||
check().create(activity).show();
|
||||
}
|
||||
|
||||
private AlertDialog create(Activity activity) {
|
||||
return dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).setCancelable(false).create();
|
||||
dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).setCancelable(false).create();
|
||||
// 设置对话框背景为透明,让布局的深色背景显示
|
||||
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
|
||||
dialog.getWindow().setDimAmount(0);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
private void cancel(View view) {
|
||||
Setting.putUpdate(false);
|
||||
download.cancel();
|
||||
dismiss();
|
||||
if (download != null) {
|
||||
download.cancel();
|
||||
}
|
||||
dialog.dismiss();
|
||||
}
|
||||
|
||||
private void confirm(View view) {
|
||||
// 跳转到具体版本的GitHub Releases页面
|
||||
try {
|
||||
String url = "https://github.com/Tosencen/XMBOX/releases/tag/v" + latestVersion;
|
||||
Logger.d("Updater: Attempting to open URL: " + url);
|
||||
// 开始下载更新(使用jsDelivr CDN,失败时回退到GitHub)
|
||||
String downloadUrl = getApk();
|
||||
String fallbackUrl = this.fallbackApkUrl;
|
||||
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW);
|
||||
intent.setData(Uri.parse(url));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
// 检查是否有应用可以处理这个Intent
|
||||
if (intent.resolveActivity(App.get().getPackageManager()) != null) {
|
||||
App.get().startActivity(intent);
|
||||
Logger.d("Updater: Successfully started browser intent");
|
||||
dismiss();
|
||||
} else {
|
||||
Logger.e("Updater: No app can handle the URL");
|
||||
Notify.show("没有找到可以打开链接的应用,请手动访问GitHub下载");
|
||||
dismiss();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.e("Updater: Failed to open GitHub releases page: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
Notify.show("无法打开更新页面,请手动访问GitHub下载");
|
||||
dismiss();
|
||||
// 检查URL是否为空
|
||||
if (downloadUrl == null || downloadUrl.isEmpty()) {
|
||||
Logger.e("Updater: 下载URL为空,无法下载");
|
||||
Notify.show("无法获取下载链接,请稍后重试或手动下载");
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.d("Updater: 开始下载,URL: " + downloadUrl);
|
||||
|
||||
// 创建带回退URL的下载对象
|
||||
this.download = Download.create(downloadUrl, getFile(), fallbackUrl, this);
|
||||
this.download.start();
|
||||
}
|
||||
|
||||
private void dismiss() {
|
||||
@@ -315,7 +401,30 @@ public class Updater implements Download.Callback {
|
||||
|
||||
@Override
|
||||
public void success(File file) {
|
||||
FileUtil.openFile(file);
|
||||
dismiss();
|
||||
// 使用UpdateInstaller处理安装,包括权限检查和请求
|
||||
UpdateInstaller installer = UpdateInstaller.get();
|
||||
|
||||
// 检查安装权限
|
||||
if (!installer.hasInstallPermission()) {
|
||||
// 没有权限,请求权限并保存待安装的文件
|
||||
Logger.d("Updater: 没有安装权限,请求权限");
|
||||
installer.requestInstallPermission();
|
||||
// 保存待安装的文件,将在权限授予后自动安装
|
||||
installer.install(file, true); // checkPermission=true会保存文件
|
||||
Notify.show("请授予安装权限以完成更新");
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
// 有权限,直接安装
|
||||
boolean success = installer.install(file, false);
|
||||
if (success) {
|
||||
Logger.d("Updater: 已启动安装程序");
|
||||
dismiss();
|
||||
} else {
|
||||
Logger.e("Updater: 启动安装程序失败");
|
||||
Notify.show("无法启动安装程序,请检查文件是否完整");
|
||||
dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +245,24 @@ public class HomeActivity extends BaseActivity implements CustomTitleView.Listen
|
||||
}
|
||||
|
||||
private void getHistory(boolean renew) {
|
||||
List<History> items = History.get();
|
||||
// 获取所有视频源的观看记录(最近60天)
|
||||
List<History> items = History.getAll();
|
||||
com.github.catvod.utils.Logger.d("HomeActivity: 获取观看记录,共 " + items.size() + " 条");
|
||||
|
||||
// 对比一下数据库中所有记录
|
||||
List<com.fongmi.android.tv.bean.History> allInDb = com.fongmi.android.tv.db.AppDatabase.get().getHistoryDao().findAllRecent(0);
|
||||
com.github.catvod.utils.Logger.d("HomeActivity: 数据库总记录数: " + allInDb.size() + " 条(包含所有时间)");
|
||||
|
||||
if (items.size() < allInDb.size()) {
|
||||
com.github.catvod.utils.Logger.w("HomeActivity: 有 " + (allInDb.size() - items.size()) + " 条记录因为时间过滤被隐藏");
|
||||
}
|
||||
|
||||
for (History h : items) {
|
||||
com.github.catvod.utils.Logger.d("HomeActivity: 记录 - " + h.getVodName() +
|
||||
" (cid=" + h.getCid() +
|
||||
", createTime=" + h.getCreateTime() + ")");
|
||||
}
|
||||
|
||||
int historyIndex = getHistoryIndex();
|
||||
int recommendIndex = getRecommendIndex();
|
||||
boolean exist = recommendIndex - historyIndex == 2;
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.fongmi.android.tv.ui.activity;
|
||||
import android.Manifest;
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.viewbinding.ViewBinding;
|
||||
@@ -35,11 +36,13 @@ import com.fongmi.android.tv.ui.dialog.LiveDialog;
|
||||
import com.fongmi.android.tv.ui.dialog.ProxyDialog;
|
||||
import com.fongmi.android.tv.ui.dialog.RestoreDialog;
|
||||
import com.fongmi.android.tv.ui.dialog.SiteDialog;
|
||||
import com.fongmi.android.tv.ui.dialog.WebDAVDialog;
|
||||
import com.fongmi.android.tv.utils.FileChooser;
|
||||
import com.fongmi.android.tv.utils.FileUtil;
|
||||
import com.fongmi.android.tv.utils.Notify;
|
||||
import com.fongmi.android.tv.utils.ResUtil;
|
||||
import com.fongmi.android.tv.utils.UrlUtil;
|
||||
import com.fongmi.android.tv.utils.WebDAVSyncManager;
|
||||
import com.github.catvod.bean.Doh;
|
||||
import com.github.catvod.net.OkHttp;
|
||||
import com.github.catvod.utils.Path;
|
||||
@@ -102,9 +105,32 @@ public class SettingActivity extends BaseActivity implements ConfigCallback, Sit
|
||||
mBinding.liveTabVisibleText.setText(getSwitch(Setting.isLiveTabVisible()));
|
||||
mBinding.sizeText.setText((size = ResUtil.getStringArray(R.array.select_size))[Setting.getSize()]);
|
||||
mBinding.qualityText.setText((quality = ResUtil.getStringArray(R.array.select_quality))[Setting.getQuality()]);
|
||||
setWebDAVStatus();
|
||||
setLiveSettingsVisibility();
|
||||
}
|
||||
|
||||
private void setWebDAVStatus() {
|
||||
WebDAVSyncManager manager = WebDAVSyncManager.get();
|
||||
if (manager.isConfigured()) {
|
||||
// 显示账号昵称(用户名)
|
||||
String username = Setting.getWebDAVUsername();
|
||||
if (!TextUtils.isEmpty(username)) {
|
||||
// 如果用户名是邮箱,只显示@前面的部分
|
||||
String displayName = username;
|
||||
if (username.contains("@")) {
|
||||
displayName = username.substring(0, username.indexOf("@"));
|
||||
}
|
||||
String status = Setting.isWebDAVAutoSync() ? displayName + "(自动同步)" : displayName;
|
||||
mBinding.webdavStatusText.setText(status);
|
||||
} else {
|
||||
String status = Setting.isWebDAVAutoSync() ? "已配置(自动同步)" : "已配置";
|
||||
mBinding.webdavStatusText.setText(status);
|
||||
}
|
||||
} else {
|
||||
mBinding.webdavStatusText.setText("未配置");
|
||||
}
|
||||
}
|
||||
|
||||
private void setLiveSettingsVisibility() {
|
||||
boolean isLiveTabVisible = !Setting.isLiveTabVisible(); // 注意:这里取反,因为开关是"隐藏直播"
|
||||
mBinding.live.setVisibility(isLiveTabVisible ? View.VISIBLE : View.GONE);
|
||||
@@ -147,6 +173,7 @@ public class SettingActivity extends BaseActivity implements ConfigCallback, Sit
|
||||
mBinding.quality.setOnClickListener(this::setQuality);
|
||||
mBinding.size.setOnClickListener(this::setSize);
|
||||
mBinding.doh.setOnClickListener(this::setDoh);
|
||||
mBinding.webdav.setOnClickListener(this::onWebDAV);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -405,12 +432,33 @@ public class SettingActivity extends BaseActivity implements ConfigCallback, Sit
|
||||
}));
|
||||
}
|
||||
|
||||
private void onWebDAV(View view) {
|
||||
WebDAVDialog.create(this).show();
|
||||
}
|
||||
|
||||
private void initConfig() {
|
||||
WallConfig.get().init();
|
||||
LiveConfig.get().init().load();
|
||||
VodConfig.get().init().load(getCallback(0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRefreshEvent(RefreshEvent event) {
|
||||
super.onRefreshEvent(event);
|
||||
if (event.getType() == RefreshEvent.Type.CONFIG) {
|
||||
setWebDAVStatus();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
if (hasFocus) {
|
||||
// 当Activity重新获得焦点时,更新WebDAV状态(例如从对话框返回后)
|
||||
setWebDAVStatus();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.fongmi.android.tv.ui.custom;
|
||||
|
||||
import android.animation.ArgbEvaluator;
|
||||
import android.animation.ValueAnimator;
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.RectF;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.appcompat.widget.AppCompatCheckBox;
|
||||
|
||||
public class CustomSwitch extends AppCompatCheckBox {
|
||||
|
||||
private Paint trackPaint;
|
||||
private Paint thumbPaint;
|
||||
private RectF trackRect;
|
||||
private RectF thumbRect;
|
||||
|
||||
private float thumbPosition = 0f; // 0 = 左边, 1 = 右边
|
||||
private int currentTrackColor;
|
||||
private int currentThumbColor;
|
||||
|
||||
private static final int TRACK_COLOR_OFF = 0xFF555555; // 灰色
|
||||
private static final int TRACK_COLOR_ON = 0xFFFFEB3B; // 黄色
|
||||
private static final int THUMB_COLOR_OFF = 0xFFFFFFFF; // 白色
|
||||
private static final int THUMB_COLOR_ON = 0xFF000000; // 黑色
|
||||
|
||||
private ValueAnimator animator;
|
||||
|
||||
public CustomSwitch(Context context) {
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public CustomSwitch(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
|
||||
public CustomSwitch(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
init();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
// 隐藏默认的checkbox样式
|
||||
setButtonDrawable(null);
|
||||
|
||||
trackPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
thumbPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
|
||||
trackRect = new RectF();
|
||||
thumbRect = new RectF();
|
||||
|
||||
currentTrackColor = TRACK_COLOR_OFF;
|
||||
currentThumbColor = THUMB_COLOR_OFF;
|
||||
|
||||
// 监听状态变化
|
||||
setOnCheckedChangeListener((buttonView, isChecked) -> animateSwitch(isChecked));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||
// 固定尺寸:50dp × 30dp
|
||||
int width = (int) (50 * getResources().getDisplayMetrics().density);
|
||||
int height = (int) (30 * getResources().getDisplayMetrics().density);
|
||||
setMeasuredDimension(width, height);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
int width = getWidth();
|
||||
int height = getHeight();
|
||||
float radius = height / 2f;
|
||||
|
||||
// 绘制轨道
|
||||
trackRect.set(0, 0, width, height);
|
||||
trackPaint.setColor(currentTrackColor);
|
||||
canvas.drawRoundRect(trackRect, radius, radius, trackPaint);
|
||||
|
||||
// 计算小圆位置
|
||||
float thumbSize = height - 8 * getResources().getDisplayMetrics().density; // 22dp
|
||||
float padding = 4 * getResources().getDisplayMetrics().density;
|
||||
float thumbLeft = padding + thumbPosition * (width - thumbSize - 2 * padding);
|
||||
float thumbTop = padding;
|
||||
|
||||
// 绘制小圆
|
||||
thumbRect.set(thumbLeft, thumbTop, thumbLeft + thumbSize, thumbTop + thumbSize);
|
||||
thumbPaint.setColor(currentThumbColor);
|
||||
canvas.drawOval(thumbRect, thumbPaint);
|
||||
}
|
||||
|
||||
private void animateSwitch(boolean isChecked) {
|
||||
if (animator != null && animator.isRunning()) {
|
||||
animator.cancel();
|
||||
}
|
||||
|
||||
float targetPosition = isChecked ? 1f : 0f;
|
||||
int targetTrackColor = isChecked ? TRACK_COLOR_ON : TRACK_COLOR_OFF;
|
||||
int targetThumbColor = isChecked ? THUMB_COLOR_ON : THUMB_COLOR_OFF;
|
||||
|
||||
animator = ValueAnimator.ofFloat(thumbPosition, targetPosition);
|
||||
animator.setDuration(250); // 250ms动画时长
|
||||
|
||||
final ArgbEvaluator colorEvaluator = new ArgbEvaluator();
|
||||
|
||||
animator.addUpdateListener(animation -> {
|
||||
thumbPosition = (float) animation.getAnimatedValue();
|
||||
|
||||
// 颜色渐变
|
||||
currentTrackColor = (int) colorEvaluator.evaluate(
|
||||
thumbPosition, TRACK_COLOR_OFF, TRACK_COLOR_ON
|
||||
);
|
||||
currentThumbColor = (int) colorEvaluator.evaluate(
|
||||
thumbPosition, THUMB_COLOR_OFF, THUMB_COLOR_ON
|
||||
);
|
||||
|
||||
invalidate();
|
||||
});
|
||||
|
||||
animator.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setChecked(boolean checked) {
|
||||
super.setChecked(checked);
|
||||
// 初始化时不播放动画
|
||||
if (!isAttachedToWindow()) {
|
||||
thumbPosition = checked ? 1f : 0f;
|
||||
currentTrackColor = checked ? TRACK_COLOR_ON : TRACK_COLOR_OFF;
|
||||
currentThumbColor = checked ? THUMB_COLOR_ON : THUMB_COLOR_OFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ public class ConfigDialog implements DialogInterface.OnDismissListener {
|
||||
params.width = (int) (ResUtil.getScreenWidth() * 0.55f);
|
||||
dialog.getWindow().setAttributes(params);
|
||||
dialog.getWindow().setDimAmount(0);
|
||||
// 设置对话框背景为透明,让布局的深色背景显示
|
||||
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
|
||||
dialog.setOnDismissListener(this);
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ public class DescDialog {
|
||||
DialogDescBinding binding = DialogDescBinding.inflate(LayoutInflater.from(activity));
|
||||
AlertDialog dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).create();
|
||||
dialog.getWindow().setDimAmount(0);
|
||||
// 设置对话框背景为透明,让布局的深色背景显示
|
||||
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
|
||||
initView(binding.text, desc, activity);
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ public class DohDialog implements DohAdapter.OnClickListener {
|
||||
params.width = (int) (ResUtil.getScreenWidth() * 0.4f);
|
||||
dialog.getWindow().setAttributes(params);
|
||||
dialog.getWindow().setDimAmount(0);
|
||||
// 设置对话框背景为透明,让布局的深色背景显示
|
||||
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ public class HistoryDialog implements ConfigAdapter.OnClickListener {
|
||||
params.width = (int) (ResUtil.getScreenWidth() * 0.4f);
|
||||
dialog.getWindow().setAttributes(params);
|
||||
dialog.getWindow().setDimAmount(0);
|
||||
// 设置对话框背景为透明,让布局的深色背景显示
|
||||
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ public class LiveDialog implements LiveAdapter.OnClickListener {
|
||||
params.width = (int) (ResUtil.getScreenWidth() * 0.4f);
|
||||
dialog.getWindow().setAttributes(params);
|
||||
dialog.getWindow().setDimAmount(0);
|
||||
// 设置对话框背景为透明,让布局的深色背景显示
|
||||
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ public class ProxyDialog implements DialogInterface.OnDismissListener {
|
||||
params.width = (int) (ResUtil.getScreenWidth() * 0.55f);
|
||||
dialog.getWindow().setAttributes(params);
|
||||
dialog.getWindow().setDimAmount(0);
|
||||
// 设置对话框背景为透明,让布局的深色背景显示
|
||||
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
|
||||
dialog.setOnDismissListener(this);
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ public class RestoreDialog implements RestoreAdapter.OnClickListener {
|
||||
params.width = (int) (ResUtil.getScreenWidth() * 0.4f);
|
||||
dialog.getWindow().setAttributes(params);
|
||||
dialog.getWindow().setDimAmount(0);
|
||||
// 设置对话框背景为透明,让布局的深色背景显示
|
||||
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,8 @@ public class SiteDialog implements SiteAdapter.OnClickListener {
|
||||
params.width = (int) (ResUtil.getScreenWidth() * getWidth());
|
||||
dialog.getWindow().setAttributes(params);
|
||||
dialog.getWindow().setDimAmount(0);
|
||||
// 设置对话框背景为透明,让布局的深色背景显示
|
||||
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ public class UaDialog implements DialogInterface.OnDismissListener {
|
||||
params.width = (int) (ResUtil.getScreenWidth() * 0.55f);
|
||||
dialog.getWindow().setAttributes(params);
|
||||
dialog.getWindow().setDimAmount(0);
|
||||
// 设置对话框背景为透明,让布局的深色背景显示
|
||||
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
|
||||
dialog.setOnDismissListener(this);
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
package com.fongmi.android.tv.ui.dialog;
|
||||
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
import android.text.Editable;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
|
||||
import com.fongmi.android.tv.App;
|
||||
import com.fongmi.android.tv.R;
|
||||
import com.fongmi.android.tv.Setting;
|
||||
import com.fongmi.android.tv.databinding.DialogWebdavBinding;
|
||||
import com.fongmi.android.tv.event.RefreshEvent;
|
||||
import com.fongmi.android.tv.utils.Notify;
|
||||
import com.fongmi.android.tv.utils.ResUtil;
|
||||
import com.fongmi.android.tv.utils.WebDAVSyncManager;
|
||||
import com.github.catvod.utils.Logger;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
public class WebDAVDialog {
|
||||
|
||||
// 预设的WebDAV服务提供商
|
||||
private static final String[] PROVIDERS = {
|
||||
"坚果云",
|
||||
"Nextcloud",
|
||||
"ownCloud",
|
||||
"自定义"
|
||||
};
|
||||
|
||||
private static final String[] PROVIDER_URLS = {
|
||||
"https://dav.jianguoyun.com/dav/XMBOX/", // 坚果云(添加XMBOX子目录,方便在网页版查看)
|
||||
"", // Nextcloud(需要用户输入)
|
||||
"", // ownCloud(需要用户输入)
|
||||
"" // 自定义(需要用户输入)
|
||||
};
|
||||
|
||||
private final DialogWebdavBinding binding;
|
||||
private final FragmentActivity activity;
|
||||
private AlertDialog dialog;
|
||||
private WebDAVSyncManager syncManager;
|
||||
private int selectedProvider = 0; // 默认选择坚果云
|
||||
private boolean isInitializing = false; // 标记是否正在初始化,防止初始化时触发监听器
|
||||
private Handler statusHandler = new Handler(Looper.getMainLooper());
|
||||
private Runnable hideStatusRunnable; // 用于延迟隐藏状态消息
|
||||
|
||||
public static WebDAVDialog create(FragmentActivity activity) {
|
||||
return new WebDAVDialog(activity);
|
||||
}
|
||||
|
||||
public WebDAVDialog(FragmentActivity activity) {
|
||||
this.activity = activity;
|
||||
this.binding = DialogWebdavBinding.inflate(LayoutInflater.from(activity));
|
||||
this.syncManager = WebDAVSyncManager.get();
|
||||
}
|
||||
|
||||
public void show() {
|
||||
initDialog();
|
||||
initView();
|
||||
initEvent();
|
||||
}
|
||||
|
||||
private void initDialog() {
|
||||
dialog = new MaterialAlertDialogBuilder(activity)
|
||||
.setView(binding.getRoot())
|
||||
.create();
|
||||
|
||||
// 设置对话框大小(适合TV屏幕)
|
||||
WindowManager.LayoutParams params = dialog.getWindow().getAttributes();
|
||||
params.width = (int) (ResUtil.getScreenWidth() * 0.45f);
|
||||
dialog.getWindow().setAttributes(params);
|
||||
dialog.getWindow().setDimAmount(0);
|
||||
// 设置对话框背景为透明,让布局的深色背景显示
|
||||
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
private void initView() {
|
||||
isInitializing = true; // 标记开始初始化
|
||||
|
||||
// 加载已保存的配置
|
||||
String url = Setting.getWebDAVUrl();
|
||||
String username = Setting.getWebDAVUsername();
|
||||
String password = Setting.getWebDAVPassword();
|
||||
boolean autoSync = Setting.isWebDAVAutoSync();
|
||||
int interval = Setting.getWebDAVSyncInterval();
|
||||
|
||||
// 根据保存的URL判断是哪个服务提供商
|
||||
selectedProvider = getProviderIndexByUrl(url);
|
||||
binding.providerText.setText(PROVIDERS[selectedProvider]);
|
||||
|
||||
// 根据选择的服务提供商决定是否显示URL输入框
|
||||
if (selectedProvider == PROVIDERS.length - 1) {
|
||||
// 自定义,显示URL输入框
|
||||
binding.urlInput.setVisibility(View.VISIBLE);
|
||||
binding.urlText.setText(url);
|
||||
if (!TextUtils.isEmpty(url)) {
|
||||
binding.urlText.setSelection(url.length());
|
||||
}
|
||||
} else if (selectedProvider == 0) {
|
||||
// 坚果云,永远隐藏输入框(有预设URL)
|
||||
binding.urlInput.setVisibility(View.GONE);
|
||||
} else {
|
||||
// Nextcloud或ownCloud需要用户输入URL
|
||||
binding.urlInput.setVisibility(View.VISIBLE);
|
||||
binding.urlText.setText(url);
|
||||
if (!TextUtils.isEmpty(url)) {
|
||||
binding.urlText.setSelection(url.length());
|
||||
}
|
||||
}
|
||||
|
||||
binding.usernameText.setText(username);
|
||||
binding.passwordText.setText(password);
|
||||
binding.autoSyncSwitch.setChecked(autoSync);
|
||||
binding.syncIntervalText.setText(String.valueOf(interval));
|
||||
|
||||
// 根据自动同步开关显示/隐藏同步间隔
|
||||
updateSyncIntervalVisibility(autoSync);
|
||||
|
||||
isInitializing = false; // 初始化完成
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据URL判断是哪个服务提供商
|
||||
*/
|
||||
private int getProviderIndexByUrl(String url) {
|
||||
if (TextUtils.isEmpty(url)) {
|
||||
return 0; // 默认坚果云
|
||||
}
|
||||
if (url.contains("jianguoyun.com")) {
|
||||
return 0; // 坚果云
|
||||
}
|
||||
if (url.contains("nextcloud")) {
|
||||
return 1; // Nextcloud
|
||||
}
|
||||
if (url.contains("owncloud")) {
|
||||
return 2; // ownCloud
|
||||
}
|
||||
return PROVIDERS.length - 1; // 自定义
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前选择的服务提供商的URL
|
||||
*/
|
||||
private String getProviderUrl() {
|
||||
if (selectedProvider < PROVIDER_URLS.length && !TextUtils.isEmpty(PROVIDER_URLS[selectedProvider])) {
|
||||
return PROVIDER_URLS[selectedProvider];
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private void initEvent() {
|
||||
// 服务提供商选择
|
||||
binding.providerText.setOnClickListener(v -> onSelectProvider());
|
||||
|
||||
// 自动同步开关监听(立即保存状态)
|
||||
// 使用setOnClickListener而不是setOnCheckedChangeListener,避免覆盖CustomSwitch内部的动画监听器
|
||||
// AppCompatCheckBox会自动处理状态切换,我们只需要在状态切换后获取新状态
|
||||
binding.autoSyncSwitch.setOnClickListener(v -> {
|
||||
// 防止初始化时触发监听器
|
||||
if (isInitializing) {
|
||||
return;
|
||||
}
|
||||
// 使用post()确保在状态切换后获取新状态
|
||||
binding.autoSyncSwitch.post(() -> {
|
||||
boolean newState = binding.autoSyncSwitch.isChecked();
|
||||
// 立即保存自动同步状态
|
||||
Setting.putWebDAVAutoSync(newState);
|
||||
// 更新同步间隔的可见性
|
||||
updateSyncIntervalVisibility(newState);
|
||||
});
|
||||
});
|
||||
|
||||
// 测试连接按钮
|
||||
binding.testButton.setOnClickListener(v -> onTestConnection());
|
||||
|
||||
// 立即同步按钮
|
||||
binding.syncButton.setOnClickListener(v -> onSyncNow());
|
||||
|
||||
// 同步间隔点击(弹出选择对话框)
|
||||
binding.syncIntervalContainer.setOnClickListener(v -> onSelectInterval());
|
||||
|
||||
// 保存按钮
|
||||
binding.positive.setOnClickListener(v -> onPositive(null, 0));
|
||||
|
||||
// 取消按钮
|
||||
binding.negative.setOnClickListener(v -> onNegative(null, 0));
|
||||
|
||||
// 密码输入框回车键
|
||||
binding.passwordText.setOnEditorActionListener((textView, actionId, event) -> {
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
binding.positive.performClick();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// 监听输入框内容变化,清除状态提示
|
||||
TextWatcher clearStatusWatcher = new TextWatcher() {
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
clearStatus();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {}
|
||||
};
|
||||
binding.urlText.addTextChangedListener(clearStatusWatcher);
|
||||
binding.usernameText.addTextChangedListener(clearStatusWatcher);
|
||||
binding.passwordText.addTextChangedListener(clearStatusWatcher);
|
||||
}
|
||||
|
||||
private void onSelectProvider() {
|
||||
AlertDialog providerDialog = new MaterialAlertDialogBuilder(activity)
|
||||
.setTitle("选择服务提供商")
|
||||
.setSingleChoiceItems(PROVIDERS, selectedProvider, (dialog, which) -> {
|
||||
selectedProvider = which;
|
||||
binding.providerText.setText(PROVIDERS[which]);
|
||||
|
||||
// 如果是自定义,显示URL输入框
|
||||
if (which == PROVIDERS.length - 1) {
|
||||
binding.urlInput.setVisibility(View.VISIBLE);
|
||||
String currentUrl = binding.urlText.getText().toString().trim();
|
||||
if (TextUtils.isEmpty(currentUrl)) {
|
||||
binding.urlText.setText("");
|
||||
}
|
||||
} else {
|
||||
// 使用预设的URL
|
||||
binding.urlInput.setVisibility(View.GONE);
|
||||
String providerUrl = getProviderUrl();
|
||||
if (!TextUtils.isEmpty(providerUrl)) {
|
||||
// URL会在保存时自动填充
|
||||
} else {
|
||||
// Nextcloud或ownCloud需要用户输入URL
|
||||
binding.urlInput.setVisibility(View.VISIBLE);
|
||||
binding.urlText.setHint("请输入" + PROVIDERS[which] + "服务器地址");
|
||||
}
|
||||
}
|
||||
dialog.dismiss();
|
||||
})
|
||||
.setNegativeButton("取消", null)
|
||||
.create();
|
||||
// 设置对话框深色背景
|
||||
providerDialog.getWindow().setBackgroundDrawableResource(R.color.black_90);
|
||||
providerDialog.getWindow().setDimAmount(0);
|
||||
providerDialog.show();
|
||||
|
||||
// 设置标题和按钮文字颜色为白色
|
||||
setDialogTextColor(providerDialog, R.color.white);
|
||||
|
||||
// 设置列表项文字颜色为白色(使用 post 确保在列表渲染后设置)
|
||||
android.widget.ListView listView = providerDialog.getListView();
|
||||
if (listView != null) {
|
||||
listView.post(() -> {
|
||||
for (int i = 0; i < listView.getChildCount(); i++) {
|
||||
View itemView = listView.getChildAt(i);
|
||||
setTextViewColorRecursive(itemView, R.color.white);
|
||||
}
|
||||
});
|
||||
// 监听列表滚动,确保新显示的项目也是白色
|
||||
listView.setOnScrollListener(new android.widget.AbsListView.OnScrollListener() {
|
||||
@Override
|
||||
public void onScrollStateChanged(android.widget.AbsListView view, int scrollState) {
|
||||
if (scrollState == android.widget.AbsListView.OnScrollListener.SCROLL_STATE_IDLE) {
|
||||
for (int i = 0; i < view.getChildCount(); i++) {
|
||||
setTextViewColorRecursive(view.getChildAt(i), R.color.white);
|
||||
}
|
||||
}
|
||||
}
|
||||
@Override
|
||||
public void onScroll(android.widget.AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void updateSyncIntervalVisibility(boolean visible) {
|
||||
binding.syncIntervalContainer.setVisibility(visible ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归设置 View 及其子 View 中所有 TextView 的文字颜色
|
||||
*/
|
||||
private void setTextViewColorRecursive(View view, int colorResId) {
|
||||
if (view == null) return;
|
||||
|
||||
if (view instanceof android.widget.TextView) {
|
||||
((android.widget.TextView) view).setTextColor(activity.getResources().getColor(colorResId));
|
||||
} else if (view instanceof android.view.ViewGroup) {
|
||||
android.view.ViewGroup group = (android.view.ViewGroup) view;
|
||||
for (int i = 0; i < group.getChildCount(); i++) {
|
||||
setTextViewColorRecursive(group.getChildAt(i), colorResId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置对话框中的标题和按钮文字颜色
|
||||
*/
|
||||
private void setDialogTextColor(AlertDialog dialog, int colorResId) {
|
||||
if (dialog == null) return;
|
||||
|
||||
int color = activity.getResources().getColor(colorResId);
|
||||
|
||||
// 设置标题文字颜色
|
||||
int titleId = activity.getResources().getIdentifier("alertTitle", "id", "android");
|
||||
if (titleId != 0) {
|
||||
View titleView = dialog.findViewById(titleId);
|
||||
if (titleView instanceof android.widget.TextView) {
|
||||
((android.widget.TextView) titleView).setTextColor(color);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 post 延迟设置按钮文字颜色(按钮可能在显示后才创建)
|
||||
dialog.getWindow().getDecorView().post(() -> {
|
||||
android.widget.Button negativeButton = dialog.getButton(DialogInterface.BUTTON_NEGATIVE);
|
||||
if (negativeButton != null) {
|
||||
negativeButton.setTextColor(color);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void onTestConnection() {
|
||||
String url = getServerUrl();
|
||||
String username = binding.usernameText.getText().toString().trim();
|
||||
String password = binding.passwordText.getText().toString().trim();
|
||||
|
||||
if (TextUtils.isEmpty(url)) {
|
||||
showStatus("请选择服务提供商或输入服务器地址", false);
|
||||
return;
|
||||
}
|
||||
if (TextUtils.isEmpty(username)) {
|
||||
showStatus("请输入用户名", false);
|
||||
return;
|
||||
}
|
||||
if (TextUtils.isEmpty(password)) {
|
||||
showStatus("请输入密码", false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 临时保存配置用于测试
|
||||
Setting.putWebDAVUrl(url);
|
||||
Setting.putWebDAVUsername(username);
|
||||
Setting.putWebDAVPassword(password);
|
||||
syncManager.reloadConfig();
|
||||
|
||||
showStatus("正在测试连接...", true);
|
||||
binding.testButton.setEnabled(false);
|
||||
App.execute(() -> {
|
||||
WebDAVSyncManager.TestResult result = syncManager.testConnectionWithMessage();
|
||||
App.post(() -> {
|
||||
// 检查对话框是否还存在
|
||||
if (binding == null || dialog == null || !dialog.isShowing()) {
|
||||
return;
|
||||
}
|
||||
binding.testButton.setEnabled(true);
|
||||
showStatus(result.message, result.success);
|
||||
if (!result.success) {
|
||||
// 显示详细错误信息
|
||||
Logger.e("WebDAV测试连接失败: " + result.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void onSyncNow() {
|
||||
// 先临时保存当前配置用于测试同步
|
||||
String url = getServerUrl();
|
||||
String username = binding.usernameText.getText().toString().trim();
|
||||
String password = binding.passwordText.getText().toString().trim();
|
||||
|
||||
// 验证输入
|
||||
if (TextUtils.isEmpty(url)) {
|
||||
showStatus("请选择服务提供商或输入服务器地址", false);
|
||||
return;
|
||||
}
|
||||
if (TextUtils.isEmpty(username)) {
|
||||
showStatus("请输入用户名", false);
|
||||
return;
|
||||
}
|
||||
if (TextUtils.isEmpty(password)) {
|
||||
showStatus("请输入密码", false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 临时保存配置用于同步
|
||||
Setting.putWebDAVUrl(url);
|
||||
Setting.putWebDAVUsername(username);
|
||||
Setting.putWebDAVPassword(password);
|
||||
syncManager.reloadConfig();
|
||||
|
||||
if (!syncManager.isConfigured()) {
|
||||
showStatus("配置无效,无法同步", false);
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus("正在同步...", true);
|
||||
binding.syncButton.setEnabled(false);
|
||||
|
||||
// 在后台线程执行同步
|
||||
App.execute(() -> {
|
||||
try {
|
||||
// 先上传本地记录
|
||||
syncManager.uploadHistory();
|
||||
// 再下载远程记录并合并
|
||||
boolean downloadSuccess = syncManager.downloadHistory();
|
||||
|
||||
App.post(() -> {
|
||||
// 检查对话框是否还存在
|
||||
if (binding == null || dialog == null || !dialog.isShowing()) {
|
||||
return;
|
||||
}
|
||||
binding.syncButton.setEnabled(true);
|
||||
if (downloadSuccess) {
|
||||
showStatus("同步完成", true);
|
||||
Notify.show("同步完成");
|
||||
} else {
|
||||
showStatus("同步完成(本地数据已上传)", true);
|
||||
Notify.show("同步完成");
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
App.post(() -> {
|
||||
// 检查对话框是否还存在
|
||||
if (binding == null || dialog == null || !dialog.isShowing()) {
|
||||
return;
|
||||
}
|
||||
binding.syncButton.setEnabled(true);
|
||||
showStatus("同步失败:" + e.getMessage(), false);
|
||||
Notify.show("同步失败");
|
||||
Logger.e("WebDAV: 同步失败: " + e.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void onSelectInterval() {
|
||||
String[] intervals = {"15", "30", "60", "120", "240"};
|
||||
int currentInterval = Setting.getWebDAVSyncInterval();
|
||||
int selectedIndex = 0;
|
||||
for (int i = 0; i < intervals.length; i++) {
|
||||
if (Integer.parseInt(intervals[i]) == currentInterval) {
|
||||
selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog intervalDialog = new MaterialAlertDialogBuilder(activity)
|
||||
.setTitle("选择同步间隔")
|
||||
.setSingleChoiceItems(intervals, selectedIndex, (dialog, which) -> {
|
||||
int interval = Integer.parseInt(intervals[which]);
|
||||
binding.syncIntervalText.setText(String.valueOf(interval));
|
||||
// 立即保存同步间隔
|
||||
Setting.putWebDAVSyncInterval(interval);
|
||||
dialog.dismiss();
|
||||
})
|
||||
.setNegativeButton("取消", null)
|
||||
.create();
|
||||
// 设置对话框深色背景
|
||||
intervalDialog.getWindow().setBackgroundDrawableResource(R.color.black_90);
|
||||
intervalDialog.getWindow().setDimAmount(0);
|
||||
intervalDialog.show();
|
||||
|
||||
// 设置标题和按钮文字颜色为白色
|
||||
setDialogTextColor(intervalDialog, R.color.white);
|
||||
|
||||
// 设置列表项文字颜色为白色(使用 post 确保在列表渲染后设置)
|
||||
android.widget.ListView listView = intervalDialog.getListView();
|
||||
if (listView != null) {
|
||||
listView.post(() -> {
|
||||
for (int i = 0; i < listView.getChildCount(); i++) {
|
||||
View itemView = listView.getChildAt(i);
|
||||
setTextViewColorRecursive(itemView, R.color.white);
|
||||
}
|
||||
});
|
||||
// 监听列表滚动,确保新显示的项目也是白色
|
||||
listView.setOnScrollListener(new android.widget.AbsListView.OnScrollListener() {
|
||||
@Override
|
||||
public void onScrollStateChanged(android.widget.AbsListView view, int scrollState) {
|
||||
if (scrollState == android.widget.AbsListView.OnScrollListener.SCROLL_STATE_IDLE) {
|
||||
for (int i = 0; i < view.getChildCount(); i++) {
|
||||
setTextViewColorRecursive(view.getChildAt(i), R.color.white);
|
||||
}
|
||||
}
|
||||
}
|
||||
@Override
|
||||
public void onScroll(android.widget.AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void showStatus(String message, boolean isSuccess) {
|
||||
// 检查对话框是否还存在
|
||||
if (binding == null || dialog == null || !dialog.isShowing()) {
|
||||
return;
|
||||
}
|
||||
// 取消之前的隐藏任务
|
||||
if (hideStatusRunnable != null) {
|
||||
statusHandler.removeCallbacks(hideStatusRunnable);
|
||||
hideStatusRunnable = null;
|
||||
}
|
||||
|
||||
binding.statusText.setText(message);
|
||||
binding.statusText.setVisibility(TextUtils.isEmpty(message) ? View.GONE : View.VISIBLE);
|
||||
binding.statusText.setTextColor(isSuccess ?
|
||||
activity.getResources().getColor(R.color.white) :
|
||||
activity.getResources().getColor(android.R.color.holo_red_dark));
|
||||
|
||||
// 3秒后自动隐藏状态消息
|
||||
if (!TextUtils.isEmpty(message)) {
|
||||
hideStatusRunnable = () -> clearStatus();
|
||||
statusHandler.postDelayed(hideStatusRunnable, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除状态提示
|
||||
*/
|
||||
private void clearStatus() {
|
||||
// 检查对话框是否还存在
|
||||
if (binding == null || dialog == null || !dialog.isShowing()) {
|
||||
return;
|
||||
}
|
||||
if (hideStatusRunnable != null) {
|
||||
statusHandler.removeCallbacks(hideStatusRunnable);
|
||||
hideStatusRunnable = null;
|
||||
}
|
||||
binding.statusText.setText("");
|
||||
binding.statusText.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务器URL(根据选择的服务提供商)
|
||||
*/
|
||||
private String getServerUrl() {
|
||||
if (selectedProvider == PROVIDERS.length - 1) {
|
||||
// 自定义,从输入框获取
|
||||
return binding.urlText.getText().toString().trim();
|
||||
} else {
|
||||
// 使用预设URL或从输入框获取(Nextcloud/ownCloud)
|
||||
String providerUrl = getProviderUrl();
|
||||
if (!TextUtils.isEmpty(providerUrl)) {
|
||||
return providerUrl;
|
||||
} else {
|
||||
// Nextcloud或ownCloud需要用户输入
|
||||
return binding.urlText.getText().toString().trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void onPositive(DialogInterface dialog, int which) {
|
||||
String url = getServerUrl();
|
||||
String username = binding.usernameText.getText().toString().trim();
|
||||
String password = binding.passwordText.getText().toString().trim();
|
||||
boolean autoSync = binding.autoSyncSwitch.isChecked();
|
||||
int interval = Integer.parseInt(binding.syncIntervalText.getText().toString());
|
||||
|
||||
// 验证输入
|
||||
if (TextUtils.isEmpty(url)) {
|
||||
Notify.show("请选择服务提供商或输入服务器地址");
|
||||
return;
|
||||
}
|
||||
if (TextUtils.isEmpty(username)) {
|
||||
Notify.show("请输入用户名");
|
||||
return;
|
||||
}
|
||||
if (TextUtils.isEmpty(password)) {
|
||||
Notify.show("请输入密码");
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
Setting.putWebDAVUrl(url);
|
||||
Setting.putWebDAVUsername(username);
|
||||
Setting.putWebDAVPassword(password);
|
||||
Setting.putWebDAVAutoSync(autoSync);
|
||||
Setting.putWebDAVSyncInterval(interval);
|
||||
|
||||
// 重新加载配置
|
||||
syncManager.reloadConfig();
|
||||
|
||||
// 配置保存后,立即执行一次同步(下载远程数据)
|
||||
// 这样新设备配置后就能立即看到其他设备的历史记录
|
||||
if (syncManager.isConfigured()) {
|
||||
Notify.show("WebDAV配置已保存,正在同步数据...");
|
||||
App.execute(() -> {
|
||||
try {
|
||||
// 先上传本地记录
|
||||
syncManager.uploadHistory();
|
||||
// 再下载远程记录并合并
|
||||
boolean downloadSuccess = syncManager.downloadHistory();
|
||||
App.post(() -> {
|
||||
if (downloadSuccess) {
|
||||
Notify.show("同步完成,已获取远程观看记录");
|
||||
} else {
|
||||
Notify.show("同步完成(本地数据已上传)");
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
App.post(() -> {
|
||||
Notify.show("同步失败,请检查网络连接");
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
Notify.show("WebDAV配置已保存");
|
||||
}
|
||||
|
||||
clearStatus();
|
||||
if (this.dialog != null) {
|
||||
this.dialog.dismiss();
|
||||
}
|
||||
|
||||
// 通知设置界面更新状态(通过RefreshEvent)
|
||||
// 使用App.post确保对话框关闭后再发送事件,让状态能及时更新
|
||||
App.post(() -> RefreshEvent.config());
|
||||
}
|
||||
|
||||
private void onNegative(DialogInterface dialog, int which) {
|
||||
clearStatus();
|
||||
if (this.dialog != null) {
|
||||
this.dialog.dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ public class WebDialog {
|
||||
params.width = (int) (ResUtil.getScreenWidth() * 0.8f);
|
||||
dialog.getWindow().setAttributes(params);
|
||||
dialog.getWindow().setDimAmount(0);
|
||||
// 设置对话框背景为透明,让布局的深色背景显示
|
||||
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
|
||||
dialog.show();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +235,36 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/webdav"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:background="@drawable/selector_item"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="WebDAV"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/webdavStatusText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="end"
|
||||
android:text="未配置"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp"
|
||||
android:alpha="0.7" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/incognito"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxHeight="600dp"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@color/black_90"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingEnd="24dp"
|
||||
android:paddingBottom="16dp">
|
||||
|
||||
<!-- 标题 -->
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="WebDAV 配置"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<!-- 说明文字 -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="请输入您在WebDAV服务提供商(如坚果云)注册的账号和密码(坚果云的密码为应用密码而非登录密码)"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="14sp"
|
||||
android:alpha="0.7"
|
||||
android:lineSpacingMultiplier="1.2" />
|
||||
|
||||
<!-- 服务提供商选择 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="服务提供商"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/providerText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="end"
|
||||
android:text="坚果云"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:padding="12dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 服务器地址(自定义时显示) -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/urlInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:visibility="gone"
|
||||
app:hintEnabled="false"
|
||||
app:boxBackgroundColor="@color/grey_900"
|
||||
app:boxStrokeColor="@color/white_50"
|
||||
app:hintTextColor="@color/white_50"
|
||||
app:boxBackgroundMode="filled">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/urlText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="WebDAV服务器地址(如:https://example.com/webdav)"
|
||||
android:textColor="@color/white"
|
||||
android:textColorHint="@color/white_50"
|
||||
android:background="@color/grey_900"
|
||||
android:imeOptions="actionNext"
|
||||
android:importantForAutofill="no"
|
||||
android:inputType="textUri"
|
||||
android:singleLine="true"
|
||||
android:textSize="18sp" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- 用户名 -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/usernameInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:hintEnabled="false"
|
||||
app:boxBackgroundColor="@color/grey_900"
|
||||
app:boxStrokeColor="@color/white_50"
|
||||
app:hintTextColor="@color/white_50"
|
||||
app:boxBackgroundMode="filled">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/usernameText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="用户名"
|
||||
android:textColor="@color/white"
|
||||
android:textColorHint="@color/white_50"
|
||||
android:background="@color/grey_900"
|
||||
android:imeOptions="actionNext"
|
||||
android:importantForAutofill="no"
|
||||
android:inputType="text"
|
||||
android:singleLine="true"
|
||||
android:textSize="18sp" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- 密码 -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/passwordInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:hintEnabled="false"
|
||||
app:passwordToggleEnabled="true"
|
||||
app:boxBackgroundColor="@color/grey_900"
|
||||
app:boxStrokeColor="@color/white_50"
|
||||
app:hintTextColor="@color/white_50"
|
||||
app:passwordToggleTint="@color/white_50"
|
||||
app:boxBackgroundMode="filled">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/passwordText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="密码"
|
||||
android:textColor="@color/white"
|
||||
android:textColorHint="@color/white_50"
|
||||
android:background="@color/grey_900"
|
||||
android:imeOptions="actionDone"
|
||||
android:importantForAutofill="no"
|
||||
android:inputType="textPassword"
|
||||
android:singleLine="true"
|
||||
android:textSize="18sp" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- 自动同步开关 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="自动同步"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<com.fongmi.android.tv.ui.custom.CustomSwitch
|
||||
android:id="@+id/autoSyncSwitch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 同步间隔 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/syncIntervalContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="同步间隔(分钟)"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/syncIntervalText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="30"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 操作按钮区域 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="end">
|
||||
|
||||
<!-- 测试连接按钮 -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/testButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="测试连接"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp"
|
||||
app:strokeColor="@color/white_50"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton" />
|
||||
|
||||
<!-- 立即同步按钮 -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/syncButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="立即同步"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp"
|
||||
app:strokeColor="@color/white_50"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 状态提示 -->
|
||||
<TextView
|
||||
android:id="@+id/statusText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center"
|
||||
android:text=""
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- 保存和取消按钮 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="end">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/positive"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/selector_text"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true"
|
||||
android:gravity="center"
|
||||
android:singleLine="true"
|
||||
android:text="@string/dialog_positive"
|
||||
android:textColor="@color/button_text"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/negative"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/selector_text"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true"
|
||||
android:gravity="center"
|
||||
android:singleLine="true"
|
||||
android:text="@string/dialog_negative"
|
||||
android:textColor="@color/button_text"
|
||||
android:textSize="18sp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<item name="colorPrimary">@color/primary</item>
|
||||
<item name="colorPrimaryDark">@color/primaryDark</item>
|
||||
<item name="colorAccent">@color/accent</item>
|
||||
<item name="colorControlHighlight">@color/primary</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:windowBackground">@null</item>
|
||||
<item name="android:windowDisablePreview">true</item>
|
||||
|
||||
@@ -12,9 +12,12 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.os.HandlerCompat;
|
||||
|
||||
import com.fongmi.android.tv.event.EventIndex;
|
||||
import com.fongmi.android.tv.Setting;
|
||||
// import com.fongmi.android.tv.event.EventIndex; // 暂时注释,如果不存在则删除
|
||||
import com.fongmi.android.tv.ui.activity.CrashActivity;
|
||||
import com.fongmi.android.tv.utils.CacheCleaner;
|
||||
import com.fongmi.android.tv.utils.UpdateInstaller;
|
||||
import com.fongmi.android.tv.utils.WebDAVSyncManager;
|
||||
import com.fongmi.android.tv.utils.Notify;
|
||||
import com.fongmi.hook.Hook;
|
||||
import com.github.catvod.Init;
|
||||
@@ -43,6 +46,7 @@ public class App extends Application {
|
||||
private final long time;
|
||||
private Hook hook;
|
||||
private final Runnable cleanTask;
|
||||
private final Runnable syncTask;
|
||||
private boolean appJustLaunched;
|
||||
|
||||
public App() {
|
||||
@@ -50,8 +54,11 @@ public class App extends Application {
|
||||
executor = Executors.newFixedThreadPool(Constant.THREAD_POOL);
|
||||
handler = HandlerCompat.createAsync(Looper.getMainLooper());
|
||||
time = System.currentTimeMillis();
|
||||
gson = new Gson();
|
||||
gson = new com.google.gson.GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.create();
|
||||
cleanTask = this::checkCacheClean;
|
||||
syncTask = this::checkWebDAVSync;
|
||||
appJustLaunched = true;
|
||||
}
|
||||
|
||||
@@ -129,7 +136,8 @@ public class App extends Application {
|
||||
Logger.addLogAdapter(getLogAdapter());
|
||||
OkHttp.get().setProxy(Setting.getProxy());
|
||||
OkHttp.get().setDoh(Doh.objectFrom(Setting.getDoh()));
|
||||
EventBus.builder().addIndex(new EventIndex()).installDefaultEventBus();
|
||||
// EventBus.builder().addIndex(new EventIndex()).installDefaultEventBus(); // 暂时注释,如果EventIndex不存在则删除
|
||||
EventBus.getDefault(); // 使用默认EventBus
|
||||
CaocConfig.Builder.create().backgroundMode(CaocConfig.BACKGROUND_MODE_SILENT).errorActivity(CrashActivity.class).apply();
|
||||
// Ensure default notification channel exists for foreground playback service (TV flavor too)
|
||||
Notify.createChannel();
|
||||
@@ -153,6 +161,12 @@ public class App extends Application {
|
||||
if (activity != activity()) setActivity(activity);
|
||||
// 应用回到前台时检查缓存
|
||||
checkCacheClean();
|
||||
// 检查是否有待安装的更新文件(用户从设置页面返回后)
|
||||
checkPendingInstall();
|
||||
// 检查WebDAV自动同步
|
||||
checkWebDAVSync();
|
||||
// 自动检查更新(如果启用)
|
||||
checkAutoUpdate(activity);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -190,6 +204,85 @@ public class App extends Application {
|
||||
post(cleanTask, 30 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有待安装的更新文件
|
||||
* 当用户从设置页面授予安装权限后返回时,自动安装
|
||||
*/
|
||||
private void checkPendingInstall() {
|
||||
UpdateInstaller installer = UpdateInstaller.get();
|
||||
if (installer.hasPendingInstall()) {
|
||||
Logger.d("App: 检测到待安装文件且权限已授予,自动安装");
|
||||
boolean success = installer.autoRetryInstall();
|
||||
if (success) {
|
||||
Notify.show("正在安装更新...");
|
||||
} else {
|
||||
Logger.e("App: 自动安装失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并执行WebDAV自动同步
|
||||
*/
|
||||
private void checkWebDAVSync() {
|
||||
WebDAVSyncManager manager = WebDAVSyncManager.get();
|
||||
if (manager.isConfigured()) {
|
||||
// 应用启动时,如果已配置WebDAV,立即执行一次同步(下载远程数据)
|
||||
// 这样新设备配置后,下次启动应用时就能看到其他设备的历史记录
|
||||
Logger.d("App: WebDAV已配置,准备执行同步");
|
||||
manager.syncHistory(true); // 使用统一的同步方法,包含防重复逻辑
|
||||
|
||||
// 如果启用了自动同步,设置定期同步
|
||||
if (Setting.isWebDAVAutoSync()) {
|
||||
int interval = Setting.getWebDAVSyncInterval();
|
||||
// 延迟执行下次同步,避免影响启动速度
|
||||
post(syncTask, interval * 60 * 1000L);
|
||||
}
|
||||
} else {
|
||||
Logger.d("App: WebDAV未配置,跳过同步");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行WebDAV同步
|
||||
*/
|
||||
private void doWebDAVSync() {
|
||||
App.execute(() -> {
|
||||
WebDAVSyncManager manager = WebDAVSyncManager.get();
|
||||
if (manager.isConfigured()) {
|
||||
Logger.d("App: 开始WebDAV自动同步");
|
||||
manager.syncAll();
|
||||
// 设置下次同步
|
||||
int interval = Setting.getWebDAVSyncInterval();
|
||||
post(syncTask, interval * 60 * 1000L);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动检查更新(如果启用)
|
||||
*/
|
||||
private void checkAutoUpdate(Activity activity) {
|
||||
// 检查是否启用自动更新检查
|
||||
if (!Setting.getAutoUpdateCheck()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否启用更新功能
|
||||
if (!Setting.getUpdate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 延迟一小段时间,避免影响应用启动速度
|
||||
post(() -> {
|
||||
if (activity != null && !activity.isFinishing() && !activity.isDestroyed()) {
|
||||
Logger.d("App: 开始自动检查更新");
|
||||
Updater.create().auto().release().start(activity);
|
||||
}
|
||||
}, 2000); // 延迟2秒
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public PackageManager getPackageManager() {
|
||||
return hook != null ? hook : getBaseContext().getPackageManager();
|
||||
|
||||
@@ -332,4 +332,97 @@ public class Setting {
|
||||
public static void putLiveTabVisible(boolean visible) {
|
||||
Prefers.put("live_tab_visible", visible);
|
||||
}
|
||||
|
||||
// WebDAV 同步配置
|
||||
public static String getWebDAVUrl() {
|
||||
return Prefers.getString("webdav_url");
|
||||
}
|
||||
|
||||
public static void putWebDAVUrl(String url) {
|
||||
Prefers.put("webdav_url", url);
|
||||
}
|
||||
|
||||
public static String getWebDAVUsername() {
|
||||
return Prefers.getString("webdav_username");
|
||||
}
|
||||
|
||||
public static void putWebDAVUsername(String username) {
|
||||
Prefers.put("webdav_username", username);
|
||||
}
|
||||
|
||||
public static String getWebDAVPassword() {
|
||||
return Prefers.getString("webdav_password");
|
||||
}
|
||||
|
||||
public static void putWebDAVPassword(String password) {
|
||||
Prefers.put("webdav_password", password);
|
||||
}
|
||||
|
||||
public static boolean isWebDAVAutoSync() {
|
||||
return Prefers.getBoolean("webdav_auto_sync", false);
|
||||
}
|
||||
|
||||
public static void putWebDAVAutoSync(boolean autoSync) {
|
||||
Prefers.put("webdav_auto_sync", autoSync);
|
||||
}
|
||||
|
||||
public static int getWebDAVSyncInterval() {
|
||||
return Prefers.getInt("webdav_sync_interval", 30); // 默认30分钟
|
||||
}
|
||||
|
||||
public static void putWebDAVSyncInterval(int minutes) {
|
||||
Prefers.put("webdav_sync_interval", minutes);
|
||||
}
|
||||
|
||||
// WebDAV 同步模式:ACCOUNT(账号模式)或 CODE(同步码模式)
|
||||
public static String getWebDAVSyncMode() {
|
||||
return Prefers.getString("webdav_sync_mode", "ACCOUNT");
|
||||
}
|
||||
|
||||
public static void putWebDAVSyncMode(String mode) {
|
||||
Prefers.put("webdav_sync_mode", mode);
|
||||
}
|
||||
|
||||
// 同步码(用于同步码模式)
|
||||
public static String getWebDAVSyncCode() {
|
||||
return Prefers.getString("webdav_sync_code");
|
||||
}
|
||||
|
||||
public static void putWebDAVSyncCode(String code) {
|
||||
Prefers.put("webdav_sync_code", code);
|
||||
}
|
||||
|
||||
// 公开存储URL(用于同步码模式,如GitHub Gist URL)
|
||||
public static String getWebDAVPublicUrl() {
|
||||
return Prefers.getString("webdav_public_url");
|
||||
}
|
||||
|
||||
public static void putWebDAVPublicUrl(String url) {
|
||||
Prefers.put("webdav_public_url", url);
|
||||
}
|
||||
|
||||
// GitHub Gist相关(用于同步码模式)
|
||||
public static String getWebDAVGistId() {
|
||||
return Prefers.getString("webdav_gist_id");
|
||||
}
|
||||
|
||||
public static void putWebDAVGistId(String gistId) {
|
||||
Prefers.put("webdav_gist_id", gistId);
|
||||
}
|
||||
|
||||
public static String getWebDAVGistRawUrl() {
|
||||
return Prefers.getString("webdav_gist_raw_url");
|
||||
}
|
||||
|
||||
public static void putWebDAVGistRawUrl(String url) {
|
||||
Prefers.put("webdav_gist_raw_url", url);
|
||||
}
|
||||
|
||||
public static String getWebDAVGistToken() {
|
||||
return Prefers.getString("webdav_gist_token");
|
||||
}
|
||||
|
||||
public static void putWebDAVGistToken(String token) {
|
||||
Prefers.put("webdav_gist_token", token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,10 @@ public class History {
|
||||
return AppDatabase.get().getHistoryDao().find(cid, System.currentTimeMillis() - Constant.HISTORY_TIME);
|
||||
}
|
||||
|
||||
public static List<History> getAll() {
|
||||
return AppDatabase.get().getHistoryDao().findAllRecent(System.currentTimeMillis() - Constant.HISTORY_TIME);
|
||||
}
|
||||
|
||||
public static History find(String key) {
|
||||
return AppDatabase.get().getHistoryDao().find(VodConfig.getCid(), key);
|
||||
}
|
||||
@@ -272,8 +276,15 @@ public class History {
|
||||
}
|
||||
|
||||
public void update() {
|
||||
merge(find(), false);
|
||||
save();
|
||||
try {
|
||||
com.github.catvod.utils.Logger.d("History.update: 开始更新观看记录 key=" + getKey());
|
||||
merge(find(), false);
|
||||
save();
|
||||
com.github.catvod.utils.Logger.d("History.update: 更新成功");
|
||||
} catch (Exception e) {
|
||||
com.github.catvod.utils.Logger.e("History.update: 更新失败 - " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public History update(int cid) {
|
||||
@@ -287,6 +298,7 @@ public class History {
|
||||
}
|
||||
|
||||
public History save() {
|
||||
com.github.catvod.utils.Logger.d("History.save: key=" + getKey() + ", vodName=" + getVodName());
|
||||
AppDatabase.get().getHistoryDao().insertOrUpdate(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -105,12 +105,37 @@ public class Site implements Parcelable {
|
||||
|
||||
public static Site objectFrom(JsonElement element) {
|
||||
try {
|
||||
return App.gson().fromJson(element, Site.class);
|
||||
Site site = App.gson().fromJson(element, Site.class);
|
||||
// 尝试修复可能的编码问题
|
||||
if (site != null && site.getKey() != null) {
|
||||
site.setKey(fixEncoding(site.getKey()));
|
||||
if (site.getName() != null) {
|
||||
site.setName(fixEncoding(site.getName()));
|
||||
}
|
||||
}
|
||||
return site;
|
||||
} catch (Exception e) {
|
||||
return new Site();
|
||||
}
|
||||
}
|
||||
|
||||
private static String fixEncoding(String str) {
|
||||
if (str == null || str.isEmpty()) return str;
|
||||
try {
|
||||
// 检查是否包含乱码字符(替换字符 U+FFFD)
|
||||
if (str.indexOf('\uFFFD') >= 0) {
|
||||
// 尝试用ISO-8859-1重新解码为UTF-8
|
||||
byte[] bytes = str.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1);
|
||||
String fixed = new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
|
||||
com.github.catvod.utils.Logger.d("Site.fixEncoding: 修复编码 '" + str + "' -> '" + fixed + "'");
|
||||
return fixed;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
com.github.catvod.utils.Logger.e("Site.fixEncoding: 修复失败 - " + e.getMessage());
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
public static Site get(String key) {
|
||||
Site site = new Site();
|
||||
site.setKey(key);
|
||||
@@ -133,6 +158,13 @@ public class Site implements Parcelable {
|
||||
|
||||
public void setKey(@NonNull String key) {
|
||||
this.key = key;
|
||||
// 检查key中是否有异常字符
|
||||
for (int i = 0; i < key.length(); i++) {
|
||||
char c = key.charAt(i);
|
||||
if (c == 0xFFFD || c < 0x20 || (c >= 0x7F && c < 0xA0)) {
|
||||
com.github.catvod.utils.Logger.w("Site.setKey: 检测到异常字符 at position " + i + ": U+" + String.format("%04X", (int)c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
@@ -141,6 +173,15 @@ public class Site implements Parcelable {
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
// 检查name中是否有异常字符
|
||||
if (name != null) {
|
||||
for (int i = 0; i < name.length(); i++) {
|
||||
char c = name.charAt(i);
|
||||
if (c == 0xFFFD || c < 0x20 || (c >= 0x7F && c < 0xA0)) {
|
||||
com.github.catvod.utils.Logger.w("Site.setName: 检测到异常字符 at position " + i + ": U+" + String.format("%04X", (int)c) + " in name: " + name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String getApi() {
|
||||
|
||||
@@ -16,6 +16,9 @@ public abstract class HistoryDao extends BaseDao<History> {
|
||||
@Query("SELECT * FROM History WHERE cid = :cid AND createTime >= :createTime ORDER BY createTime DESC")
|
||||
public abstract List<History> find(int cid, long createTime);
|
||||
|
||||
@Query("SELECT * FROM History WHERE createTime >= :createTime ORDER BY createTime DESC")
|
||||
public abstract List<History> findAllRecent(long createTime);
|
||||
|
||||
@Query("SELECT * FROM History WHERE cid = :cid AND `key` = :key")
|
||||
public abstract History find(int cid, String key);
|
||||
|
||||
|
||||
@@ -18,53 +18,219 @@ public class Download {
|
||||
|
||||
private final File file;
|
||||
private final String url;
|
||||
private final String fallbackUrl;
|
||||
private Callback callback;
|
||||
private static final int MAX_RETRY_COUNT = 3; // 最大重试次数
|
||||
|
||||
public static Download create(String url, File file) {
|
||||
return create(url, file, null);
|
||||
}
|
||||
|
||||
public static Download create(String url, File file, Callback callback) {
|
||||
return new Download(url, file, callback);
|
||||
return create(url, file, null, callback);
|
||||
}
|
||||
|
||||
public static Download create(String url, File file, String fallbackUrl, Callback callback) {
|
||||
return new Download(url, file, fallbackUrl, callback);
|
||||
}
|
||||
|
||||
public Download(String url, File file, Callback callback) {
|
||||
this(url, file, null, callback);
|
||||
}
|
||||
|
||||
public Download(String url, File file, String fallbackUrl, Callback callback) {
|
||||
this.url = url;
|
||||
this.file = file;
|
||||
this.fallbackUrl = fallbackUrl;
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (url == null || url.isEmpty()) {
|
||||
if (callback != null) {
|
||||
App.post(() -> callback.error("下载URL为空"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (url.startsWith("file")) return;
|
||||
if (callback == null) doInBackground();
|
||||
else App.execute(this::doInBackground);
|
||||
if (file == null) {
|
||||
if (callback != null) {
|
||||
App.post(() -> callback.error("保存文件路径为空"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (callback == null) {
|
||||
// 无回调时,直接执行(同步)
|
||||
doInBackgroundWithFallback();
|
||||
} else {
|
||||
// 有回调时,异步执行
|
||||
App.execute(this::doInBackgroundWithFallback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 带智能回退的下载方法
|
||||
* 先尝试主URL(通常是jsDelivr CDN),失败后回退到备用URL
|
||||
*/
|
||||
private void doInBackgroundWithFallback() {
|
||||
// 先尝试主URL
|
||||
boolean mainSuccess = doInBackground(url, "主URL");
|
||||
if (mainSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 主URL失败,如果有回退URL,尝试回退URL
|
||||
if (fallbackUrl != null && !fallbackUrl.equals(url)) {
|
||||
Logger.d("Download: 主URL下载失败,回退到备用URL: " + fallbackUrl);
|
||||
doInBackground(fallbackUrl, "备用URL");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定URL下载文件(带重试机制)
|
||||
*/
|
||||
private boolean doInBackground(String downloadUrl, String source) {
|
||||
Exception lastException = null;
|
||||
|
||||
for (int attempt = 1; attempt <= MAX_RETRY_COUNT; attempt++) {
|
||||
try {
|
||||
if (callback != null) {
|
||||
App.post(() -> callback.progress(0));
|
||||
}
|
||||
|
||||
boolean success = downloadWithUrl(downloadUrl, source, attempt);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
lastException = e;
|
||||
Logger.w("Download: 下载失败 (来源: " + source + ", 尝试 " + attempt + "/" + MAX_RETRY_COUNT + "): " + e.getMessage());
|
||||
|
||||
// 如果不是最后一次尝试,等待后重试
|
||||
if (attempt < MAX_RETRY_COUNT) {
|
||||
try {
|
||||
long retryDelay = 500L * attempt; // 递增延迟
|
||||
Thread.sleep(retryDelay);
|
||||
Logger.d("Download: 等待 " + retryDelay + "ms 后重试...");
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有尝试都失败
|
||||
if (callback != null && lastException != null) {
|
||||
String errorMsg = lastException.getMessage();
|
||||
App.post(() -> callback.error(errorMsg != null ? errorMsg : "下载失败"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定URL下载文件
|
||||
*/
|
||||
private boolean downloadWithUrl(String downloadUrl, String source, int attempt) throws Exception {
|
||||
if (downloadUrl == null || downloadUrl.isEmpty()) {
|
||||
throw new Exception("下载URL为空");
|
||||
}
|
||||
if (file == null) {
|
||||
throw new Exception("保存文件路径为空");
|
||||
}
|
||||
|
||||
Response res = null;
|
||||
InputStream inputStream = null;
|
||||
try {
|
||||
res = OkHttp.newCall(downloadUrl, downloadUrl).execute();
|
||||
|
||||
// 检查HTTP响应状态码
|
||||
if (!res.isSuccessful()) {
|
||||
throw new Exception("下载失败: HTTP " + res.code() + " " + (res.message() != null ? res.message() : "未知错误"));
|
||||
}
|
||||
|
||||
// 检查响应体是否存在
|
||||
if (res.body() == null) {
|
||||
throw new Exception("下载失败: 响应体为空");
|
||||
}
|
||||
|
||||
// 获取输入流
|
||||
inputStream = res.body().byteStream();
|
||||
if (inputStream == null) {
|
||||
throw new Exception("下载失败: 无法获取输入流");
|
||||
}
|
||||
|
||||
Path.create(file);
|
||||
|
||||
// 获取文件大小,如果无法获取则使用-1表示未知大小
|
||||
String contentLengthStr = res.header(HttpHeaders.CONTENT_LENGTH);
|
||||
long expectedLength = -1;
|
||||
if (contentLengthStr != null && !contentLengthStr.isEmpty()) {
|
||||
try {
|
||||
expectedLength = Long.parseLong(contentLengthStr);
|
||||
if (expectedLength < 0) {
|
||||
expectedLength = -1;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
Logger.w("Download: 无法解析Content-Length: " + contentLengthStr);
|
||||
expectedLength = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
download(inputStream, expectedLength);
|
||||
|
||||
// 验证下载的文件(如果知道预期大小)
|
||||
if (expectedLength > 0 && !verifyDownloadedFile(file, expectedLength)) {
|
||||
throw new Exception("下载的文件可能已损坏,请重试");
|
||||
}
|
||||
|
||||
Logger.d("Download: 下载成功 (来源: " + source + ", 尝试 " + attempt + "/" + MAX_RETRY_COUNT + ")");
|
||||
if (callback != null) {
|
||||
App.post(() -> callback.success(file));
|
||||
}
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
// 如果下载失败,删除可能不完整的文件
|
||||
if (file != null && file.exists()) {
|
||||
try {
|
||||
file.delete();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
// 关闭输入流
|
||||
if (inputStream != null) {
|
||||
try {
|
||||
inputStream.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
// 关闭响应
|
||||
if (res != null) {
|
||||
try {
|
||||
res.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void cancel() {
|
||||
OkHttp.cancel(url);
|
||||
if (fallbackUrl != null) {
|
||||
OkHttp.cancel(fallbackUrl);
|
||||
}
|
||||
Path.clear(file);
|
||||
callback = null;
|
||||
}
|
||||
|
||||
private void doInBackground() {
|
||||
try (Response res = OkHttp.newCall(url, url).execute()) {
|
||||
Path.create(file);
|
||||
long expectedLength = Long.parseLong(res.header(HttpHeaders.CONTENT_LENGTH, "0"));
|
||||
download(res.body().byteStream(), expectedLength);
|
||||
|
||||
// 验证下载的文件
|
||||
if (!verifyDownloadedFile(file, expectedLength)) {
|
||||
App.post(() -> {if (callback != null) callback.error("下载的文件可能已损坏,请重试");});
|
||||
return;
|
||||
}
|
||||
|
||||
App.post(() -> {if (callback != null) callback.success(file);});
|
||||
} catch (Exception e) {
|
||||
App.post(() -> {if (callback != null) callback.error(e.getMessage());});
|
||||
}
|
||||
}
|
||||
|
||||
private void download(InputStream is, long length) throws Exception {
|
||||
if (is == null) {
|
||||
throw new Exception("输入流为空,无法下载");
|
||||
}
|
||||
|
||||
try (BufferedInputStream input = new BufferedInputStream(is); FileOutputStream os = new FileOutputStream(file)) {
|
||||
byte[] buffer = new byte[4096];
|
||||
int readBytes;
|
||||
@@ -72,39 +238,56 @@ public class Download {
|
||||
while ((readBytes = input.read(buffer)) != -1) {
|
||||
totalBytes += readBytes;
|
||||
os.write(buffer, 0, readBytes);
|
||||
int progress = (int) (totalBytes / length * 100.0);
|
||||
App.post(() -> {if (callback != null) callback.progress(progress);});
|
||||
|
||||
// 只有当知道文件大小时才计算进度
|
||||
if (length > 0 && callback != null) {
|
||||
int progress = (int) (totalBytes * 100.0 / length);
|
||||
final int finalProgress = Math.min(progress, 100); // 确保不超过100%,并设为final
|
||||
App.post(() -> callback.progress(finalProgress));
|
||||
} else if (callback != null) {
|
||||
// 不知道文件大小时,显示不确定进度
|
||||
App.post(() -> callback.progress(-1));
|
||||
}
|
||||
}
|
||||
|
||||
// 下载完成后,如果不知道文件大小,显示100%
|
||||
if (length <= 0 && callback != null) {
|
||||
App.post(() -> callback.progress(100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean verifyDownloadedFile(File file, long expectedLength) {
|
||||
try {
|
||||
// 检查文件大小
|
||||
if (file.length() != expectedLength) {
|
||||
// 如果文件不存在或为空,验证失败
|
||||
if (file == null || !file.exists() || file.length() == 0) {
|
||||
Logger.e("File verification failed: file does not exist or is empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果知道预期大小,检查文件大小是否匹配
|
||||
if (expectedLength > 0 && file.length() != expectedLength) {
|
||||
Logger.e("File size mismatch: expected " + expectedLength + ", actual " + file.length());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查APK文件头 (ZIP文件头)
|
||||
if (file.length() < 4) return false;
|
||||
if (file.length() < 4) {
|
||||
Logger.e("File too small: " + file.length() + " bytes");
|
||||
return false;
|
||||
}
|
||||
|
||||
try (FileInputStream fis = new FileInputStream(file)) {
|
||||
byte[] header = new byte[4];
|
||||
fis.read(header);
|
||||
// ZIP文件头应该是 0x504B0304 (PK..)
|
||||
if (header[0] != 0x50 || header[1] != 0x4B || header[2] != 0x03 || header[3] != 0x04) {
|
||||
Logger.e("Invalid APK file header");
|
||||
int bytesRead = fis.read(header);
|
||||
if (bytesRead < 4) {
|
||||
Logger.e("Cannot read file header");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 额外验证:检查APK文件是否完整
|
||||
// 尝试读取ZIP文件结构
|
||||
fis.getChannel().position(0);
|
||||
byte[] buffer = new byte[1024];
|
||||
int bytesRead = fis.read(buffer);
|
||||
if (bytesRead < 4) {
|
||||
Logger.e("APK file too small or corrupted");
|
||||
// ZIP文件头应该是 0x504B0304 (PK..)
|
||||
if (header[0] != 0x50 || header[1] != 0x4B || header[2] != 0x03 || header[3] != 0x04) {
|
||||
Logger.e("Invalid APK file header: " + String.format("%02X %02X %02X %02X", header[0], header[1], header[2], header[3]));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -113,6 +296,7 @@ public class Download {
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Logger.e("File verification failed: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package com.fongmi.android.tv.utils;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.fongmi.android.tv.Setting;
|
||||
|
||||
import com.fongmi.android.tv.App;
|
||||
import com.fongmi.android.tv.bean.Backup;
|
||||
import com.fongmi.android.tv.bean.History;
|
||||
import com.fongmi.android.tv.db.AppDatabase;
|
||||
import com.github.catvod.net.OkHttp;
|
||||
import com.github.catvod.utils.Logger;
|
||||
import com.github.catvod.utils.Prefers;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
/**
|
||||
* 同步码管理器(无需WebDAV账号)
|
||||
* 使用公开的HTTP存储服务,通过同步码区分用户
|
||||
*
|
||||
* 方案:使用GitHub Gist作为存储
|
||||
* - 用户创建一个公开的GitHub Gist
|
||||
* - 通过同步码作为文件名的一部分来区分不同用户
|
||||
* - 所有知道同步码的设备可以共享数据
|
||||
*/
|
||||
public class SyncCodeManager {
|
||||
|
||||
private static final String HISTORY_FILE_PREFIX = "xmbox_history_";
|
||||
private static final String SETTINGS_FILE_PREFIX = "xmbox_settings_";
|
||||
private static final String BACKUP_FILE_PREFIX = "xmbox_backup_";
|
||||
private static final String FILE_SUFFIX = ".json";
|
||||
|
||||
private static SyncCodeManager instance;
|
||||
private String syncCode;
|
||||
private String gistId; // GitHub Gist ID
|
||||
private String gistToken; // GitHub Personal Access Token(用于上传,可选)
|
||||
|
||||
public static SyncCodeManager get() {
|
||||
if (instance == null) {
|
||||
instance = new SyncCodeManager();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
private SyncCodeManager() {
|
||||
loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载配置
|
||||
*/
|
||||
private void loadConfig() {
|
||||
syncCode = Setting.getWebDAVSyncCode();
|
||||
gistId = Setting.getWebDAVGistId();
|
||||
gistToken = Setting.getWebDAVGistToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已配置
|
||||
*/
|
||||
public boolean isConfigured() {
|
||||
return !TextUtils.isEmpty(syncCode) && !TextUtils.isEmpty(gistId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成同步码
|
||||
* @return 8位随机同步码(字母+数字)
|
||||
*/
|
||||
public static String generateSyncCode() {
|
||||
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
Random random = new Random();
|
||||
StringBuilder code = new StringBuilder();
|
||||
for (int i = 0; i < 8; i++) {
|
||||
code.append(chars.charAt(random.nextInt(chars.length())));
|
||||
}
|
||||
return code.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件URL(GitHub Gist raw URL)
|
||||
*/
|
||||
private String getFileUrl(String prefix) {
|
||||
// GitHub Gist raw URL格式:
|
||||
// https://gist.githubusercontent.com/{username}/{gist_id}/raw/{filename}
|
||||
// 文件名格式:{prefix}{syncCode}.json
|
||||
// 例如:xmbox_history_ABC123XYZ.json
|
||||
|
||||
// 如果用户提供了完整的Gist raw URL
|
||||
String gistRawUrl = Setting.getWebDAVGistRawUrl();
|
||||
if (!TextUtils.isEmpty(gistRawUrl)) {
|
||||
String filename = prefix + syncCode + FILE_SUFFIX;
|
||||
return gistRawUrl + "/" + filename;
|
||||
}
|
||||
|
||||
// 否则需要从Gist ID构建(需要知道username)
|
||||
// 这里简化处理,要求用户提供完整的raw URL
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传观看记录
|
||||
*/
|
||||
public boolean uploadHistory() {
|
||||
if (!isConfigured()) {
|
||||
Logger.e("SyncCode: 未配置,无法上传观看记录");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取所有观看记录
|
||||
List<History> historyList = AppDatabase.get().getHistoryDao().findAll();
|
||||
String json = App.gson().toJson(historyList);
|
||||
|
||||
// 上传到GitHub Gist
|
||||
String fileUrl = getFileUrl(HISTORY_FILE_PREFIX);
|
||||
if (fileUrl == null) {
|
||||
Logger.e("SyncCode: 无法构建文件URL,请配置Gist Raw URL");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 使用GitHub Gist API更新文件
|
||||
boolean success = updateGistFile(fileUrl, json);
|
||||
if (success) {
|
||||
Logger.d("SyncCode: 观看记录上传成功,共 " + historyList.size() + " 条");
|
||||
}
|
||||
return success;
|
||||
} catch (Exception e) {
|
||||
Logger.e("SyncCode: 观看记录上传失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载观看记录
|
||||
*/
|
||||
public boolean downloadHistory() {
|
||||
if (!isConfigured()) {
|
||||
Logger.e("SyncCode: 未配置,无法下载观看记录");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
String fileUrl = getFileUrl(HISTORY_FILE_PREFIX);
|
||||
if (fileUrl == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 从GitHub Gist下载文件
|
||||
String json = downloadGistFile(fileUrl);
|
||||
if (TextUtils.isEmpty(json)) {
|
||||
Logger.d("SyncCode: 观看记录文件不存在,跳过下载");
|
||||
return false;
|
||||
}
|
||||
|
||||
Type listType = new TypeToken<List<History>>(){}.getType();
|
||||
List<History> remoteHistoryList = App.gson().fromJson(json, listType);
|
||||
|
||||
// 智能合并(与WebDAV相同的逻辑)
|
||||
if (!remoteHistoryList.isEmpty()) {
|
||||
List<History> localHistoryList = AppDatabase.get().getHistoryDao().findAll();
|
||||
|
||||
Map<String, History> localMap = new HashMap<>();
|
||||
for (History local : localHistoryList) {
|
||||
localMap.put(local.getKey(), local);
|
||||
}
|
||||
|
||||
List<History> toInsert = new java.util.ArrayList<>();
|
||||
List<History> toUpdate = new java.util.ArrayList<>();
|
||||
|
||||
for (History remote : remoteHistoryList) {
|
||||
History local = localMap.get(remote.getKey());
|
||||
|
||||
if (local == null) {
|
||||
toInsert.add(remote);
|
||||
} else {
|
||||
if (remote.getCreateTime() > local.getCreateTime()) {
|
||||
toUpdate.add(remote);
|
||||
} else if (remote.getCreateTime() == local.getCreateTime() && remote.getPosition() > local.getPosition()) {
|
||||
toUpdate.add(remote);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!toInsert.isEmpty()) {
|
||||
AppDatabase.get().getHistoryDao().insert(toInsert);
|
||||
Logger.d("SyncCode: 新增 " + toInsert.size() + " 条观看记录");
|
||||
}
|
||||
if (!toUpdate.isEmpty()) {
|
||||
AppDatabase.get().getHistoryDao().update(toUpdate);
|
||||
Logger.d("SyncCode: 更新 " + toUpdate.size() + " 条观看记录");
|
||||
}
|
||||
|
||||
Logger.d("SyncCode: 观看记录合并完成");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
Logger.e("SyncCode: 观看记录下载失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从GitHub Gist下载文件
|
||||
*/
|
||||
private String downloadGistFile(String fileUrl) {
|
||||
try {
|
||||
Response response = OkHttp.newCall(fileUrl).execute();
|
||||
if (response.isSuccessful()) {
|
||||
return response.body().string();
|
||||
}
|
||||
return "";
|
||||
} catch (Exception e) {
|
||||
Logger.e("SyncCode: 下载文件失败: " + e.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新GitHub Gist文件
|
||||
* 注意:GitHub Gist需要通过API更新,不能直接PUT文件
|
||||
*/
|
||||
private boolean updateGistFile(String fileUrl, String content) {
|
||||
// GitHub Gist需要通过REST API更新
|
||||
// 这里简化处理,实际需要调用GitHub Gist API
|
||||
// POST https://api.github.com/gists/{gist_id}
|
||||
|
||||
if (TextUtils.isEmpty(gistToken)) {
|
||||
Logger.w("SyncCode: 未提供GitHub Token,无法上传(Gist需要Token才能更新)");
|
||||
// 可以提示用户:同步码模式需要GitHub Token才能上传
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 构建GitHub Gist API请求
|
||||
String apiUrl = "https://api.github.com/gists/" + gistId;
|
||||
String filename = HISTORY_FILE_PREFIX + syncCode + FILE_SUFFIX;
|
||||
|
||||
// 构建请求体
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
Map<String, Object> files = new HashMap<>();
|
||||
Map<String, String> fileContent = new HashMap<>();
|
||||
fileContent.put("content", content);
|
||||
files.put(filename, fileContent);
|
||||
requestBody.put("files", files);
|
||||
|
||||
String jsonBody = App.gson().toJson(requestBody);
|
||||
|
||||
RequestBody body = RequestBody.create(
|
||||
MediaType.parse("application/json; charset=utf-8"),
|
||||
jsonBody
|
||||
);
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(apiUrl)
|
||||
.method("PATCH", body)
|
||||
.header("Authorization", "Bearer " + gistToken)
|
||||
.header("Accept", "application/vnd.github.v3+json")
|
||||
.build();
|
||||
|
||||
Response response = OkHttp.client().newCall(request).execute();
|
||||
boolean success = response.isSuccessful();
|
||||
response.close();
|
||||
|
||||
return success;
|
||||
} catch (Exception e) {
|
||||
Logger.e("SyncCode: 更新Gist失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步观看记录
|
||||
*/
|
||||
public boolean syncHistory() {
|
||||
if (!isConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
App.execute(() -> {
|
||||
uploadHistory();
|
||||
downloadHistory();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新加载配置
|
||||
*/
|
||||
public void reloadConfig() {
|
||||
loadConfig();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.fongmi.android.tv.utils;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.provider.Settings;
|
||||
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
import com.fongmi.android.tv.App;
|
||||
import com.github.catvod.utils.Logger;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* Android 更新安装器
|
||||
* 处理安装权限检查和请求,以及APK安装
|
||||
*/
|
||||
public class UpdateInstaller {
|
||||
|
||||
private static UpdateInstaller instance;
|
||||
private File pendingInstallFile; // 待安装的文件
|
||||
|
||||
public static UpdateInstaller get() {
|
||||
if (instance == null) {
|
||||
instance = new UpdateInstaller();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有安装权限
|
||||
* @return 是否有安装权限
|
||||
*/
|
||||
public boolean hasInstallPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
return App.get().getPackageManager().canRequestPackageInstalls();
|
||||
}
|
||||
return true; // Android 8.0以下不需要此权限
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求安装权限(打开设置页面)
|
||||
*/
|
||||
public void requestInstallPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
try {
|
||||
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES);
|
||||
intent.setData(Uri.parse("package:" + App.get().getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
App.get().startActivity(intent);
|
||||
Logger.d("UpdateInstaller: 已打开安装权限设置页面");
|
||||
} catch (Exception e) {
|
||||
Logger.e("UpdateInstaller: 无法打开安装权限设置页面: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装 APK 文件
|
||||
* @param apkFile APK 文件
|
||||
* @return 是否成功启动安装流程
|
||||
*/
|
||||
public boolean install(File apkFile) {
|
||||
return install(apkFile, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装 APK 文件
|
||||
* @param apkFile APK 文件
|
||||
* @param checkPermission 是否检查权限(如果为false,即使没有权限也会尝试安装)
|
||||
* @return 是否成功启动安装流程
|
||||
*/
|
||||
public boolean install(File apkFile, boolean checkPermission) {
|
||||
try {
|
||||
// Android 8.0+ 需要请求安装权限
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
if (checkPermission && !hasInstallPermission()) {
|
||||
// 没有权限,保存待安装的文件,返回 false,由调用方处理
|
||||
this.pendingInstallFile = apkFile;
|
||||
Logger.d("UpdateInstaller: 没有安装权限,已保存待安装文件: " + apkFile.getAbsolutePath());
|
||||
return false; // 返回false表示需要权限,但不表示失败
|
||||
}
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!apkFile.exists() || !apkFile.isFile()) {
|
||||
Logger.e("UpdateInstaller: APK文件不存在或不是文件: " + apkFile.getAbsolutePath());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 使用 FileProvider 获取 URI
|
||||
String authority = App.get().getPackageName() + ".provider";
|
||||
Uri apkUri = FileProvider.getUriForFile(App.get(), authority, apkFile);
|
||||
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW);
|
||||
intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
App.get().startActivity(intent);
|
||||
Logger.d("UpdateInstaller: 已启动安装程序");
|
||||
this.pendingInstallFile = null; // 清除待安装文件
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Logger.e("UpdateInstaller: 安装失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待安装的文件
|
||||
* @return 待安装的文件,如果没有则返回null
|
||||
*/
|
||||
public File getPendingInstallFile() {
|
||||
return pendingInstallFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有待安装的文件且权限已授予
|
||||
* 用于应用恢复时自动检测
|
||||
*/
|
||||
public boolean hasPendingInstall() {
|
||||
return pendingInstallFile != null && pendingInstallFile.exists() && hasInstallPermission();
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动重试安装(用于应用恢复时)
|
||||
*/
|
||||
public boolean autoRetryInstall() {
|
||||
if (hasPendingInstall()) {
|
||||
File file = pendingInstallFile;
|
||||
pendingInstallFile = null; // 清除待安装文件
|
||||
return install(file, false); // 不检查权限,因为已经检查过了
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除待安装的文件
|
||||
*/
|
||||
public void clearPendingInstall() {
|
||||
this.pendingInstallFile = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,1010 @@
|
||||
package com.fongmi.android.tv.utils;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.fongmi.android.tv.App;
|
||||
import com.fongmi.android.tv.bean.Backup;
|
||||
import com.fongmi.android.tv.bean.History;
|
||||
import com.fongmi.android.tv.db.AppDatabase;
|
||||
import com.fongmi.android.tv.event.RefreshEvent;
|
||||
import com.github.catvod.utils.Logger;
|
||||
import com.github.catvod.utils.Prefers;
|
||||
import com.google.gson.Gson;
|
||||
import com.thegrizzlylabs.sardineandroid.Sardine;
|
||||
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.fongmi.android.tv.Setting;
|
||||
|
||||
/**
|
||||
* WebDAV同步管理器
|
||||
* 用于同步观看记录和设置到WebDAV服务器
|
||||
*/
|
||||
public class WebDAVSyncManager {
|
||||
|
||||
private static final String HISTORY_FILE = "xmbox_history.json";
|
||||
private static final String SETTINGS_FILE = "xmbox_settings.json";
|
||||
private static final String BACKUP_FILE = "xmbox_backup.json";
|
||||
|
||||
// 同步模式:ACCOUNT(账号模式)或 CODE(同步码模式)
|
||||
public enum SyncMode {
|
||||
ACCOUNT, // 使用WebDAV账号
|
||||
CODE // 使用同步码(无需账号)
|
||||
}
|
||||
|
||||
private static WebDAVSyncManager instance;
|
||||
private Sardine sardine;
|
||||
private String baseUrl;
|
||||
private String username;
|
||||
private String password;
|
||||
private String syncCode; // 同步码
|
||||
private SyncMode syncMode = SyncMode.ACCOUNT; // 默认使用账号模式
|
||||
private volatile boolean isSyncing = false; // 同步锁,防止重复同步
|
||||
|
||||
public static WebDAVSyncManager get() {
|
||||
if (instance == null) {
|
||||
instance = new WebDAVSyncManager();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
private WebDAVSyncManager() {
|
||||
loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载WebDAV配置
|
||||
*/
|
||||
private void loadConfig() {
|
||||
// 检查同步模式
|
||||
String modeStr = Setting.getWebDAVSyncMode();
|
||||
if ("CODE".equals(modeStr)) {
|
||||
syncMode = SyncMode.CODE;
|
||||
syncCode = Setting.getWebDAVSyncCode();
|
||||
// 同步码模式:使用公开的WebDAV服务器(如jsDelivr CDN的GitHub仓库)
|
||||
// 或者使用其他公开存储服务
|
||||
baseUrl = getPublicStorageUrl();
|
||||
username = null;
|
||||
password = null;
|
||||
} else {
|
||||
syncMode = SyncMode.ACCOUNT;
|
||||
baseUrl = Setting.getWebDAVUrl();
|
||||
username = Setting.getWebDAVUsername();
|
||||
password = Setting.getWebDAVPassword();
|
||||
}
|
||||
|
||||
if (syncMode == SyncMode.ACCOUNT) {
|
||||
// 账号模式:需要账号密码
|
||||
if (!TextUtils.isEmpty(baseUrl) && !TextUtils.isEmpty(username) && !TextUtils.isEmpty(password)) {
|
||||
try {
|
||||
sardine = new OkHttpSardine();
|
||||
sardine.setCredentials(username, password);
|
||||
Logger.d("WebDAV: 账号模式配置已加载");
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 初始化失败: " + e.getMessage());
|
||||
sardine = null;
|
||||
}
|
||||
} else {
|
||||
sardine = null;
|
||||
}
|
||||
} else {
|
||||
// 同步码模式:使用公开存储,无需认证
|
||||
if (!TextUtils.isEmpty(syncCode) && !TextUtils.isEmpty(baseUrl)) {
|
||||
try {
|
||||
sardine = new OkHttpSardine();
|
||||
// 公开存储不需要认证
|
||||
Logger.d("WebDAV: 同步码模式配置已加载,同步码: " + syncCode);
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 初始化失败: " + e.getMessage());
|
||||
sardine = null;
|
||||
}
|
||||
} else {
|
||||
sardine = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公开存储URL(同步码模式使用)
|
||||
* 方案:使用GitHub Gist作为公开存储
|
||||
* 用户需要:
|
||||
* 1. 创建一个GitHub Gist(公开)
|
||||
* 2. 获取Gist的raw URL
|
||||
* 3. 输入同步码
|
||||
*
|
||||
* 文件路径格式:{gist_raw_url}/{syncCode}/xmbox_history.json
|
||||
*/
|
||||
private String getPublicStorageUrl() {
|
||||
// 获取用户配置的GitHub Gist raw URL
|
||||
// 例如:https://gist.githubusercontent.com/username/gist_id/raw/
|
||||
String gistBaseUrl = Setting.getWebDAVPublicUrl();
|
||||
|
||||
if (TextUtils.isEmpty(gistBaseUrl)) {
|
||||
// 如果没有配置,返回null(需要用户配置)
|
||||
return null;
|
||||
}
|
||||
|
||||
// 将同步码添加到路径中,作为子目录
|
||||
// 例如:https://gist.githubusercontent.com/username/gist_id/raw/ABC123XYZ/
|
||||
if (!TextUtils.isEmpty(syncCode)) {
|
||||
String url = gistBaseUrl.endsWith("/") ? gistBaseUrl : gistBaseUrl + "/";
|
||||
return url + syncCode + "/";
|
||||
}
|
||||
|
||||
return gistBaseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查WebDAV是否已配置
|
||||
*/
|
||||
public boolean isConfigured() {
|
||||
if (syncMode == SyncMode.CODE) {
|
||||
// 同步码模式:需要同步码和公开存储URL
|
||||
return sardine != null && !TextUtils.isEmpty(baseUrl) && !TextUtils.isEmpty(syncCode);
|
||||
} else {
|
||||
// 账号模式:需要账号密码和URL
|
||||
return sardine != null && !TextUtils.isEmpty(baseUrl) && !TextUtils.isEmpty(username) && !TextUtils.isEmpty(password);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成同步码
|
||||
* @return 8位随机同步码(字母+数字)
|
||||
*/
|
||||
public static String generateSyncCode() {
|
||||
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
java.util.Random random = new java.util.Random();
|
||||
StringBuilder code = new StringBuilder();
|
||||
for (int i = 0; i < 8; i++) {
|
||||
code.append(chars.charAt(random.nextInt(chars.length())));
|
||||
}
|
||||
return code.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试WebDAV连接
|
||||
* @return 测试结果,包含成功状态和错误信息
|
||||
*/
|
||||
public TestResult testConnectionWithMessage() {
|
||||
if (!isConfigured()) {
|
||||
return new TestResult(false, "WebDAV未配置,请检查URL、用户名和密码");
|
||||
}
|
||||
|
||||
try {
|
||||
// 确保baseUrl以/结尾
|
||||
String testUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
||||
Logger.d("WebDAV: 测试连接URL: " + testUrl);
|
||||
Logger.d("WebDAV: 用户名: " + (username != null ? username : "null"));
|
||||
|
||||
// 尝试列出目录
|
||||
sardine.list(testUrl);
|
||||
Logger.d("WebDAV: 连接测试成功,可以访问目录");
|
||||
return new TestResult(true, "连接成功!");
|
||||
} catch (java.io.IOException e) {
|
||||
String errorMsg = e.getMessage();
|
||||
Logger.e("WebDAV: 连接测试失败: " + errorMsg);
|
||||
Logger.e("WebDAV: 异常类型: " + e.getClass().getName());
|
||||
e.printStackTrace();
|
||||
|
||||
// 根据错误类型提供更详细的提示
|
||||
if (errorMsg != null) {
|
||||
if (errorMsg.contains("401") || errorMsg.contains("Unauthorized")) {
|
||||
return new TestResult(false, "认证失败:用户名或密码错误,请检查账号密码。\n提示:坚果云需要使用应用密码,不是登录密码");
|
||||
} else if (errorMsg.contains("403") || errorMsg.contains("Forbidden")) {
|
||||
return new TestResult(false, "访问被拒绝:账号可能没有WebDAV权限");
|
||||
} else if (errorMsg.contains("404") || errorMsg.contains("Not Found")) {
|
||||
return new TestResult(false, "URL不存在:请检查WebDAV服务器地址是否正确");
|
||||
} else if (errorMsg.contains("SSL") || errorMsg.contains("Certificate")) {
|
||||
return new TestResult(false, "SSL证书错误:请检查服务器证书是否有效");
|
||||
} else if (errorMsg.contains("timeout") || errorMsg.contains("Timeout")) {
|
||||
return new TestResult(false, "连接超时:请检查网络连接或服务器地址");
|
||||
} else if (errorMsg.contains("UnknownHost") || errorMsg.contains("unreachable")) {
|
||||
return new TestResult(false, "无法连接到服务器:请检查网络连接和服务器地址");
|
||||
}
|
||||
}
|
||||
return new TestResult(false, "连接失败:" + (errorMsg != null ? errorMsg : "未知错误"));
|
||||
} catch (Exception e) {
|
||||
String errorMsg = e.getMessage();
|
||||
Logger.e("WebDAV: 连接测试失败: " + errorMsg);
|
||||
Logger.e("WebDAV: 异常类型: " + e.getClass().getName());
|
||||
e.printStackTrace();
|
||||
return new TestResult(false, "连接失败:" + (errorMsg != null ? errorMsg : e.getClass().getSimpleName()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试WebDAV连接(兼容旧接口)
|
||||
*/
|
||||
public boolean testConnection() {
|
||||
return testConnectionWithMessage().success;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试结果类
|
||||
*/
|
||||
public static class TestResult {
|
||||
public final boolean success;
|
||||
public final String message;
|
||||
|
||||
public TestResult(boolean success, String message) {
|
||||
this.success = success;
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
private void ensureDirectory(String path) throws Exception {
|
||||
try {
|
||||
if (!sardine.exists(path)) {
|
||||
sardine.createDirectory(path);
|
||||
Logger.d("WebDAV: 创建目录: " + path);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 创建目录失败: " + e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件完整URL
|
||||
*/
|
||||
private String getFileUrl(String filename) {
|
||||
if (syncMode == SyncMode.CODE) {
|
||||
// 同步码模式:使用GitHub Gist raw URL
|
||||
// 格式:https://gist.githubusercontent.com/username/gist_id/raw/{syncCode}/{filename}
|
||||
String url = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
||||
return url + filename;
|
||||
} else {
|
||||
// 账号模式:使用WebDAV URL
|
||||
// 对于坚果云:https://dav.jianguoyun.com/dav/xmbox_history.json
|
||||
// 确保 baseUrl 以 / 结尾
|
||||
String url = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
||||
return url + filename;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传观看记录
|
||||
*/
|
||||
public boolean uploadHistory() {
|
||||
if (!isConfigured()) {
|
||||
Logger.e("WebDAV: 未配置,无法上传观看记录");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取所有观看记录 - 使用findAllRecent(0)来获取所有记录(包括旧记录)
|
||||
Logger.d("WebDAV: 开始查询数据库中的观看记录...");
|
||||
List<History> historyList = AppDatabase.get().getHistoryDao().findAllRecent(0);
|
||||
Logger.d("WebDAV: 数据库查询完成,结果: " + (historyList == null ? "null" : historyList.size() + " 条"));
|
||||
|
||||
if (historyList == null) {
|
||||
Logger.w("WebDAV: 查询结果为null,创建空列表");
|
||||
historyList = new java.util.ArrayList<>();
|
||||
}
|
||||
|
||||
// 修复数据中可能的编码问题(重点修复key中的站点名称部分)
|
||||
Logger.d("WebDAV: 开始修复上传数据的编码问题...");
|
||||
for (History h : historyList) {
|
||||
String originalKey = h.getKey();
|
||||
|
||||
// key格式: 站点key$视频ID$cid,需要单独修复站点key部分
|
||||
String fixedKey = fixHistoryKey(originalKey);
|
||||
if (!originalKey.equals(fixedKey)) {
|
||||
Logger.d("WebDAV: 修复key编码: '" + originalKey + "' -> '" + fixedKey + "'");
|
||||
h.setKey(fixedKey);
|
||||
}
|
||||
|
||||
String originalName = h.getVodName();
|
||||
String fixedName = fixEncodingIfNeeded(originalName);
|
||||
if (!originalName.equals(fixedName)) {
|
||||
Logger.d("WebDAV: 修复vodName编码: '" + originalName + "' -> '" + fixedName + "'");
|
||||
h.setVodName(fixedName);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.d("WebDAV: 准备上传观看记录,共 " + historyList.size() + " 条");
|
||||
|
||||
// 记录前3条数据的详细信息
|
||||
for (int i = 0; i < Math.min(3, historyList.size()); i++) {
|
||||
History h = historyList.get(i);
|
||||
Logger.d("WebDAV: 上传记录[" + i + "] key=" + h.getKey() + ", vodName=" + h.getVodName());
|
||||
// 检查key中的每个字符
|
||||
String key = h.getKey();
|
||||
StringBuilder hexDump = new StringBuilder();
|
||||
for (int j = 0; j < Math.min(20, key.length()); j++) {
|
||||
hexDump.append(String.format("%04x ", (int)key.charAt(j)));
|
||||
}
|
||||
Logger.d("WebDAV: key前20字符的Unicode: " + hexDump.toString());
|
||||
}
|
||||
|
||||
String json = App.gson().toJson(historyList);
|
||||
if (TextUtils.isEmpty(json)) {
|
||||
Logger.w("WebDAV: JSON数据为空");
|
||||
json = "[]"; // 确保至少有一个有效的JSON数组
|
||||
}
|
||||
|
||||
// 记录JSON的前500个字符
|
||||
Logger.d("WebDAV: JSON前500字符: " + json.substring(0, Math.min(500, json.length())));
|
||||
|
||||
// 确保目录存在(如果baseUrl包含子目录)
|
||||
if (syncMode == SyncMode.ACCOUNT && !TextUtils.isEmpty(baseUrl)) {
|
||||
try {
|
||||
String dirUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
||||
ensureDirectory(dirUrl);
|
||||
} catch (Exception e) {
|
||||
Logger.w("WebDAV: 创建目录失败,尝试继续上传: " + e.getMessage());
|
||||
// 继续尝试上传,某些WebDAV服务可能不需要预先创建目录
|
||||
}
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
String fileUrl = getFileUrl(HISTORY_FILE);
|
||||
Logger.d("WebDAV: 上传文件URL: " + fileUrl);
|
||||
Logger.d("WebDAV: 上传数据大小: " + json.length() + " 字节");
|
||||
|
||||
byte[] data = json.getBytes("UTF-8");
|
||||
|
||||
// 对于坚果云等WebDAV服务,直接上传文件即可(会自动创建文件)
|
||||
// 如果文件已存在,会被覆盖
|
||||
sardine.put(fileUrl, data);
|
||||
|
||||
// 验证上传是否成功:检查文件是否存在
|
||||
if (sardine.exists(fileUrl)) {
|
||||
Logger.d("WebDAV: 观看记录上传成功,共 " + historyList.size() + " 条,文件已确认存在");
|
||||
return true;
|
||||
} else {
|
||||
Logger.e("WebDAV: 上传后文件不存在,可能上传失败");
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 观看记录上传失败: " + e.getMessage());
|
||||
Logger.e("WebDAV: 异常类型: " + e.getClass().getName());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载观看记录
|
||||
*/
|
||||
public boolean downloadHistory() {
|
||||
if (!isConfigured()) {
|
||||
Logger.e("WebDAV: 未配置,无法下载观看记录");
|
||||
Logger.e("WebDAV: baseUrl=" + baseUrl + ", username=" + username);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
String fileUrl = getFileUrl(HISTORY_FILE);
|
||||
Logger.d("WebDAV: 检查文件是否存在: " + fileUrl);
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!sardine.exists(fileUrl)) {
|
||||
Logger.w("WebDAV: 观看记录文件不存在,跳过下载");
|
||||
return false;
|
||||
}
|
||||
|
||||
Logger.d("WebDAV: 文件存在,开始下载");
|
||||
|
||||
// 下载文件(使用循环读取,避免available()不准确的问题)
|
||||
InputStream is = sardine.get(fileUrl);
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = is.read(buffer)) != -1) {
|
||||
baos.write(buffer, 0, bytesRead);
|
||||
}
|
||||
is.close();
|
||||
byte[] data = baos.toByteArray();
|
||||
baos.close();
|
||||
|
||||
String json = new String(data, "UTF-8");
|
||||
if (TextUtils.isEmpty(json)) {
|
||||
Logger.d("WebDAV: 观看记录文件为空");
|
||||
return true; // 文件存在但为空,也算同步成功
|
||||
}
|
||||
|
||||
Type listType = new TypeToken<List<History>>(){}.getType();
|
||||
List<History> remoteHistoryList = App.gson().fromJson(json, listType);
|
||||
|
||||
// 验证数据
|
||||
if (remoteHistoryList == null) {
|
||||
Logger.e("WebDAV: JSON解析失败,返回null");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 智能合并:比较本地和远程记录,保留较新的
|
||||
List<History> localHistoryList = AppDatabase.get().getHistoryDao().findAllRecent(0);
|
||||
Logger.d("WebDAV: 本地记录数: " + localHistoryList.size());
|
||||
Logger.d("WebDAV: 远程记录数: " + remoteHistoryList.size());
|
||||
|
||||
// 修复远程记录的编码问题和时间戳
|
||||
Logger.d("WebDAV: 开始修复远程记录编码和时间戳...");
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long historyTimeLimit = currentTime - com.fongmi.android.tv.Constant.HISTORY_TIME; // 60天前
|
||||
|
||||
for (History remote : remoteHistoryList) {
|
||||
if (remote != null) {
|
||||
String originalKey = remote.getKey();
|
||||
// 修复key中的站点名称部分
|
||||
String fixedKey = fixHistoryKey(originalKey);
|
||||
if (!originalKey.equals(fixedKey)) {
|
||||
Logger.d("WebDAV: 修复远程key: '" + originalKey + "' -> '" + fixedKey + "'");
|
||||
remote.setKey(fixedKey);
|
||||
}
|
||||
|
||||
String originalName = remote.getVodName();
|
||||
String fixedName = fixEncodingIfNeeded(originalName);
|
||||
if (!originalName.equals(fixedName)) {
|
||||
Logger.d("WebDAV: 修复远程vodName: '" + originalName + "' -> '" + fixedName + "'");
|
||||
remote.setVodName(fixedName);
|
||||
}
|
||||
|
||||
// 关键修复:确保createTime在60天内,否则会被过滤掉!
|
||||
long remoteCreateTime = remote.getCreateTime();
|
||||
if (remoteCreateTime < historyTimeLimit) {
|
||||
Logger.d("WebDAV: 修复过期时间戳: " + remote.getVodName() +
|
||||
" createTime=" + remoteCreateTime + " -> " + currentTime +
|
||||
" (已过期 " + ((currentTime - remoteCreateTime) / (24*60*60*1000)) + " 天)");
|
||||
remote.setCreateTime(currentTime);
|
||||
}
|
||||
|
||||
// 记录前3条远程数据的详细信息
|
||||
if (remoteHistoryList.indexOf(remote) < 3) {
|
||||
Logger.d("WebDAV: 远程记录[" + remoteHistoryList.indexOf(remote) + "]: " +
|
||||
remote.getVodName() + " (key=" + remote.getKey() +
|
||||
", cid=" + remote.getCid() +
|
||||
", createTime=" + remote.getCreateTime() + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 修复本地记录的编码问题(重要!)
|
||||
Logger.d("WebDAV: 开始修复本地记录编码...");
|
||||
for (History local : localHistoryList) {
|
||||
if (local != null) {
|
||||
String originalKey = local.getKey();
|
||||
// 修复key中的站点名称部分
|
||||
String fixedKey = fixHistoryKey(originalKey);
|
||||
if (!originalKey.equals(fixedKey)) {
|
||||
Logger.d("WebDAV: 修复本地key: '" + originalKey + "' -> '" + fixedKey + "'");
|
||||
local.setKey(fixedKey);
|
||||
}
|
||||
|
||||
// 记录前3条本地数据的详细信息
|
||||
if (localHistoryList.indexOf(local) < 3) {
|
||||
Logger.d("WebDAV: 本地记录[" + localHistoryList.indexOf(local) + "]: " +
|
||||
local.getVodName() + " (key=" + local.getKey() +
|
||||
", cid=" + local.getCid() +
|
||||
", createTime=" + local.getCreateTime() + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建本地记录的映射(key -> History)
|
||||
java.util.Map<String, History> localMap = new java.util.HashMap<>();
|
||||
for (History local : localHistoryList) {
|
||||
if (local != null && local.getKey() != null) {
|
||||
localMap.put(local.getKey(), local);
|
||||
}
|
||||
}
|
||||
Logger.d("WebDAV: 本地记录映射大小: " + localMap.size());
|
||||
|
||||
// 合并远程记录
|
||||
List<History> toInsert = new java.util.ArrayList<>();
|
||||
List<History> toUpdate = new java.util.ArrayList<>();
|
||||
|
||||
Logger.d("WebDAV: 开始合并 " + remoteHistoryList.size() + " 条远程记录...");
|
||||
|
||||
for (History remote : remoteHistoryList) {
|
||||
// 验证远程记录
|
||||
if (remote == null || TextUtils.isEmpty(remote.getKey())) {
|
||||
Logger.w("WebDAV: 跳过无效的远程记录(key为空)");
|
||||
continue;
|
||||
}
|
||||
|
||||
History local = localMap.get(remote.getKey());
|
||||
|
||||
if (local == null) {
|
||||
// 本地没有,直接添加
|
||||
Logger.d("WebDAV: 发现新记录: " + remote.getVodName() + " (key=" + remote.getKey() + ")");
|
||||
toInsert.add(remote);
|
||||
} else {
|
||||
Logger.d("WebDAV: 本地已有记录: " + remote.getVodName() + ", 比较时间 remote=" + remote.getCreateTime() + " local=" + local.getCreateTime());
|
||||
|
||||
// 改进的合并策略:优先保留较新的记录,但也要比较播放进度
|
||||
long remotePos = remote.getPosition();
|
||||
long localPos = local.getPosition();
|
||||
long remoteTime = remote.getCreateTime();
|
||||
long localTime = local.getCreateTime();
|
||||
|
||||
boolean shouldUpdate = false;
|
||||
String reason = "";
|
||||
|
||||
// 策略1:如果远程时间更新,直接更新
|
||||
if (remoteTime > localTime) {
|
||||
shouldUpdate = true;
|
||||
reason = "远程时间更新 (" + remoteTime + " > " + localTime + ")";
|
||||
}
|
||||
// 策略2:如果时间相同或相近(误差1秒内),比较播放进度
|
||||
else if (Math.abs(remoteTime - localTime) <= 1000) {
|
||||
if (remotePos >= 0 && localPos >= 0) {
|
||||
if (remotePos > localPos) {
|
||||
shouldUpdate = true;
|
||||
reason = "播放进度更新 (" + remotePos + " > " + localPos + ")";
|
||||
} else {
|
||||
reason = "本地进度更新或相同";
|
||||
}
|
||||
} else if (remotePos >= 0 && localPos < 0) {
|
||||
shouldUpdate = true;
|
||||
reason = "远程有有效进度,本地无效";
|
||||
} else {
|
||||
reason = "保留本地";
|
||||
}
|
||||
}
|
||||
// 策略3:即使本地时间更新,如果远程有更大的播放进度,也更新
|
||||
else if (remoteTime < localTime) {
|
||||
if (remotePos >= 0 && localPos >= 0 && remotePos > localPos + 60000) {
|
||||
// 远程进度领先本地超过1分钟,可能是用户在另一台设备继续观看
|
||||
shouldUpdate = true;
|
||||
reason = "虽然本地时间更新,但远程进度显著领先 (" + remotePos + " > " + localPos + ")";
|
||||
} else {
|
||||
reason = "本地时间更新 (" + localTime + " > " + remoteTime + "),保留本地";
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldUpdate) {
|
||||
Logger.d("WebDAV: → 将更新本地 - " + reason);
|
||||
toUpdate.add(remote);
|
||||
} else {
|
||||
Logger.d("WebDAV: → 保留本地 - " + reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.d("WebDAV: 合并完成,待插入 " + toInsert.size() + " 条,待更新 " + toUpdate.size() + " 条");
|
||||
|
||||
// 执行插入和更新
|
||||
if (!toInsert.isEmpty()) {
|
||||
Logger.d("WebDAV: 开始插入 " + toInsert.size() + " 条新记录...");
|
||||
AppDatabase.get().getHistoryDao().insert(toInsert);
|
||||
Logger.d("WebDAV: 新增 " + toInsert.size() + " 条观看记录");
|
||||
for (History h : toInsert) {
|
||||
Logger.d("WebDAV: ✓ 新增 - " + h.getVodName() + " (cid=" + h.getCid() + ", key=" + h.getKey() + ")");
|
||||
}
|
||||
} else {
|
||||
Logger.d("WebDAV: 没有需要插入的新记录");
|
||||
}
|
||||
|
||||
if (!toUpdate.isEmpty()) {
|
||||
Logger.d("WebDAV: 开始更新 " + toUpdate.size() + " 条记录...");
|
||||
AppDatabase.get().getHistoryDao().update(toUpdate);
|
||||
Logger.d("WebDAV: 更新 " + toUpdate.size() + " 条观看记录");
|
||||
for (History h : toUpdate) {
|
||||
Logger.d("WebDAV: ✓ 更新 - " + h.getVodName() + " (cid=" + h.getCid() + ")");
|
||||
}
|
||||
} else {
|
||||
Logger.d("WebDAV: 没有需要更新的记录");
|
||||
}
|
||||
|
||||
Logger.d("WebDAV: 观看记录合并完成,远程 " + remoteHistoryList.size() + " 条,本地 " + localHistoryList.size() + " 条");
|
||||
|
||||
// 验证数据库中的记录总数
|
||||
List<History> allInDb = AppDatabase.get().getHistoryDao().findAllRecent(0);
|
||||
Logger.d("WebDAV: 数据库中总共有 " + allInDb.size() + " 条观看记录");
|
||||
|
||||
// 输出数据库中前5条记录的详细信息
|
||||
Logger.d("WebDAV: === 数据库中的记录(前5条)===");
|
||||
for (int i = 0; i < Math.min(5, allInDb.size()); i++) {
|
||||
History h = allInDb.get(i);
|
||||
Logger.d("WebDAV: [" + i + "] " + h.getVodName() +
|
||||
" (key=" + h.getKey() +
|
||||
", cid=" + h.getCid() +
|
||||
", createTime=" + h.getCreateTime() + ")");
|
||||
}
|
||||
Logger.d("WebDAV: =========================");
|
||||
|
||||
// 强制触发UI刷新(即使没有新增或更新,也刷新一次以确保显示)
|
||||
Logger.d("WebDAV: 触发UI刷新事件");
|
||||
App.post(() -> {
|
||||
RefreshEvent.history();
|
||||
Logger.d("WebDAV: UI刷新事件已发送到主线程");
|
||||
});
|
||||
|
||||
return true; // 即使远程为空,也算同步成功
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 观看记录下载失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传设置
|
||||
*/
|
||||
public boolean uploadSettings() {
|
||||
if (!isConfigured()) {
|
||||
Logger.e("WebDAV: 未配置,无法上传设置");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取所有设置
|
||||
Map<String, ?> allPrefs = Prefers.getPrefers().getAll();
|
||||
String json = App.gson().toJson(allPrefs);
|
||||
|
||||
// 确保目录存在(如果baseUrl包含子目录)
|
||||
if (syncMode == SyncMode.ACCOUNT && !TextUtils.isEmpty(baseUrl)) {
|
||||
try {
|
||||
String dirUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
||||
ensureDirectory(dirUrl);
|
||||
} catch (Exception e) {
|
||||
Logger.w("WebDAV: 创建目录失败,尝试继续上传: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
String fileUrl = getFileUrl(SETTINGS_FILE);
|
||||
byte[] data = json.getBytes("UTF-8");
|
||||
sardine.put(fileUrl, data);
|
||||
|
||||
Logger.d("WebDAV: 设置上传成功");
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 设置上传失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载设置
|
||||
*/
|
||||
public boolean downloadSettings() {
|
||||
if (!isConfigured()) {
|
||||
Logger.e("WebDAV: 未配置,无法下载设置");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
String fileUrl = getFileUrl(SETTINGS_FILE);
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!sardine.exists(fileUrl)) {
|
||||
Logger.d("WebDAV: 设置文件不存在,跳过下载");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
InputStream is = sardine.get(fileUrl);
|
||||
byte[] buffer = new byte[is.available()];
|
||||
is.read(buffer);
|
||||
is.close();
|
||||
|
||||
String json = new String(buffer, "UTF-8");
|
||||
Gson gson = App.gson();
|
||||
Map<String, Object> settings = gson.fromJson(json, Map.class);
|
||||
|
||||
// 应用设置(合并,不覆盖已存在的)
|
||||
if (settings != null && !settings.isEmpty()) {
|
||||
for (Map.Entry<String, Object> entry : settings.entrySet()) {
|
||||
// 只同步非敏感设置,跳过某些本地设置
|
||||
String key = entry.getKey();
|
||||
if (!shouldSkipSetting(key)) {
|
||||
Prefers.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
Logger.d("WebDAV: 设置下载成功");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 设置下载失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否应该跳过某个设置项
|
||||
*/
|
||||
private boolean shouldSkipSetting(String key) {
|
||||
// 跳过WebDAV相关设置,避免循环同步
|
||||
if (key.startsWith("webdav_")) {
|
||||
return true;
|
||||
}
|
||||
// 跳过设备特定设置
|
||||
if (key.equals("device_uuid") || key.equals("device_name")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传完整备份(包含所有数据)
|
||||
*/
|
||||
public boolean uploadBackup() {
|
||||
if (!isConfigured()) {
|
||||
Logger.e("WebDAV: 未配置,无法上传备份");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
Backup backup = Backup.create();
|
||||
String json = backup.toString();
|
||||
|
||||
// 确保目录存在(如果baseUrl包含子目录)
|
||||
if (syncMode == SyncMode.ACCOUNT && !TextUtils.isEmpty(baseUrl)) {
|
||||
try {
|
||||
String dirUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
||||
ensureDirectory(dirUrl);
|
||||
} catch (Exception e) {
|
||||
Logger.w("WebDAV: 创建目录失败,尝试继续上传: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
String fileUrl = getFileUrl(BACKUP_FILE);
|
||||
byte[] data = json.getBytes("UTF-8");
|
||||
sardine.put(fileUrl, data);
|
||||
|
||||
Logger.d("WebDAV: 完整备份上传成功");
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 完整备份上传失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载完整备份
|
||||
*/
|
||||
public boolean downloadBackup() {
|
||||
if (!isConfigured()) {
|
||||
Logger.e("WebDAV: 未配置,无法下载备份");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
String fileUrl = getFileUrl(BACKUP_FILE);
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!sardine.exists(fileUrl)) {
|
||||
Logger.d("WebDAV: 备份文件不存在,跳过下载");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
InputStream is = sardine.get(fileUrl);
|
||||
byte[] buffer = new byte[is.available()];
|
||||
is.read(buffer);
|
||||
is.close();
|
||||
|
||||
String json = new String(buffer, "UTF-8");
|
||||
Backup backup = Backup.objectFrom(json);
|
||||
|
||||
// 恢复备份
|
||||
if (!backup.getConfig().isEmpty()) {
|
||||
backup.restore();
|
||||
Logger.d("WebDAV: 完整备份下载并恢复成功");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 完整备份下载失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步观看记录(上传+下载合并)
|
||||
* @param async 是否异步执行,true=异步,false=同步(阻塞)
|
||||
*/
|
||||
public boolean syncHistory(boolean async) {
|
||||
if (!isConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 防止重复同步
|
||||
if (isSyncing) {
|
||||
Logger.w("WebDAV: 同步正在进行中,跳过本次请求");
|
||||
return false;
|
||||
}
|
||||
|
||||
Runnable syncTask = () -> {
|
||||
try {
|
||||
isSyncing = true;
|
||||
// 先上传本地记录
|
||||
uploadHistory();
|
||||
// 再下载远程记录并合并
|
||||
downloadHistory();
|
||||
} finally {
|
||||
isSyncing = false;
|
||||
}
|
||||
};
|
||||
|
||||
if (async) {
|
||||
App.execute(syncTask);
|
||||
} else {
|
||||
syncTask.run();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步观看记录(异步执行,默认)
|
||||
*/
|
||||
public boolean syncHistory() {
|
||||
return syncHistory(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步设置(上传+下载合并)
|
||||
* @param async 是否异步执行
|
||||
*/
|
||||
public boolean syncSettings(boolean async) {
|
||||
if (!isConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Runnable syncTask = () -> {
|
||||
// 先上传本地设置
|
||||
uploadSettings();
|
||||
// 再下载远程设置并合并
|
||||
downloadSettings();
|
||||
};
|
||||
|
||||
if (async) {
|
||||
App.execute(syncTask);
|
||||
} else {
|
||||
syncTask.run();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步设置(异步执行,默认)
|
||||
*/
|
||||
public boolean syncSettings() {
|
||||
return syncSettings(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整同步(观看记录+设置)
|
||||
* @param async 是否异步执行
|
||||
*/
|
||||
public boolean syncAll(boolean async) {
|
||||
if (!isConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 防止重复同步
|
||||
if (isSyncing) {
|
||||
Logger.w("WebDAV: 同步正在进行中,跳过本次请求");
|
||||
return false;
|
||||
}
|
||||
|
||||
Runnable syncTask = () -> {
|
||||
try {
|
||||
isSyncing = true;
|
||||
// 先上传本地记录
|
||||
uploadHistory();
|
||||
// 再下载远程记录并合并
|
||||
downloadHistory();
|
||||
// 同步设置
|
||||
syncSettings(false); // 设置同步使用同步方式,避免嵌套异步
|
||||
} finally {
|
||||
isSyncing = false;
|
||||
}
|
||||
};
|
||||
|
||||
if (async) {
|
||||
App.execute(syncTask);
|
||||
} else {
|
||||
syncTask.run();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整同步(异步执行,默认)
|
||||
*/
|
||||
public boolean syncAll() {
|
||||
return syncAll(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新加载配置(配置更改后调用)
|
||||
*/
|
||||
public void reloadConfig() {
|
||||
loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 修复History的key中的站点名称编码
|
||||
* key格式: 站点key$视频ID$cid
|
||||
*/
|
||||
private String fixHistoryKey(String key) {
|
||||
if (key == null || key.isEmpty()) {
|
||||
return key;
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用AppDatabase.SYMBOL分隔
|
||||
String symbol = com.fongmi.android.tv.db.AppDatabase.SYMBOL;
|
||||
String[] parts = key.split(java.util.regex.Pattern.quote(symbol));
|
||||
|
||||
if (parts.length >= 3) {
|
||||
// parts[0] = 站点key, parts[1] = 视频ID, parts[2] = cid
|
||||
String siteKey = parts[0];
|
||||
String fixedSiteKey = fixEncodingIfNeeded(siteKey);
|
||||
|
||||
if (!siteKey.equals(fixedSiteKey)) {
|
||||
// 重新组装key
|
||||
StringBuilder newKey = new StringBuilder(fixedSiteKey);
|
||||
for (int i = 1; i < parts.length; i++) {
|
||||
newKey.append(symbol).append(parts[i]);
|
||||
}
|
||||
return newKey.toString();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 修复History key失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修复字符串编码问题
|
||||
* 尝试将错误编码的UTF-8字符串修复为正确的UTF-8
|
||||
*/
|
||||
private String fixEncodingIfNeeded(String str) {
|
||||
if (str == null || str.isEmpty()) {
|
||||
return str;
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查字符串中是否包含明显的乱码特征
|
||||
// 1. 包含替换字符 U+FFFD
|
||||
// 2. 包含异常的低位控制字符
|
||||
boolean needsFix = false;
|
||||
for (int i = 0; i < str.length(); i++) {
|
||||
char c = str.charAt(i);
|
||||
if (c == '\uFFFD' || (c >= 0x80 && c < 0xA0)) {
|
||||
needsFix = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsFix) {
|
||||
// 尝试修复:假设原始数据是UTF-8,但被错误地当作ISO-8859-1解码
|
||||
byte[] bytes = str.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1);
|
||||
String fixed = new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
|
||||
Logger.d("WebDAV: 编码修复 '" + str + "' -> '" + fixed + "'");
|
||||
return fixed;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 编码修复失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
<string name="setting_app">应用设置</string>
|
||||
<string name="setting_network">网络设置</string>
|
||||
<string name="setting_data">数据管理</string>
|
||||
<string name="app_version">v3.0.9</string>
|
||||
<string name="app_version">v3.1.1</string>
|
||||
<string name="about_github">在GitHub上查看</string>
|
||||
|
||||
<!-- Backup & Restore -->
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
<string name="setting_app">應用設置</string>
|
||||
<string name="setting_network">網絡設置</string>
|
||||
<string name="setting_data">數據管理</string>
|
||||
<string name="app_version">v3.0.9</string>
|
||||
<string name="app_version">v3.1.1</string>
|
||||
<string name="about_github">在GitHub上查看</string>
|
||||
|
||||
<!-- Backup & Restore -->
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
<string name="setting_choose">Choose</string>
|
||||
<string name="setting_off">Off</string>
|
||||
<string name="setting_on">On</string>
|
||||
<string name="app_version">v3.0.9</string>
|
||||
<string name="app_version">v3.1.1</string>
|
||||
<string name="about_github">View on GitHub</string>
|
||||
|
||||
<!-- Backup & Restore -->
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.fongmi.android.tv.utils.Download;
|
||||
import com.fongmi.android.tv.utils.FileUtil;
|
||||
import com.fongmi.android.tv.utils.Notify;
|
||||
import com.fongmi.android.tv.utils.ResUtil;
|
||||
import com.fongmi.android.tv.utils.UpdateInstaller;
|
||||
import com.github.catvod.net.OkHttp;
|
||||
import com.github.catvod.utils.Github;
|
||||
import com.github.catvod.utils.Logger;
|
||||
@@ -29,53 +30,34 @@ import java.util.Locale;
|
||||
public class Updater implements Download.Callback {
|
||||
|
||||
private DialogUpdateBinding binding;
|
||||
private final Download download;
|
||||
private Download download;
|
||||
private AlertDialog dialog;
|
||||
private boolean dev;
|
||||
private boolean forceCheck; // 是否为手动检查
|
||||
private boolean autoShow; // 是否自动显示更新对话框(用于自动检查)
|
||||
private String latestVersion; // 存储检测到的最新版本
|
||||
private String releaseApkUrl; // 从 GitHub Release 获取的 APK 下载链接
|
||||
private String releaseApkUrl; // 从 GitHub Release 获取的 APK 下载链接(jsDelivr CDN)
|
||||
private String fallbackApkUrl; // 备用下载链接(GitHub原始URL)
|
||||
|
||||
// 静态变量:记录上次检查时间(用于时间间隔限制)
|
||||
private static long lastCheckTime = 0;
|
||||
private static final long CHECK_INTERVAL = 60 * 60 * 1000; // 1小时(毫秒)
|
||||
|
||||
private File getFile() {
|
||||
return Path.root("Download", "XMBOX-update.apk");
|
||||
}
|
||||
|
||||
private String getJson() {
|
||||
return Github.getJson(dev, BuildConfig.FLAVOR_mode);
|
||||
// Android 10+ 无法直接访问外部存储的Download目录
|
||||
// 使用应用的cache目录,FileProvider可以正常访问
|
||||
return Path.cache("XMBOX-update.apk");
|
||||
}
|
||||
|
||||
private String getApk() {
|
||||
// 优先使用从 GitHub Release 获取的 APK URL
|
||||
// 使用从 GitHub Release 获取的 APK URL(jsDelivr CDN)
|
||||
if (releaseApkUrl != null && !releaseApkUrl.isEmpty()) {
|
||||
Logger.d("APK download URL from Release: " + releaseApkUrl);
|
||||
Logger.d("APK download URL from Release (jsDelivr): " + releaseApkUrl);
|
||||
return releaseApkUrl;
|
||||
}
|
||||
|
||||
// 使用JSON中指定的具体下载路径
|
||||
try {
|
||||
String response = OkHttp.string(getJson());
|
||||
JSONObject object = new JSONObject(response);
|
||||
JSONObject downloads = object.optJSONObject("downloads");
|
||||
if (downloads != null) {
|
||||
String abi = BuildConfig.FLAVOR_abi;
|
||||
String downloadPath = downloads.optString(abi);
|
||||
if (!downloadPath.isEmpty()) {
|
||||
// 直接构建完整URL,不通过Github.getApk()避免重复添加路径
|
||||
String baseUrl = Github.useCnMirror() ?
|
||||
"https://gitee.com/ochenoktochen/XMBOX-Release/raw/main" :
|
||||
"https://raw.githubusercontent.com/Tosencen/XMBOX-Release/main";
|
||||
String fullUrl = baseUrl + "/apk/" + (dev ? "dev" : "release") + "/" + downloadPath;
|
||||
Logger.d("APK download URL: " + fullUrl);
|
||||
return fullUrl;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.e("Failed to get download path from JSON: " + e.getMessage());
|
||||
}
|
||||
// 回退到原来的方式
|
||||
String fallbackUrl = Github.getApk(dev, BuildConfig.FLAVOR_mode + "-" + BuildConfig.FLAVOR_abi);
|
||||
Logger.d("APK fallback URL: " + fallbackUrl);
|
||||
return fallbackUrl;
|
||||
// 如果没有获取到URL,返回空(不应该发生)
|
||||
Logger.e("Updater: 未找到APK下载链接");
|
||||
return "";
|
||||
}
|
||||
|
||||
public static Updater create() {
|
||||
@@ -83,8 +65,9 @@ public class Updater implements Download.Callback {
|
||||
}
|
||||
|
||||
public Updater() {
|
||||
this.download = Download.create(getApk(), getFile(), this);
|
||||
this.forceCheck = false;
|
||||
this.autoShow = false;
|
||||
// download对象将在需要时创建
|
||||
}
|
||||
|
||||
public Updater force() {
|
||||
@@ -94,6 +77,15 @@ public class Updater implements Download.Callback {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置自动检查模式(应用启动时自动检查)
|
||||
*/
|
||||
public Updater auto() {
|
||||
this.forceCheck = false;
|
||||
this.autoShow = true; // 自动显示更新对话框
|
||||
return this;
|
||||
}
|
||||
|
||||
public Updater release() {
|
||||
this.dev = false;
|
||||
return this;
|
||||
@@ -110,6 +102,16 @@ public class Updater implements Download.Callback {
|
||||
}
|
||||
|
||||
public void start(Activity activity) {
|
||||
// 如果是自动检查,检查时间间隔
|
||||
if (autoShow && !forceCheck) {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long timeSinceLastCheck = currentTime - lastCheckTime;
|
||||
// 1小时内只检查一次
|
||||
if (lastCheckTime > 0 && timeSinceLastCheck < CHECK_INTERVAL) {
|
||||
Logger.d("Updater: 距离上次检查仅 " + (timeSinceLastCheck / 1000 / 60) + " 分钟,跳过本次检查");
|
||||
return;
|
||||
}
|
||||
}
|
||||
App.execute(() -> doInBackground(activity));
|
||||
}
|
||||
|
||||
@@ -119,48 +121,9 @@ public class Updater implements Download.Callback {
|
||||
|
||||
private void doInBackground(Activity activity) {
|
||||
Logger.d("Updater: Starting update check...");
|
||||
try {
|
||||
// 优先使用 JSON 方式检测更新(兼容性更好)
|
||||
String response = OkHttp.string(getJson());
|
||||
JSONObject object = new JSONObject(response);
|
||||
String name = object.optString("name");
|
||||
String desc = object.optString("desc");
|
||||
int code = object.optInt("code");
|
||||
|
||||
Logger.d("Updater: JSON Remote version: " + name + ", code: " + code);
|
||||
Logger.d("Updater: Local version: " + BuildConfig.VERSION_NAME + ", code: " + BuildConfig.VERSION_CODE);
|
||||
|
||||
// 使用 JSON 中的版本信息
|
||||
if (need(code, name)) {
|
||||
Logger.d("Updater: Update needed (from JSON), showing dialog");
|
||||
this.latestVersion = name; // 保存最新版本号
|
||||
|
||||
// 从 JSON 获取下载链接
|
||||
JSONObject downloads = object.optJSONObject("downloads");
|
||||
if (downloads != null) {
|
||||
String abi = BuildConfig.FLAVOR_abi;
|
||||
String downloadPath = downloads.optString(abi);
|
||||
if (!downloadPath.isEmpty()) {
|
||||
String baseUrl = Github.useCnMirror() ?
|
||||
"https://gitee.com/ochenoktochen/XMBOX-Release/raw/main" :
|
||||
"https://raw.githubusercontent.com/Tosencen/XMBOX-Release/main";
|
||||
this.releaseApkUrl = baseUrl + "/apk/" + (dev ? "dev" : "release") + "/" + downloadPath;
|
||||
Logger.d("Updater: APK URL from JSON: " + this.releaseApkUrl);
|
||||
}
|
||||
}
|
||||
|
||||
App.post(() -> show(activity, name, desc));
|
||||
} else {
|
||||
Logger.d("Updater: No update needed (from JSON)");
|
||||
if (forceCheck) {
|
||||
App.post(() -> Notify.show("已是最新版本 " + name));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.e("Updater: JSON check failed, trying GitHub API: " + e.getMessage());
|
||||
// JSON 检测失败,尝试使用 GitHub Releases API
|
||||
checkViaGitHubAPI(activity);
|
||||
}
|
||||
lastCheckTime = System.currentTimeMillis(); // 更新检查时间
|
||||
// 直接使用 GitHub Releases API 检查更新
|
||||
checkViaGitHubAPI(activity);
|
||||
}
|
||||
|
||||
private void checkViaGitHubAPI(Activity activity) {
|
||||
@@ -168,12 +131,42 @@ public class Updater implements Download.Callback {
|
||||
String releasesUrl = "https://api.github.com/repos/Tosencen/XMBOX/releases/latest";
|
||||
Logger.d("Updater: Trying GitHub Releases API: " + releasesUrl);
|
||||
|
||||
String response = OkHttp.string(releasesUrl);
|
||||
// 检查是否有GitHub Token
|
||||
String githubToken = BuildConfig.GITHUB_TOKEN;
|
||||
String response;
|
||||
if (githubToken != null && !githubToken.isEmpty()) {
|
||||
// 使用token进行认证请求(5000次/小时)
|
||||
java.util.Map<String, String> headers = new java.util.HashMap<>();
|
||||
headers.put("Authorization", "Bearer " + githubToken);
|
||||
headers.put("Accept", "application/vnd.github.v3+json");
|
||||
Logger.d("Updater: Using GitHub Token for authenticated request");
|
||||
response = OkHttp.string(releasesUrl, headers);
|
||||
} else {
|
||||
// 使用未认证请求(60次/小时)
|
||||
Logger.d("Updater: Using unauthenticated request (60 requests/hour limit)");
|
||||
response = OkHttp.string(releasesUrl);
|
||||
}
|
||||
|
||||
if (response.contains("rate limit exceeded")) {
|
||||
// 检查响应是否为空(可能是网络错误、VPN问题等)
|
||||
if (response == null || response.isEmpty()) {
|
||||
Logger.e("Updater: 网络请求失败,响应为空。可能是网络连接问题或VPN配置问题");
|
||||
if (forceCheck) {
|
||||
// 手动检查时,显示错误提示
|
||||
App.post(() -> {
|
||||
Notify.show("检查更新失败:网络连接异常,请检查网络设置或VPN配置");
|
||||
showVersionInfo(activity, BuildConfig.VERSION_NAME, "");
|
||||
});
|
||||
} else {
|
||||
Logger.w("Updater: 自动检查失败,网络不可用");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.contains("rate limit exceeded") || response.contains("API rate limit exceeded")) {
|
||||
Logger.e("Updater: Rate limit exceeded");
|
||||
if (forceCheck) {
|
||||
App.post(() -> Notify.show("检查更新失败:API请求过于频繁,请稍后重试"));
|
||||
// 手动检查时,显示版本信息弹窗(不显示错误提示)
|
||||
App.post(() -> showVersionInfo(activity, BuildConfig.VERSION_NAME, ""));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -181,7 +174,8 @@ public class Updater implements Download.Callback {
|
||||
if (response.contains("Not Found") || response.contains("404")) {
|
||||
Logger.e("Updater: Release not found");
|
||||
if (forceCheck) {
|
||||
App.post(() -> Notify.show("检查更新失败:无法连接到更新服务器"));
|
||||
// 手动检查时,显示版本信息弹窗(不显示错误提示)
|
||||
App.post(() -> showVersionInfo(activity, BuildConfig.VERSION_NAME, ""));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -196,26 +190,106 @@ public class Updater implements Download.Callback {
|
||||
// 从 assets 中查找 APK
|
||||
JSONArray assets = release.optJSONArray("assets");
|
||||
if (assets != null) {
|
||||
String targetApkName = BuildConfig.FLAVOR_mode + "-" + BuildConfig.FLAVOR_abi + "-v" + version + ".apk";
|
||||
for (int i = 0; i < assets.length(); i++) {
|
||||
String mode = BuildConfig.FLAVOR_mode;
|
||||
String abi = BuildConfig.FLAVOR_abi;
|
||||
|
||||
// 尝试多种文件名格式
|
||||
String[] possibleNames = {
|
||||
mode + "-" + abi + "-v" + version + ".apk", // mobile-arm64_v8a-v3.1.0.apk
|
||||
mode + "-" + abi + "-release.apk", // mobile-arm64_v8a-release.apk
|
||||
mode + "-" + abi + ".apk", // mobile-arm64_v8a.apk
|
||||
mode + "-" + abi + "-" + version + ".apk" // mobile-arm64_v8a-3.1.0.apk
|
||||
};
|
||||
|
||||
boolean found = false;
|
||||
for (int i = 0; i < assets.length() && !found; i++) {
|
||||
JSONObject asset = assets.getJSONObject(i);
|
||||
if (targetApkName.equals(asset.optString("name"))) {
|
||||
this.releaseApkUrl = asset.optString("browser_download_url");
|
||||
break;
|
||||
String assetName = asset.optString("name");
|
||||
|
||||
// 检查是否匹配任何可能的文件名格式
|
||||
for (String targetName : possibleNames) {
|
||||
if (targetName.equals(assetName)) {
|
||||
String githubUrl = asset.optString("browser_download_url");
|
||||
// jsDelivr无法访问GitHub Release文件,直接使用GitHub Release URL
|
||||
// 如果GitHub访问慢,可以配置代理或使用其他CDN
|
||||
this.releaseApkUrl = githubUrl;
|
||||
this.fallbackApkUrl = githubUrl;
|
||||
Logger.d("Updater: 找到匹配的APK: " + assetName);
|
||||
Logger.d("Updater: APK URL (GitHub Release): " + this.releaseApkUrl);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果精确匹配失败,尝试模糊匹配(包含mode和abi的APK文件)
|
||||
if (!found) {
|
||||
Logger.w("Updater: 未找到精确匹配的APK,尝试模糊匹配...");
|
||||
for (int i = 0; i < assets.length(); i++) {
|
||||
JSONObject asset = assets.getJSONObject(i);
|
||||
String assetName = asset.optString("name");
|
||||
// 检查文件名是否包含mode和abi,且是APK文件
|
||||
if (assetName.endsWith(".apk") &&
|
||||
assetName.contains(mode) &&
|
||||
assetName.contains(abi.replace("_", "-"))) {
|
||||
String githubUrl = asset.optString("browser_download_url");
|
||||
// jsDelivr无法访问GitHub Release文件,直接使用GitHub Release URL
|
||||
this.releaseApkUrl = githubUrl;
|
||||
this.fallbackApkUrl = githubUrl;
|
||||
Logger.d("Updater: 找到模糊匹配的APK: " + assetName);
|
||||
Logger.d("Updater: APK URL (GitHub Release): " + this.releaseApkUrl);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
Logger.e("Updater: 在Release中未找到匹配的APK文件");
|
||||
Logger.e("Updater: 期望的格式: " + mode + "-" + abi + "-v" + version + ".apk");
|
||||
Logger.e("Updater: 可用的assets:");
|
||||
for (int i = 0; i < assets.length(); i++) {
|
||||
JSONObject asset = assets.getJSONObject(i);
|
||||
String assetName = asset.optString("name");
|
||||
if (assetName.endsWith(".apk")) {
|
||||
Logger.e("Updater: - " + assetName);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.e("Updater: Release中没有assets数组");
|
||||
}
|
||||
|
||||
if (needUpdate(version)) {
|
||||
this.latestVersion = version;
|
||||
// 有新版本时,自动显示或手动显示更新对话框
|
||||
App.post(() -> show(activity, version, body));
|
||||
} else if (forceCheck) {
|
||||
App.post(() -> Notify.show("已是最新版本 " + version));
|
||||
} else {
|
||||
// 没有新版本
|
||||
if (forceCheck) {
|
||||
// 手动检查时,显示版本信息弹窗
|
||||
App.post(() -> showVersionInfo(activity, version, body));
|
||||
} else if (autoShow) {
|
||||
// 自动检查时,不显示任何内容(静默检查)
|
||||
Logger.d("Updater: 自动检查完成,当前已是最新版本");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.e("Updater: GitHub API check failed: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
if (forceCheck) {
|
||||
App.post(() -> Notify.show("检查更新失败:无法连接到更新服务器"));
|
||||
// 手动检查时,显示错误提示
|
||||
String errorMsg = e.getMessage();
|
||||
if (errorMsg != null && (errorMsg.contains("network") || errorMsg.contains("timeout") || errorMsg.contains("connect"))) {
|
||||
App.post(() -> {
|
||||
Notify.show("检查更新失败:网络连接异常,请检查网络设置或VPN配置");
|
||||
showVersionInfo(activity, BuildConfig.VERSION_NAME, "");
|
||||
});
|
||||
} else {
|
||||
App.post(() -> showVersionInfo(activity, BuildConfig.VERSION_NAME, ""));
|
||||
}
|
||||
} else {
|
||||
Logger.w("Updater: 自动检查失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,42 +330,51 @@ public class Updater implements Download.Callback {
|
||||
binding.desc.setText(desc);
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示版本信息弹窗(无更新时)
|
||||
*/
|
||||
private void showVersionInfo(Activity activity, String remoteVersion, String desc) {
|
||||
binding = DialogUpdateBinding.inflate(LayoutInflater.from(activity));
|
||||
// 先设置内容,只显示当前版本号,不使用远程信息
|
||||
binding.desc.setText(BuildConfig.VERSION_NAME);
|
||||
check().create(activity, "最新版本").show();
|
||||
// 隐藏确认按钮,只显示取消按钮(改为"确定")
|
||||
dialog.getButton(DialogInterface.BUTTON_POSITIVE).setVisibility(View.GONE);
|
||||
dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setText("确定");
|
||||
dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener(v -> {
|
||||
if (dialog != null) dialog.dismiss();
|
||||
});
|
||||
}
|
||||
|
||||
private AlertDialog create(Activity activity, String title) {
|
||||
return dialog = new MaterialAlertDialogBuilder(activity).setTitle(title).setView(binding.getRoot()).setPositiveButton(R.string.update_confirm, null).setNegativeButton(R.string.dialog_negative, null).setCancelable(false).create();
|
||||
}
|
||||
|
||||
private void cancel(View view) {
|
||||
Setting.putUpdate(false);
|
||||
download.cancel();
|
||||
if (download != null) {
|
||||
download.cancel();
|
||||
}
|
||||
dialog.dismiss();
|
||||
}
|
||||
|
||||
private void confirm(View view) {
|
||||
// 跳转到具体版本的GitHub Releases页面
|
||||
try {
|
||||
String url = "https://github.com/Tosencen/XMBOX/releases/tag/v" + latestVersion;
|
||||
Logger.d("Updater: Attempting to open URL: " + url);
|
||||
// 开始下载更新(使用jsDelivr CDN,失败时回退到GitHub)
|
||||
String downloadUrl = getApk();
|
||||
String fallbackUrl = this.fallbackApkUrl;
|
||||
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW);
|
||||
intent.setData(Uri.parse(url));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
// 检查是否有应用可以处理这个Intent
|
||||
if (intent.resolveActivity(App.get().getPackageManager()) != null) {
|
||||
App.get().startActivity(intent);
|
||||
Logger.d("Updater: Successfully started browser intent");
|
||||
dismiss();
|
||||
} else {
|
||||
Logger.e("Updater: No app can handle the URL");
|
||||
Notify.show("没有找到可以打开链接的应用,请手动访问GitHub下载");
|
||||
dismiss();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.e("Updater: Failed to open GitHub releases page: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
Notify.show("无法打开更新页面,请手动访问GitHub下载");
|
||||
dismiss();
|
||||
// 检查URL是否为空
|
||||
if (downloadUrl == null || downloadUrl.isEmpty()) {
|
||||
Logger.e("Updater: 下载URL为空,无法下载");
|
||||
Notify.show("无法获取下载链接,请稍后重试或手动下载");
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.d("Updater: 开始下载,URL: " + downloadUrl);
|
||||
|
||||
// 创建带回退URL的下载对象
|
||||
this.download = Download.create(downloadUrl, getFile(), fallbackUrl, this);
|
||||
this.download.start();
|
||||
}
|
||||
|
||||
private void dismiss() {
|
||||
@@ -314,7 +397,30 @@ public class Updater implements Download.Callback {
|
||||
|
||||
@Override
|
||||
public void success(File file) {
|
||||
FileUtil.openFile(file);
|
||||
dismiss();
|
||||
// 使用UpdateInstaller处理安装,包括权限检查和请求
|
||||
UpdateInstaller installer = UpdateInstaller.get();
|
||||
|
||||
// 检查安装权限
|
||||
if (!installer.hasInstallPermission()) {
|
||||
// 没有权限,请求权限并保存待安装的文件
|
||||
Logger.d("Updater: 没有安装权限,请求权限");
|
||||
installer.requestInstallPermission();
|
||||
// 保存待安装的文件,将在权限授予后自动安装
|
||||
installer.install(file, true); // checkPermission=true会保存文件
|
||||
Notify.show("请授予安装权限以完成更新");
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
// 有权限,直接安装
|
||||
boolean success = installer.install(file, false);
|
||||
if (success) {
|
||||
Logger.d("Updater: 已启动安装程序");
|
||||
dismiss();
|
||||
} else {
|
||||
Logger.e("Updater: 启动安装程序失败");
|
||||
Notify.show("无法启动安装程序,请检查文件是否完整");
|
||||
dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ public class HistoryActivity extends BaseActivity implements HistoryAdapter.OnCl
|
||||
}
|
||||
|
||||
private void getHistory() {
|
||||
mAdapter.addAll(History.get());
|
||||
mAdapter.addAll(History.getAll()); // 显示所有视频源的观看记录
|
||||
mBinding.delete.setVisibility(mAdapter.getItemCount() > 0 ? View.VISIBLE : View.GONE);
|
||||
updateEmptyState();
|
||||
}
|
||||
|
||||
@@ -23,10 +23,12 @@ import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.format.DateFormat;
|
||||
import android.text.style.ClickableSpan;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
import android.media.AudioManager;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
@@ -166,6 +168,7 @@ public class VideoActivity extends BaseActivity implements Clock.Callback, Custo
|
||||
private int mBatteryLevel = -1;
|
||||
private boolean mIsCharging = false;
|
||||
private boolean mPausedByScreen = false;
|
||||
private AudioManager mAudioManager;
|
||||
|
||||
public static void push(FragmentActivity activity, String text) {
|
||||
if (FileChooser.isValid(activity, Uri.parse(text))) file(activity, FileChooser.getPathFromUri(activity, Uri.parse(text)));
|
||||
@@ -308,6 +311,7 @@ public class VideoActivity extends BaseActivity implements Clock.Callback, Custo
|
||||
mDialogs = new ArrayList<>();
|
||||
mBroken = new ArrayList<>();
|
||||
mClock = Clock.create();
|
||||
mAudioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
|
||||
mR1 = this::hideControl;
|
||||
mR2 = this::setTraffic;
|
||||
mR3 = this::setOrient;
|
||||
@@ -1823,6 +1827,71 @@ public class VideoActivity extends BaseActivity implements Clock.Callback, Custo
|
||||
setStop(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onKeyDown(int keyCode, KeyEvent event) {
|
||||
// 只在视频播放时处理键盘事件
|
||||
if (mPlayers != null && !mPlayers.isEmpty()) {
|
||||
switch (keyCode) {
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT:
|
||||
// 左方向键:快退10秒
|
||||
if (mPlayers.isPlaying() || mPlayers.getPosition() > 0) {
|
||||
long currentPosition = mPlayers.getPosition();
|
||||
long seekTime = -10000; // 快退10秒
|
||||
long newPosition = Math.max(0, currentPosition + seekTime);
|
||||
mPlayers.seekTo(newPosition);
|
||||
// 显示快退提示
|
||||
onSeek(seekTime);
|
||||
App.post(() -> {
|
||||
mBinding.widget.seek.setVisibility(View.GONE);
|
||||
}, 1000);
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT:
|
||||
// 右方向键:快进10秒
|
||||
if (mPlayers.isPlaying() || mPlayers.getPosition() > 0) {
|
||||
long currentPosition = mPlayers.getPosition();
|
||||
long duration = mPlayers.getDuration();
|
||||
long seekTime = 10000; // 快进10秒
|
||||
long newPosition = Math.min(duration > 0 ? duration : Long.MAX_VALUE, currentPosition + seekTime);
|
||||
mPlayers.seekTo(newPosition);
|
||||
// 显示快进提示
|
||||
onSeek(seekTime);
|
||||
App.post(() -> {
|
||||
mBinding.widget.seek.setVisibility(View.GONE);
|
||||
}, 1000);
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case KeyEvent.KEYCODE_DPAD_UP:
|
||||
// 上方向键:增加音量
|
||||
if (mAudioManager != null) {
|
||||
int currentVolume = mAudioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
|
||||
int maxVolume = mAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
|
||||
int newVolume = Math.min(maxVolume, currentVolume + 1);
|
||||
mAudioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0);
|
||||
onVolume((int) (newVolume * 100.0f / maxVolume));
|
||||
App.post(() -> onVolumeEnd(), 1000);
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN:
|
||||
// 下方向键:减少音量
|
||||
if (mAudioManager != null) {
|
||||
int currentVolume = mAudioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
|
||||
int maxVolume = mAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
|
||||
int newVolume = Math.max(0, currentVolume - 1);
|
||||
mAudioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0);
|
||||
onVolume((int) (newVolume * 100.0f / maxVolume));
|
||||
App.post(() -> onVolumeEnd(), 1000);
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return super.onKeyDown(keyCode, event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (isVisible(mBinding.control.getRoot())) {
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
package com.fongmi.android.tv.ui.dialog;
|
||||
|
||||
import android.content.DialogInterface;
|
||||
import android.text.TextUtils;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import com.fongmi.android.tv.App;
|
||||
import com.fongmi.android.tv.R;
|
||||
import com.fongmi.android.tv.Setting;
|
||||
import com.fongmi.android.tv.databinding.DialogWebdavBinding;
|
||||
import com.fongmi.android.tv.event.RefreshEvent;
|
||||
import com.fongmi.android.tv.utils.Notify;
|
||||
import com.fongmi.android.tv.utils.WebDAVSyncManager;
|
||||
import com.github.catvod.utils.Logger;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
public class WebDAVDialog {
|
||||
|
||||
// 预设的WebDAV服务提供商
|
||||
private static final String[] PROVIDERS = {
|
||||
"坚果云",
|
||||
"Nextcloud",
|
||||
"ownCloud",
|
||||
"自定义"
|
||||
};
|
||||
|
||||
private static final String[] PROVIDER_URLS = {
|
||||
"https://dav.jianguoyun.com/dav/XMBOX/", // 坚果云(添加XMBOX子目录,方便在网页版查看)
|
||||
"", // Nextcloud(需要用户输入)
|
||||
"", // ownCloud(需要用户输入)
|
||||
"" // 自定义(需要用户输入)
|
||||
};
|
||||
|
||||
private final DialogWebdavBinding binding;
|
||||
private final Fragment fragment;
|
||||
private AlertDialog dialog;
|
||||
private WebDAVSyncManager syncManager;
|
||||
private int selectedProvider = 0; // 默认选择坚果云
|
||||
private boolean isInitializing = false; // 标记是否正在初始化,防止初始化时触发监听器
|
||||
|
||||
public static WebDAVDialog create(Fragment fragment) {
|
||||
return new WebDAVDialog(fragment);
|
||||
}
|
||||
|
||||
public WebDAVDialog(Fragment fragment) {
|
||||
this.fragment = fragment;
|
||||
this.binding = DialogWebdavBinding.inflate(LayoutInflater.from(fragment.getContext()));
|
||||
this.syncManager = WebDAVSyncManager.get();
|
||||
}
|
||||
|
||||
public void show() {
|
||||
initDialog();
|
||||
initView();
|
||||
initEvent();
|
||||
}
|
||||
|
||||
private void initDialog() {
|
||||
dialog = new MaterialAlertDialogBuilder(binding.getRoot().getContext())
|
||||
.setTitle("WebDAV 配置")
|
||||
.setView(binding.getRoot())
|
||||
.setPositiveButton("保存", this::onPositive)
|
||||
.setNegativeButton("取消", this::onNegative)
|
||||
.create();
|
||||
dialog.getWindow().setDimAmount(0);
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
private void initView() {
|
||||
isInitializing = true; // 标记开始初始化
|
||||
|
||||
// 加载已保存的配置
|
||||
String url = Setting.getWebDAVUrl();
|
||||
String username = Setting.getWebDAVUsername();
|
||||
String password = Setting.getWebDAVPassword();
|
||||
boolean autoSync = Setting.isWebDAVAutoSync();
|
||||
int interval = Setting.getWebDAVSyncInterval();
|
||||
|
||||
// 根据保存的URL判断是哪个服务提供商
|
||||
selectedProvider = getProviderIndexByUrl(url);
|
||||
binding.providerText.setText(PROVIDERS[selectedProvider]);
|
||||
|
||||
// 根据选择的服务提供商决定是否显示URL输入框
|
||||
if (selectedProvider == PROVIDERS.length - 1) {
|
||||
// 自定义,显示URL输入框
|
||||
binding.urlInput.setVisibility(View.VISIBLE);
|
||||
binding.urlText.setText(url);
|
||||
if (!TextUtils.isEmpty(url)) {
|
||||
binding.urlText.setSelection(url.length());
|
||||
}
|
||||
} else if (selectedProvider == 0) {
|
||||
// 坚果云,永远隐藏输入框(有预设URL)
|
||||
binding.urlInput.setVisibility(View.GONE);
|
||||
} else {
|
||||
// Nextcloud或ownCloud需要用户输入URL
|
||||
binding.urlInput.setVisibility(View.VISIBLE);
|
||||
binding.urlText.setText(url);
|
||||
if (!TextUtils.isEmpty(url)) {
|
||||
binding.urlText.setSelection(url.length());
|
||||
}
|
||||
}
|
||||
|
||||
binding.usernameText.setText(username);
|
||||
binding.passwordText.setText(password);
|
||||
binding.autoSyncSwitch.setChecked(autoSync);
|
||||
binding.syncIntervalText.setText(String.valueOf(interval));
|
||||
|
||||
// 根据自动同步开关显示/隐藏同步间隔
|
||||
updateSyncIntervalVisibility(autoSync);
|
||||
|
||||
isInitializing = false; // 初始化完成
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据URL判断是哪个服务提供商
|
||||
*/
|
||||
private int getProviderIndexByUrl(String url) {
|
||||
if (TextUtils.isEmpty(url)) {
|
||||
return 0; // 默认坚果云
|
||||
}
|
||||
if (url.contains("jianguoyun.com")) {
|
||||
return 0; // 坚果云
|
||||
}
|
||||
if (url.contains("nextcloud")) {
|
||||
return 1; // Nextcloud
|
||||
}
|
||||
if (url.contains("owncloud")) {
|
||||
return 2; // ownCloud
|
||||
}
|
||||
return PROVIDERS.length - 1; // 自定义
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前选择的服务提供商的URL
|
||||
*/
|
||||
private String getProviderUrl() {
|
||||
if (selectedProvider < PROVIDER_URLS.length && !TextUtils.isEmpty(PROVIDER_URLS[selectedProvider])) {
|
||||
return PROVIDER_URLS[selectedProvider];
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private void initEvent() {
|
||||
// 服务提供商选择
|
||||
binding.providerText.setOnClickListener(v -> onSelectProvider());
|
||||
|
||||
// 自动同步开关监听(立即保存状态)
|
||||
// 使用setOnClickListener而不是setOnCheckedChangeListener,避免覆盖CustomSwitch内部的动画监听器
|
||||
// AppCompatCheckBox会自动处理状态切换,我们只需要在状态切换后获取新状态
|
||||
binding.autoSyncSwitch.setOnClickListener(v -> {
|
||||
// 防止初始化时触发监听器
|
||||
if (isInitializing) {
|
||||
return;
|
||||
}
|
||||
// 使用post()确保在状态切换后获取新状态
|
||||
binding.autoSyncSwitch.post(() -> {
|
||||
boolean newState = binding.autoSyncSwitch.isChecked();
|
||||
// 立即保存自动同步状态
|
||||
Setting.putWebDAVAutoSync(newState);
|
||||
// 更新同步间隔的可见性
|
||||
updateSyncIntervalVisibility(newState);
|
||||
});
|
||||
});
|
||||
|
||||
// 测试连接按钮
|
||||
binding.testButton.setOnClickListener(v -> onTestConnection());
|
||||
|
||||
// 立即同步按钮
|
||||
binding.syncButton.setOnClickListener(v -> onSyncNow());
|
||||
|
||||
// 同步间隔点击(弹出选择对话框)
|
||||
binding.syncIntervalContainer.setOnClickListener(v -> onSelectInterval());
|
||||
|
||||
// 密码输入框回车键
|
||||
binding.passwordText.setOnEditorActionListener((textView, actionId, event) -> {
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
dialog.getButton(DialogInterface.BUTTON_POSITIVE).performClick();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private void onSelectProvider() {
|
||||
new MaterialAlertDialogBuilder(binding.getRoot().getContext())
|
||||
.setTitle("选择服务提供商")
|
||||
.setSingleChoiceItems(PROVIDERS, selectedProvider, (dialog, which) -> {
|
||||
selectedProvider = which;
|
||||
binding.providerText.setText(PROVIDERS[which]);
|
||||
|
||||
// 如果是自定义,显示URL输入框
|
||||
if (which == PROVIDERS.length - 1) {
|
||||
binding.urlInput.setVisibility(View.VISIBLE);
|
||||
String currentUrl = binding.urlText.getText().toString().trim();
|
||||
if (TextUtils.isEmpty(currentUrl)) {
|
||||
binding.urlText.setText("");
|
||||
}
|
||||
} else {
|
||||
// 使用预设的URL
|
||||
binding.urlInput.setVisibility(View.GONE);
|
||||
String providerUrl = getProviderUrl();
|
||||
if (!TextUtils.isEmpty(providerUrl)) {
|
||||
// URL会在保存时自动填充
|
||||
} else {
|
||||
// Nextcloud或ownCloud需要用户输入URL
|
||||
binding.urlInput.setVisibility(View.VISIBLE);
|
||||
binding.urlText.setHint("请输入" + PROVIDERS[which] + "服务器地址");
|
||||
}
|
||||
}
|
||||
dialog.dismiss();
|
||||
})
|
||||
.setNegativeButton("取消", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void updateSyncIntervalVisibility(boolean visible) {
|
||||
binding.syncIntervalContainer.setVisibility(visible ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
private void onTestConnection() {
|
||||
String url = getServerUrl();
|
||||
String username = binding.usernameText.getText().toString().trim();
|
||||
String password = binding.passwordText.getText().toString().trim();
|
||||
|
||||
if (TextUtils.isEmpty(url)) {
|
||||
showStatus("请选择服务提供商或输入服务器地址", false);
|
||||
return;
|
||||
}
|
||||
if (TextUtils.isEmpty(username)) {
|
||||
showStatus("请输入用户名", false);
|
||||
return;
|
||||
}
|
||||
if (TextUtils.isEmpty(password)) {
|
||||
showStatus("请输入密码", false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 临时保存配置用于测试
|
||||
Setting.putWebDAVUrl(url);
|
||||
Setting.putWebDAVUsername(username);
|
||||
Setting.putWebDAVPassword(password);
|
||||
|
||||
// 重新加载配置
|
||||
syncManager.reloadConfig();
|
||||
|
||||
showStatus("正在测试连接...", true);
|
||||
binding.testButton.setEnabled(false);
|
||||
|
||||
// 在后台线程测试连接
|
||||
App.execute(() -> {
|
||||
boolean success = syncManager.testConnection();
|
||||
App.post(() -> {
|
||||
// 检查对话框是否还存在
|
||||
if (binding == null || dialog == null || !dialog.isShowing()) {
|
||||
return;
|
||||
}
|
||||
binding.testButton.setEnabled(true);
|
||||
if (success) {
|
||||
showStatus("连接成功!", true);
|
||||
} else {
|
||||
showStatus("连接失败,请检查配置", false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void onSyncNow() {
|
||||
// 先临时保存当前配置用于测试同步
|
||||
String url = getServerUrl();
|
||||
String username = binding.usernameText.getText().toString().trim();
|
||||
String password = binding.passwordText.getText().toString().trim();
|
||||
|
||||
// 验证输入
|
||||
if (TextUtils.isEmpty(url)) {
|
||||
showStatus("请选择服务提供商或输入服务器地址", false);
|
||||
return;
|
||||
}
|
||||
if (TextUtils.isEmpty(username)) {
|
||||
showStatus("请输入用户名", false);
|
||||
return;
|
||||
}
|
||||
if (TextUtils.isEmpty(password)) {
|
||||
showStatus("请输入密码", false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 临时保存配置用于同步
|
||||
Setting.putWebDAVUrl(url);
|
||||
Setting.putWebDAVUsername(username);
|
||||
Setting.putWebDAVPassword(password);
|
||||
syncManager.reloadConfig();
|
||||
|
||||
if (!syncManager.isConfigured()) {
|
||||
showStatus("配置无效,无法同步", false);
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus("正在同步...", true);
|
||||
binding.syncButton.setEnabled(false);
|
||||
|
||||
// 在后台线程执行同步
|
||||
App.execute(() -> {
|
||||
try {
|
||||
// 先上传本地记录
|
||||
syncManager.uploadHistory();
|
||||
// 再下载远程记录并合并
|
||||
boolean downloadSuccess = syncManager.downloadHistory();
|
||||
|
||||
App.post(() -> {
|
||||
// 检查对话框是否还存在
|
||||
if (binding == null || dialog == null || !dialog.isShowing()) {
|
||||
return;
|
||||
}
|
||||
binding.syncButton.setEnabled(true);
|
||||
if (downloadSuccess) {
|
||||
showStatus("同步完成", true);
|
||||
Notify.show("同步完成");
|
||||
} else {
|
||||
showStatus("同步完成(本地数据已上传)", true);
|
||||
Notify.show("同步完成");
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
App.post(() -> {
|
||||
// 检查对话框是否还存在
|
||||
if (binding == null || dialog == null || !dialog.isShowing()) {
|
||||
return;
|
||||
}
|
||||
binding.syncButton.setEnabled(true);
|
||||
showStatus("同步失败:" + e.getMessage(), false);
|
||||
Notify.show("同步失败");
|
||||
Logger.e("WebDAV: 同步失败: " + e.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void onSelectInterval() {
|
||||
String[] intervals = {"15", "30", "60", "120", "240"};
|
||||
int currentInterval = Setting.getWebDAVSyncInterval();
|
||||
int selectedIndex = 0;
|
||||
for (int i = 0; i < intervals.length; i++) {
|
||||
if (Integer.parseInt(intervals[i]) == currentInterval) {
|
||||
selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
new MaterialAlertDialogBuilder(binding.getRoot().getContext())
|
||||
.setTitle("选择同步间隔")
|
||||
.setSingleChoiceItems(intervals, selectedIndex, (dialog, which) -> {
|
||||
int interval = Integer.parseInt(intervals[which]);
|
||||
binding.syncIntervalText.setText(String.valueOf(interval));
|
||||
// 立即保存同步间隔
|
||||
Setting.putWebDAVSyncInterval(interval);
|
||||
dialog.dismiss();
|
||||
})
|
||||
.setNegativeButton("取消", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void showStatus(String message, boolean isSuccess) {
|
||||
// 检查对话框是否还存在
|
||||
if (binding == null || dialog == null || !dialog.isShowing()) {
|
||||
return;
|
||||
}
|
||||
binding.statusText.setText(message);
|
||||
binding.statusText.setVisibility(TextUtils.isEmpty(message) ? View.GONE : View.VISIBLE);
|
||||
// 可以根据isSuccess设置不同的颜色
|
||||
binding.statusText.setTextColor(isSuccess ?
|
||||
fragment.getResources().getColor(R.color.white) :
|
||||
fragment.getResources().getColor(android.R.color.holo_red_dark));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务器URL(根据选择的服务提供商)
|
||||
*/
|
||||
private String getServerUrl() {
|
||||
if (selectedProvider == PROVIDERS.length - 1) {
|
||||
// 自定义,从输入框获取
|
||||
return binding.urlText.getText().toString().trim();
|
||||
} else {
|
||||
// 使用预设URL或从输入框获取(Nextcloud/ownCloud)
|
||||
String providerUrl = getProviderUrl();
|
||||
if (!TextUtils.isEmpty(providerUrl)) {
|
||||
return providerUrl;
|
||||
} else {
|
||||
// Nextcloud或ownCloud需要用户输入
|
||||
return binding.urlText.getText().toString().trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void onPositive(DialogInterface dialog, int which) {
|
||||
String url = getServerUrl();
|
||||
String username = binding.usernameText.getText().toString().trim();
|
||||
String password = binding.passwordText.getText().toString().trim();
|
||||
boolean autoSync = binding.autoSyncSwitch.isChecked();
|
||||
int interval = Integer.parseInt(binding.syncIntervalText.getText().toString());
|
||||
|
||||
// 验证输入
|
||||
if (TextUtils.isEmpty(url)) {
|
||||
Notify.show("请选择服务提供商或输入服务器地址");
|
||||
return;
|
||||
}
|
||||
if (TextUtils.isEmpty(username)) {
|
||||
Notify.show("请输入用户名");
|
||||
return;
|
||||
}
|
||||
if (TextUtils.isEmpty(password)) {
|
||||
Notify.show("请输入密码");
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
Setting.putWebDAVUrl(url);
|
||||
Setting.putWebDAVUsername(username);
|
||||
Setting.putWebDAVPassword(password);
|
||||
Setting.putWebDAVAutoSync(autoSync);
|
||||
Setting.putWebDAVSyncInterval(interval);
|
||||
|
||||
// 重新加载配置
|
||||
syncManager.reloadConfig();
|
||||
|
||||
// 配置保存后,立即执行一次同步(下载远程数据)
|
||||
// 这样新设备配置后就能立即看到其他设备的历史记录
|
||||
if (syncManager.isConfigured()) {
|
||||
Notify.show("WebDAV配置已保存,正在同步数据...");
|
||||
App.execute(() -> {
|
||||
try {
|
||||
// 先上传本地记录
|
||||
syncManager.uploadHistory();
|
||||
// 再下载远程记录并合并
|
||||
boolean downloadSuccess = syncManager.downloadHistory();
|
||||
App.post(() -> {
|
||||
if (downloadSuccess) {
|
||||
Notify.show("同步完成,已获取远程观看记录");
|
||||
} else {
|
||||
Notify.show("同步完成(本地数据已上传)");
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
App.post(() -> {
|
||||
Notify.show("同步失败,请检查网络连接");
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
Notify.show("WebDAV配置已保存");
|
||||
}
|
||||
|
||||
dialog.dismiss();
|
||||
|
||||
// 通知设置界面更新状态(通过RefreshEvent)
|
||||
// 使用App.post确保对话框关闭后再发送事件,让状态能及时更新
|
||||
App.post(() -> RefreshEvent.config());
|
||||
}
|
||||
|
||||
private void onNegative(DialogInterface dialog, int which) {
|
||||
dialog.dismiss();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新加载配置(用于外部调用)
|
||||
*/
|
||||
public void reloadConfig() {
|
||||
syncManager.reloadConfig();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,9 +48,11 @@ import com.fongmi.android.tv.ui.dialog.LiveDialog;
|
||||
import com.fongmi.android.tv.ui.dialog.ProxyDialog;
|
||||
import com.fongmi.android.tv.ui.dialog.RestoreDialog;
|
||||
import com.fongmi.android.tv.ui.dialog.SiteDialog;
|
||||
import com.fongmi.android.tv.ui.dialog.WebDAVDialog;
|
||||
import com.fongmi.android.tv.utils.FileChooser;
|
||||
import com.fongmi.android.tv.utils.FileUtil;
|
||||
import com.fongmi.android.tv.utils.Notify;
|
||||
import com.fongmi.android.tv.utils.WebDAVSyncManager;
|
||||
import com.fongmi.android.tv.utils.ResUtil;
|
||||
import com.fongmi.android.tv.utils.UrlUtil;
|
||||
import com.github.catvod.bean.Doh;
|
||||
@@ -59,6 +61,10 @@ import com.github.catvod.utils.Path;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.permissionx.guolindev.PermissionX;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -119,9 +125,32 @@ public class SettingFragment extends BaseFragment implements ConfigCallback, Sit
|
||||
mBinding.incognitoSwitch.setChecked(Setting.isIncognito());
|
||||
mBinding.liveTabVisibleSwitch.setChecked(Setting.isLiveTabVisible());
|
||||
mBinding.sizeText.setText((size = ResUtil.getStringArray(R.array.select_size))[Setting.getSize()]);
|
||||
setWebDAVStatus();
|
||||
setLiveSettingsVisibility();
|
||||
}
|
||||
|
||||
private void setWebDAVStatus() {
|
||||
WebDAVSyncManager manager = WebDAVSyncManager.get();
|
||||
if (manager.isConfigured()) {
|
||||
// 显示账号昵称(用户名)
|
||||
String username = Setting.getWebDAVUsername();
|
||||
if (!TextUtils.isEmpty(username)) {
|
||||
// 如果用户名是邮箱,只显示@前面的部分
|
||||
String displayName = username;
|
||||
if (username.contains("@")) {
|
||||
displayName = username.substring(0, username.indexOf("@"));
|
||||
}
|
||||
String status = Setting.isWebDAVAutoSync() ? displayName + "(自动同步)" : displayName;
|
||||
mBinding.webdavStatusText.setText(status);
|
||||
} else {
|
||||
String status = Setting.isWebDAVAutoSync() ? "已配置(自动同步)" : "已配置";
|
||||
mBinding.webdavStatusText.setText(status);
|
||||
}
|
||||
} else {
|
||||
mBinding.webdavStatusText.setText("未配置");
|
||||
}
|
||||
}
|
||||
|
||||
private void setLiveSettingsVisibility() {
|
||||
boolean isLiveTabVisible = !Setting.isLiveTabVisible(); // 注意:这里取反,因为开关是"隐藏直播"
|
||||
|
||||
@@ -178,6 +207,7 @@ public class SettingFragment extends BaseFragment implements ConfigCallback, Sit
|
||||
mBinding.liveTabVisibleSwitch.setOnClickListener(this::setLiveTabVisible);
|
||||
mBinding.size.setOnClickListener(this::setSize);
|
||||
mBinding.doh.setOnClickListener(this::setDoh);
|
||||
mBinding.webdav.setOnClickListener(this::onWebDAV);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -498,12 +528,35 @@ public class SettingFragment extends BaseFragment implements ConfigCallback, Sit
|
||||
}));
|
||||
}
|
||||
|
||||
private void onWebDAV(View view) {
|
||||
WebDAVDialog.create(this).show();
|
||||
}
|
||||
|
||||
private void initConfig() {
|
||||
WallConfig.get().init();
|
||||
LiveConfig.get().init().load();
|
||||
VodConfig.get().init().load(getCallback(0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
EventBus.getDefault().register(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
EventBus.getDefault().unregister(this);
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
public void onRefreshEvent(RefreshEvent event) {
|
||||
if (event.getType() == RefreshEvent.Type.CONFIG) {
|
||||
setWebDAVStatus();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHiddenChanged(boolean hidden) {
|
||||
if (hidden) return;
|
||||
@@ -511,6 +564,7 @@ public class SettingFragment extends BaseFragment implements ConfigCallback, Sit
|
||||
setSourceHintText(mBinding.liveUrl, LiveConfig.getDesc(), R.string.source_hint_live);
|
||||
// setSourceHintText(mBinding.wallUrl, WallConfig.getDesc(), R.string.source_hint_wall); // 壁纸功能已移除
|
||||
setCacheText();
|
||||
setWebDAVStatus(); // 更新WebDAV状态
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingEnd="24dp"
|
||||
android:paddingBottom="16dp">
|
||||
|
||||
<!-- 说明文字 -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="请输入您在WebDAV服务提供商(如坚果云)注册的账号和密码(坚果云的密码为应用密码而非登录密码)"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="12sp"
|
||||
android:alpha="0.7"
|
||||
android:lineSpacingMultiplier="1.2" />
|
||||
|
||||
<!-- 服务提供商选择 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="服务提供商"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/providerText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="end"
|
||||
android:text="坚果云"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:padding="12dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 服务器地址(自定义时显示) -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/urlInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:visibility="gone"
|
||||
app:hintEnabled="false">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/urlText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="WebDAV服务器地址(如:https://example.com/webdav)"
|
||||
android:imeOptions="actionNext"
|
||||
android:importantForAutofill="no"
|
||||
android:inputType="textUri"
|
||||
android:singleLine="true" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- 用户名 -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/usernameInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:hintEnabled="false">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/usernameText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="用户名"
|
||||
android:imeOptions="actionNext"
|
||||
android:importantForAutofill="no"
|
||||
android:inputType="text"
|
||||
android:singleLine="true" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- 密码 -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/passwordInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:hintEnabled="false"
|
||||
app:passwordToggleEnabled="true">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/passwordText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="密码"
|
||||
android:imeOptions="actionDone"
|
||||
android:importantForAutofill="no"
|
||||
android:inputType="textPassword"
|
||||
android:singleLine="true" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- 自动同步开关 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="自动同步"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<com.fongmi.android.tv.ui.custom.CustomSwitch
|
||||
android:id="@+id/autoSyncSwitch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 同步间隔 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/syncIntervalContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="同步间隔(分钟)"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/syncIntervalText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="30"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 操作按钮区域 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="end">
|
||||
|
||||
<!-- 测试连接按钮 -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/testButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="测试连接"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton" />
|
||||
|
||||
<!-- 立即同步按钮 -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/syncButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="立即同步"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 状态提示 -->
|
||||
<TextView
|
||||
android:id="@+id/statusText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center"
|
||||
android:text=""
|
||||
android:textColor="@color/white"
|
||||
android:textSize="14sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -252,6 +252,43 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- WebDAV同步配置 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/webdav"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:src="@drawable/ic_fab_link"
|
||||
android:tint="@color/white" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="WebDAV"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/webdavStatusText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="end"
|
||||
android:text="未配置"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="14sp"
|
||||
android:alpha="0.7" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 无痕模式 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/incognito"
|
||||
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
|
||||
VERSION="3.1.1"
|
||||
DESKTOP_PATH="$HOME/Desktop"
|
||||
PROJECT_PATH="/Users/chen/Desktop/XMBOX-3.1.0"
|
||||
|
||||
cd "$PROJECT_PATH"
|
||||
|
||||
echo "========================================="
|
||||
echo " 构建 XMBOX 所有 Release 包 (v${VERSION})"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
echo "=== 1. 清理旧的构建文件 ==="
|
||||
./gradlew clean
|
||||
|
||||
echo ""
|
||||
echo "=== 2. 构建所有 Release APK ==="
|
||||
./gradlew assembleMobileArm64_v8aRelease \
|
||||
assembleMobileArmeabi_v7aRelease \
|
||||
assembleLeanbackArm64_v8aRelease \
|
||||
assembleLeanbackArmeabi_v7aRelease
|
||||
|
||||
echo ""
|
||||
echo "=== 3. 复制 APK 到桌面 ==="
|
||||
|
||||
# 定义APK路径和输出文件名
|
||||
declare -a APKS=(
|
||||
"app/build/outputs/apk/mobileArm64_v8a/release/mobile-arm64_v8a.apk|mobile-arm64_v8a-v${VERSION}.apk"
|
||||
"app/build/outputs/apk/mobileArmeabi_v7a/release/mobile-armeabi_v7a.apk|mobile-armeabi_v7a-v${VERSION}.apk"
|
||||
"app/build/outputs/apk/leanbackArm64_v8a/release/leanback-arm64_v8a.apk|leanback-arm64_v8a-v${VERSION}.apk"
|
||||
"app/build/outputs/apk/leanbackArmeabi_v7a/release/leanback-armeabi_v7a.apk|leanback-armeabi_v7a-v${VERSION}.apk"
|
||||
)
|
||||
|
||||
SUCCESS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
for apk_info in "${APKS[@]}"; do
|
||||
IFS='|' read -r source_path target_name <<< "$apk_info"
|
||||
|
||||
if [ -f "$source_path" ]; then
|
||||
cp "$source_path" "$DESKTOP_PATH/$target_name"
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ $target_name"
|
||||
ls -lh "$DESKTOP_PATH/$target_name" | awk '{print " 大小: " $5}'
|
||||
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
|
||||
else
|
||||
echo "❌ 复制失败: $target_name"
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
fi
|
||||
else
|
||||
echo "❌ 文件不存在: $source_path"
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
if [ $FAIL_COUNT -eq 0 ]; then
|
||||
echo "✅ 所有 APK 构建并复制成功!"
|
||||
echo " 成功: $SUCCESS_COUNT 个"
|
||||
echo " 位置: $DESKTOP_PATH"
|
||||
else
|
||||
echo "⚠️ 构建完成,但有 $FAIL_COUNT 个失败"
|
||||
echo " 成功: $SUCCESS_COUNT 个"
|
||||
echo " 失败: $FAIL_COUNT 个"
|
||||
fi
|
||||
echo "========================================="
|
||||
|
||||
Regular → Executable
Regular → Executable
@@ -49,6 +49,77 @@ public class Github {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将GitHub Release下载URL转换为jsDelivr CDN URL
|
||||
* 例如: https://github.com/Tosencen/XMBOX/releases/download/v3.1.0/mobile-arm64_v8a-v3.1.0.apk
|
||||
* 转换为: https://cdn.jsdelivr.net/gh/Tosencen/XMBOX@v3.1.0/mobile-arm64_v8a-v3.1.0.apk
|
||||
*
|
||||
* @param githubUrl GitHub Release下载URL
|
||||
* @param tagName Release标签名(如 "v3.1.0")
|
||||
* @param fileName 文件名
|
||||
* @return jsDelivr CDN URL
|
||||
*/
|
||||
public static String convertToJsDelivrUrl(String githubUrl, String tagName, String fileName) {
|
||||
try {
|
||||
// 尝试从GitHub URL中提取信息
|
||||
// 格式: https://github.com/{owner}/{repo}/releases/download/{tag}/{file}
|
||||
if (githubUrl.contains("/releases/download/")) {
|
||||
String[] parts = githubUrl.split("/releases/download/");
|
||||
if (parts.length == 2) {
|
||||
String basePath = parts[0]; // https://github.com/Tosencen/XMBOX
|
||||
String[] baseParts = basePath.split("/");
|
||||
if (baseParts.length >= 2) {
|
||||
String owner = baseParts[baseParts.length - 2];
|
||||
String repo = baseParts[baseParts.length - 1];
|
||||
// 使用jsDelivr CDN格式
|
||||
String jsDelivrUrl = "https://cdn.jsdelivr.net/gh/" + owner + "/" + repo + "@" + tagName + "/" + fileName;
|
||||
Logger.d("Github: URL转换: " + githubUrl + " -> " + jsDelivrUrl);
|
||||
return jsDelivrUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果无法匹配,使用默认仓库信息构建
|
||||
String jsDelivrUrl = "https://cdn.jsdelivr.net/gh/Tosencen/XMBOX@" + tagName + "/" + fileName;
|
||||
Logger.d("Github: 使用默认格式构建URL: " + jsDelivrUrl);
|
||||
return jsDelivrUrl;
|
||||
} catch (Exception e) {
|
||||
Logger.e("Github: URL转换失败: " + e.getMessage());
|
||||
// 转换失败时返回原URL
|
||||
return githubUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将GitHub raw URL转换为jsDelivr CDN URL
|
||||
* 例如: https://raw.githubusercontent.com/Tosencen/XMBOX-Release/main/apk/release/mobile-arm64_v8a-v3.1.0.apk
|
||||
* 转换为: https://cdn.jsdelivr.net/gh/Tosencen/XMBOX-Release@main/apk/release/mobile-arm64_v8a-v3.1.0.apk
|
||||
*
|
||||
* @param rawUrl GitHub raw URL
|
||||
* @return jsDelivr CDN URL,如果转换失败则返回原URL
|
||||
*/
|
||||
public static String convertRawToJsDelivrUrl(String rawUrl) {
|
||||
try {
|
||||
// 格式: https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}
|
||||
if (rawUrl.contains("raw.githubusercontent.com/")) {
|
||||
String path = rawUrl.substring(rawUrl.indexOf("raw.githubusercontent.com/") + "raw.githubusercontent.com/".length());
|
||||
String[] parts = path.split("/", 3);
|
||||
if (parts.length >= 3) {
|
||||
String owner = parts[0];
|
||||
String repo = parts[1];
|
||||
String filePath = parts[2];
|
||||
String jsDelivrUrl = "https://cdn.jsdelivr.net/gh/" + owner + "/" + repo + "@main/" + filePath;
|
||||
Logger.d("Github: Raw URL转换: " + rawUrl + " -> " + jsDelivrUrl);
|
||||
return jsDelivrUrl;
|
||||
}
|
||||
}
|
||||
// 转换失败时返回原URL
|
||||
return rawUrl;
|
||||
} catch (Exception e) {
|
||||
Logger.e("Github: Raw URL转换失败: " + e.getMessage());
|
||||
return rawUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// 智能检测是否使用国内镜像
|
||||
public static boolean useCnMirror() {
|
||||
// 如果已经测试过并且在24小时内,直接返回上次的结果
|
||||
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
# XMBOX 项目清理脚本
|
||||
# 清理构建产物和临时文件
|
||||
|
||||
echo "开始清理项目..."
|
||||
|
||||
# 清理构建目录
|
||||
echo "清理构建目录..."
|
||||
./gradlew clean
|
||||
|
||||
# 删除系统文件
|
||||
echo "删除系统文件..."
|
||||
find . -name ".DS_Store" -type f -delete
|
||||
find . -name "Thumbs.db" -type f -delete
|
||||
find . -name "*.swp" -type f -delete
|
||||
find . -name "*~" -type f -delete
|
||||
|
||||
# 删除临时文件
|
||||
echo "删除临时文件..."
|
||||
find . -name "*.tmp" -type f -delete
|
||||
find . -name "*.temp" -type f -delete
|
||||
|
||||
# 删除构建缓存
|
||||
echo "删除构建缓存..."
|
||||
rm -rf .gradle
|
||||
rm -rf build
|
||||
rm -rf app/build
|
||||
rm -rf quickjs/build
|
||||
rm -rf catvod/build
|
||||
rm -rf */build
|
||||
|
||||
# 删除IDE配置文件
|
||||
echo "删除IDE配置文件..."
|
||||
rm -rf .vscode
|
||||
rm -rf .vs
|
||||
rm -rf .idea/workspace.xml
|
||||
rm -rf .idea/tasks.xml
|
||||
|
||||
echo "✅ 清理完成!"
|
||||
echo "项目已清理,可以提交到Git"
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,69 +0,0 @@
|
||||
{
|
||||
"name": "广告过滤配置示例",
|
||||
"description": "演示如何配置广告域名黑名单,阻止视频中途弹出的广告(如澳门新葡京等博彩广告)",
|
||||
|
||||
"说明": {
|
||||
"内置拦截": "应用已内置常见广告域名库,包括:澳门新葡京、皇冠、金沙等博彩广告;Google、百度、淘宝等广告联盟;优酷、爱奇艺等视频平台广告",
|
||||
"自定义拦截": "可以在配置文件中添加ads字段,补充需要拦截的广告域名",
|
||||
"支持正则": "域名支持正则表达式匹配,使用 .* 作为通配符"
|
||||
},
|
||||
|
||||
"配置示例": {
|
||||
"spider": "your_spider_url",
|
||||
"sites": [],
|
||||
|
||||
"ads": [
|
||||
"注释: 以下是自定义广告域名列表,会与内置域名库合并使用",
|
||||
|
||||
"注释: 精确匹配 - 直接写完整域名",
|
||||
"mimg.0c1q0l.cn",
|
||||
"www.92424.cn",
|
||||
"vip.ffzyad.com",
|
||||
|
||||
"注释: 模糊匹配 - 使用通配符",
|
||||
".*\\.doubleclick\\.net",
|
||||
".*\\.googlesyndication\\.com",
|
||||
|
||||
"注释: 关键词匹配 - 拦截包含特定关键词的域名",
|
||||
".*葡京.*",
|
||||
".*皇冠.*",
|
||||
".*金沙.*",
|
||||
".*casino.*",
|
||||
".*bet.*",
|
||||
|
||||
"注释: 特定平台的广告",
|
||||
"wan.51img1.com",
|
||||
"k.jinxiuzhilv.com",
|
||||
"ssl.kdd.cc"
|
||||
]
|
||||
},
|
||||
|
||||
"常见问题": {
|
||||
"Q1": "为什么配置了还是有广告?",
|
||||
"A1": "1. 检查广告域名是否正确;2. 某些广告可能直接嵌入视频流,无法通过域名拦截;3. 尝试使用片头片尾跳过功能",
|
||||
|
||||
"Q2": "如何找到广告的域名?",
|
||||
"A2": "1. 使用浏览器开发者工具查看网络请求;2. 查看应用日志中的URL;3. 参考其他用户分享的广告域名列表",
|
||||
|
||||
"Q3": "会不会误拦截正常内容?",
|
||||
"A3": "内置域名库经过筛选,主要针对已知广告。如有误拦截,可以反馈给开发者"
|
||||
},
|
||||
|
||||
"片头片尾跳过": {
|
||||
"说明": "对于嵌入视频流中的广告,可以使用片头片尾跳过功能",
|
||||
"使用方法": [
|
||||
"1. 播放视频时,在片头(前5分钟内)按【片头】按钮,记录当前时间点",
|
||||
"2. 在片尾(后5分钟内)按【片尾】按钮,记录结束前的时间点",
|
||||
"3. 下次播放相同视频时,会自动跳过片头,并在片尾前停止",
|
||||
"4. 如需重置,长按对应按钮即可"
|
||||
]
|
||||
},
|
||||
|
||||
"技术说明": {
|
||||
"拦截层级": "WebView网络请求层拦截",
|
||||
"拦截方式": "返回空响应,阻止广告内容加载",
|
||||
"性能影响": "极小,仅在WebView解析时生效",
|
||||
"隐私保护": "所有拦截在本地进行,不上传任何数据"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
{
|
||||
"lives": [
|
||||
{
|
||||
"name": "M3U",
|
||||
"url": "file://Download/live.m3u"
|
||||
},
|
||||
{
|
||||
"name": "TXT",
|
||||
"url": "file://Download/live.txt",
|
||||
"epg": "https://epg.112114.xyz/?ch={name}&date={date}",
|
||||
"logo": "https://epg.112114.xyz/logo/{name}.png"
|
||||
},
|
||||
{
|
||||
"name": "UA",
|
||||
"url": "file://Download/live.txt",
|
||||
"epg": "https://epg.112114.xyz/?ch={name}&date={date}",
|
||||
"logo": "https://epg.112114.xyz/logo/{name}.png",
|
||||
"ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
|
||||
"referer": "https://github.com/"
|
||||
},
|
||||
{
|
||||
"name": "Custom",
|
||||
"boot": false,
|
||||
"pass": true,
|
||||
"url": "file://Download/live.txt",
|
||||
"epg": "https://epg.112114.xyz/?ch={name}&date={date}&serverTimeZone=Asia/Shanghai",
|
||||
"logo": "https://epg.112114.xyz/logo/{name}.png",
|
||||
"header": {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
|
||||
"Referer": "https://github.com/"
|
||||
},
|
||||
"catchup": {
|
||||
"days": "7",
|
||||
"type": "append",
|
||||
"regex": "/PLTV/",
|
||||
"replace": "/PLTV/,/TVOD/",
|
||||
"source": "?playseek=${(b)yyyyMMddHHmmss}-${(e)yyyyMMddHHmmss}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "JSON",
|
||||
"type": 1,
|
||||
"url": "file://Download/live.json"
|
||||
},
|
||||
{
|
||||
"name": "Spider-JS",
|
||||
"type": 3,
|
||||
"api": "./live.js",
|
||||
"ext": ""
|
||||
},
|
||||
{
|
||||
"name": "Spider-Python",
|
||||
"type": 3,
|
||||
"api": "./live.py",
|
||||
"ext": ""
|
||||
}
|
||||
],
|
||||
"headers": [
|
||||
{
|
||||
"host": "gslbserv.itv.cmvideo.cn",
|
||||
"header": {
|
||||
"User-Agent": "okhttp/3.12.13"
|
||||
}
|
||||
}
|
||||
],
|
||||
"proxy": [
|
||||
"raw.githubusercontent.com"
|
||||
],
|
||||
"hosts": [
|
||||
"cache.ott.ystenlive.itv.cmvideo.cn=base-v4-free-mghy.e.cdn.chinamobile.com"
|
||||
],
|
||||
"ads": [
|
||||
"static-mozai.4gtv.tv"
|
||||
]
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
{
|
||||
"lives": [
|
||||
{
|
||||
"name": "M3U",
|
||||
"url": "https://github.com/live.m3u"
|
||||
},
|
||||
{
|
||||
"name": "TXT",
|
||||
"url": "https://github.com/live.txt",
|
||||
"epg": "https://epg.112114.xyz/?ch={name}&date={date}",
|
||||
"logo": "https://epg.112114.xyz/logo/{name}.png"
|
||||
},
|
||||
{
|
||||
"name": "UA",
|
||||
"url": "https://github.com/live.txt",
|
||||
"epg": "https://epg.112114.xyz/?ch={name}&date={date}",
|
||||
"logo": "https://epg.112114.xyz/logo/{name}.png",
|
||||
"ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
|
||||
"referer": "https://github.com/"
|
||||
},
|
||||
{
|
||||
"name": "Custom",
|
||||
"boot": false,
|
||||
"pass": true,
|
||||
"url": "https://github.com/live.txt",
|
||||
"epg": "https://epg.112114.xyz/?ch={name}&date={date}&serverTimeZone=Asia/Shanghai",
|
||||
"logo": "https://epg.112114.xyz/logo/{name}.png",
|
||||
"header": {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
|
||||
"Referer": "https://github.com/"
|
||||
},
|
||||
"catchup": {
|
||||
"days": "7",
|
||||
"type": "append",
|
||||
"regex": "/PLTV/",
|
||||
"replace": "/PLTV/,/TVOD/",
|
||||
"source": "?playseek=${(b)yyyyMMddHHmmss}-${(e)yyyyMMddHHmmss}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "JSON",
|
||||
"type": 1,
|
||||
"url": "https://github.com/live.json"
|
||||
},
|
||||
{
|
||||
"name": "Spider-JS",
|
||||
"type": 3,
|
||||
"api": "https://github.com/live.js",
|
||||
"ext": ""
|
||||
},
|
||||
{
|
||||
"name": "Spider-Python",
|
||||
"type": 3,
|
||||
"api": "https://github.com/live.py",
|
||||
"ext": ""
|
||||
}
|
||||
],
|
||||
"headers": [
|
||||
{
|
||||
"host": "gslbserv.itv.cmvideo.cn",
|
||||
"header": {
|
||||
"User-Agent": "okhttp/3.12.13"
|
||||
}
|
||||
}
|
||||
],
|
||||
"proxy": [
|
||||
"raw.githubusercontent.com"
|
||||
],
|
||||
"hosts": [
|
||||
"cache.ott.ystenlive.itv.cmvideo.cn=base-v4-free-mghy.e.cdn.chinamobile.com"
|
||||
],
|
||||
"ads": [
|
||||
"static-mozai.4gtv.tv"
|
||||
]
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
{
|
||||
"spider": "file://Download/custom_spider.jar",
|
||||
"sites": [
|
||||
{
|
||||
"key": "one",
|
||||
"name": "One",
|
||||
"type": 3,
|
||||
"api": "csp_Csp",
|
||||
"searchable": 1,
|
||||
"changeable": 1,
|
||||
"ext": "file://Download/one.json"
|
||||
},
|
||||
{
|
||||
"key": "two",
|
||||
"name": "Two",
|
||||
"type": 3,
|
||||
"api": "csp_Csp",
|
||||
"searchable": 1,
|
||||
"changeable": 1,
|
||||
"ext": "file://Download/two.json"
|
||||
},
|
||||
{
|
||||
"key": "extend",
|
||||
"name": "Extend",
|
||||
"type": 3,
|
||||
"api": "csp_Csp",
|
||||
"searchable": 1,
|
||||
"changeable": 1,
|
||||
"ext": "file://Download/extend.json",
|
||||
"jar": "file://Download/extend.jar"
|
||||
}
|
||||
],
|
||||
"parses": [
|
||||
{
|
||||
"name": "官方",
|
||||
"type": 1,
|
||||
"url": "https://google.com/api/?url="
|
||||
}
|
||||
],
|
||||
"doh": [
|
||||
{
|
||||
"name": "Google",
|
||||
"url": "https://dns.google/dns-query",
|
||||
"ips": [
|
||||
"8.8.4.4",
|
||||
"8.8.8.8"
|
||||
]
|
||||
}
|
||||
],
|
||||
"headers": [
|
||||
{
|
||||
"host": "gslbserv.itv.cmvideo.cn",
|
||||
"header": {
|
||||
"User-Agent": "okhttp/3.12.13"
|
||||
}
|
||||
}
|
||||
],
|
||||
"proxy": [
|
||||
"raw.githubusercontent.com"
|
||||
],
|
||||
"hosts": [
|
||||
"cache.ott.ystenlive.itv.cmvideo.cn=base-v4-free-mghy.e.cdn.chinamobile.com"
|
||||
],
|
||||
"flags": [
|
||||
"qq"
|
||||
],
|
||||
"ads": [
|
||||
"static-mozai.4gtv.tv"
|
||||
]
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
{
|
||||
"spider": "https://github.com/custom_spider.jar",
|
||||
"sites": [
|
||||
{
|
||||
"key": "one",
|
||||
"name": "One",
|
||||
"type": 3,
|
||||
"api": "csp_Csp",
|
||||
"searchable": 1,
|
||||
"changeable": 1,
|
||||
"ext": "https://github.com/one.json"
|
||||
},
|
||||
{
|
||||
"key": "two",
|
||||
"name": "Two",
|
||||
"type": 3,
|
||||
"api": "csp_Csp",
|
||||
"searchable": 1,
|
||||
"changeable": 1,
|
||||
"ext": "https://github.com/two.json"
|
||||
},
|
||||
{
|
||||
"key": "extend",
|
||||
"name": "Extend",
|
||||
"type": 3,
|
||||
"api": "csp_Csp",
|
||||
"searchable": 1,
|
||||
"changeable": 1,
|
||||
"ext": "https://github.com/extend.json",
|
||||
"jar": "https://github.com/extend.jar"
|
||||
}
|
||||
],
|
||||
"parses": [
|
||||
{
|
||||
"name": "官方",
|
||||
"type": 1,
|
||||
"url": "https://google.com/api/?url="
|
||||
}
|
||||
],
|
||||
"doh": [
|
||||
{
|
||||
"name": "Google",
|
||||
"url": "https://dns.google/dns-query",
|
||||
"ips": [
|
||||
"8.8.4.4",
|
||||
"8.8.8.8"
|
||||
]
|
||||
}
|
||||
],
|
||||
"headers": [
|
||||
{
|
||||
"host": "gslbserv.itv.cmvideo.cn",
|
||||
"header": {
|
||||
"User-Agent": "okhttp/3.12.13"
|
||||
}
|
||||
}
|
||||
],
|
||||
"proxy": [
|
||||
"raw.githubusercontent.com"
|
||||
],
|
||||
"hosts": [
|
||||
"cache.ott.ystenlive.itv.cmvideo.cn=base-v4-free-mghy.e.cdn.chinamobile.com"
|
||||
],
|
||||
"flags": [
|
||||
"qq"
|
||||
],
|
||||
"ads": [
|
||||
"static-mozai.4gtv.tv"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user