14 Commits

Author SHA1 Message Date
您的名字 59a4096b37 WebDAV同步功能重大改进:修复编码问题和双向同步
主要改进:
1. 修复观看记录key中站点名称的编码问题(电视版乱码修复)
2. 实现智能合并策略,支持时间和进度比较
3. 自动修复过期时间戳,确保记录能正常显示
4. 上传和下载都使用findAllRecent(0),确保完整同步
5. 添加详细日志,方便调试定位问题

技术细节:
- 新增fixHistoryKey()方法,单独修复key中的站点名称部分
- 改进合并算法,考虑时间相近、进度领先等多种情况
- 修复createTime超过60天被过滤的问题
- 统一本地和远程记录的编码处理

删除的文件:
- other/sample/* - 示例配置文件
- other/image/* - 示例图片
- .vscode/settings.json - 编辑器配置
2025-11-18 10:22:38 +08:00
您的名字 156cecc848 项目清理:删除临时文件和示例目录,增强项目规范
- 删除 .vscode/ 编辑器配置
- 删除 other/ 示例和工具目录
- 增强 .gitignore,防止IDE配置和临时文件被提交
- 添加 CONTRIBUTING.md 贡献指南
- 添加 clean_project.sh 项目清理脚本
- 清理所有 .DS_Store 系统文件
2025-11-18 10:22:25 +08:00
您的名字 98da628aee docs: 更新README.md,添加v3.1.1版本说明和WebDAV同步功能介绍 2025-11-10 19:06:51 +08:00
您的名字 e0aee44d5a feat: 同步本地代码到远程仓库
- 新增WebDAV同步功能相关文件
- 新增CustomSwitch自定义开关组件
- 新增SyncCodeManager、UpdateInstaller、WebDAVSyncManager工具类
- 新增build_all_release.sh构建脚本
- 更新多个Dialog和Activity文件
- 更新字符串资源文件
- 删除apk/release目录下的旧文件
2025-11-10 19:05:21 +08:00
Tochen e7e215628b Update README.md 2025-11-10 17:17:52 +08:00
您的名字 a0888f7930 chore: 清理项目并规范化
- 删除重复的 XMBOX-Release 目录(与 apk/ 目录功能重复)
- 删除临时清理脚本和无关工具文件(bfg.jar, cleaner.bat)
- 从 Git 中移除本地配置文件(gradle.properties.local)
- 删除空的 local-repo 目录和 .DS_Store 文件
- 更新项目名称:rootProject.name 从 'TV' 改为 'XMBOX'
- 更新通知渠道名称从 'TV' 改为 'XMBOX'
- 更新关于对话框文本,移除 GitHub 链接
- 更新 .gitignore,添加更多忽略规则
- 从 settings.gradle 中注释掉未使用的 chaquo 模块
2025-11-09 18:22:19 +08:00
您的名字 3441bbc8f0 fix: 恢复 JSON 方式检测更新,GitHub API 作为备用
- 优先使用 XMBOX-Release 仓库的 JSON 文件检测更新
- JSON 检测失败时回退到 GitHub Releases API
- 修复 v3.0.9 无法检测更新的问题
- 提升更新检测的兼容性和稳定性
2025-10-28 20:25:56 +08:00
您的名字 13bc801b12 chore: 添加 v3.1.0 版本的 APK 文件
- mobile-arm64_v8a-v3.1.0.apk (34.4MB)
- mobile-armeabi_v7a-v3.1.0.apk (30.4MB)
- leanback-arm64_v8a-v3.1.0.apk (34.5MB)
- leanback-armeabi_v7a-v3.1.0.apk (30.5MB)
2025-10-28 20:25:56 +08:00
您的名字 9f3b631dfb chore: 发布 v3.1.0 版本 APK
- 添加 v3.1.0 版本所有架构的 APK 文件
- 更新版本信息 JSON 文件
- 手机版: mobile-arm64_v8a (34.4MB), mobile-armeabi_v7a (30.4MB)
- TV版: leanback-arm64_v8a (34.5MB), leanback-armeabi_v7a (30.5MB)
2025-10-28 20:25:49 +08:00
您的名字 8cfea9ef79 chore: 发布 v3.1.0 版本
- 更新 README.md 到 v3.1.0
- 添加 v3.1.0 版本的 JSON 配置文件
- 更新根目录的版本信息 JSON
2025-10-28 19:53:50 +08:00
您的名字 4a8a84a4eb feat: 升级到v3.1.0
- 实现定时按钮倒计时显示功能
- 适配pixel主题化图标展示
- 优化TimerDialog按钮宽度设计
2025-10-28 19:49:49 +08:00
您的名字 56251db9e7 feat: 优化播放进度条交互体验
- 修复拖拽时圆球消失问题
- 添加动态轨道高度变化效果(按住时4dp,松开时2dp)
- 优化圆球大小设置(固定14dp)
- 添加ProGuard规则保护DefaultTimeBar反射字段
- 改进触摸事件处理逻辑
- 增强拖拽体验的流畅性

修复内容:
- CustomSeekView: 重构触摸事件处理和动态高度调整
- 布局文件: 统一设置圆球大小为14dp
- ProGuard: 保护Media3 DefaultTimeBar字段不被混淆
2025-10-28 19:49:49 +08:00
Tochen 8357ebefcf Update README.md 2025-10-24 15:31:56 +08:00
Tochen b16cb4d193 Update README.md 2025-10-24 15:28:27 +08:00
76 changed files with 4807 additions and 805 deletions
+32
View File
@@ -35,3 +35,35 @@ google-services.json
# APK files # APK files
apk/release/*.apk apk/release/*.apk
# Local configuration files
gradle.properties.local
local.properties
local-repo/
# Temporary files
cleanup_project.sh
*.tmp
*.temp
# 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/
-3
View File
@@ -1,3 +0,0 @@
{
"java.configuration.updateBuildConfiguration": "automatic"
}
+77
View File
@@ -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`
+47 -18
View File
@@ -1,8 +1,8 @@
<h1 align="center"> 📱 XMBOX - Android资源播放器 <h1 align="center"> 📱 XMBOX - Android视频播放器
</h1> </h1>
<div align="center"> <div align="center">
![Version](https://img.shields.io/badge/version-3.0.9-blue.svg) ![Version](https://img.shields.io/badge/version-3.1.1-blue.svg)
![Android](https://img.shields.io/badge/platform-Android-green.svg) ![Android](https://img.shields.io/badge/platform-Android-green.svg)
![License](https://img.shields.io/badge/license-GPL--3.0-orange.svg) ![License](https://img.shields.io/badge/license-GPL--3.0-orange.svg)
![Build](https://img.shields.io/badge/build-passing-brightgreen.svg) ![Build](https://img.shields.io/badge/build-passing-brightgreen.svg)
@@ -10,6 +10,7 @@
一个操作方便、界面简洁的Android视频播放器盒子,需自行添源,支持TV和手机双平台。 一个操作方便、界面简洁的Android视频播放器盒子,需自行添源,支持TV和手机双平台。
[下载APK](https://github.com/Tosencen/XMBOX-Release/tree/main/apk/release) • [功能特性](#-功能特性) • [构建指南](#-构建指南) • [API文档](#-api文档) [下载APK](https://github.com/Tosencen/XMBOX-Release/tree/main/apk/release) • [功能特性](#-功能特性) • [构建指南](#-构建指南) • [API文档](#-api文档)
<img width="1920" height="864" alt="Group 15" src="https://github.com/user-attachments/assets/e69741bd-a21d-417e-ad85-e747032f6daf" />
</div> </div>
@@ -33,20 +34,22 @@
- 🛡️ **稳定可靠** - 完善的错误处理和崩溃防护 - 🛡️ **稳定可靠** - 完善的错误处理和崩溃防护
- 🌐 **网络优化** - 智能代理和DNS解析 - 🌐 **网络优化** - 智能代理和DNS解析
- 📱 **Material Design** - 现代化UI设计 - 📱 **Material Design** - 现代化UI设计
- ☁️ **WebDAV同步** - 支持观看记录和设置云端同步,支持账号模式和同步码模式
## 📥 下载安装 ## 📥 下载安装
### 最新版本: v3.0.9 ### 最新版本: v3.1.1
| 平台 | ARM64-V8A | ARM V7A | | 平台 | ARM64-V8A | ARM V7A |
|------|-----------|---------| |------|-----------|---------|
| **📱 手机版** | [下载 (35.8MB)](https://github.com/Tosencen/XMBOX-Release/raw/main/apk/release/v3.0.9/mobile-arm64_v8a-v3.0.9.apk) | [下载 (31.6MB)](https://github.com/Tosencen/XMBOX-Release/raw/main/apk/release/v3.0.9/mobile-armeabi_v7a-v3.0.9.apk) | | **📱 手机版** | [下载 (34.4MB)](https://github.com/Tosencen/XMBOX-Release/raw/main/apk/release/v3.1.0/mobile-arm64_v8a-v3.1.0.apk) | [下载 (30.4MB)](https://github.com/Tosencen/XMBOX-Release/raw/main/apk/release/v3.1.0/mobile-armeabi_v7a-v3.1.0.apk) |
| **📺 TV版** | [下载 (35.9MB)](https://github.com/Tosencen/XMBOX-Release/raw/main/apk/release/v3.0.9/leanback-arm64_v8a-v3.0.9.apk) | [下载 (31.7MB)](https://github.com/Tosencen/XMBOX-Release/raw/main/apk/release/v3.0.9/leanback-armeabi_v7a-v3.0.9.apk) | | **📺 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.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交互体验全面优化 - **v3.0.8**: [查看v3.0.8版本](https://github.com/Tosencen/XMBOX-Release/tree/main/apk/release/v3.0.8) - UI交互体验全面优化
- **v3.0.7**: [查看v3.0.7版本](https://github.com/Tosencen/XMBOX-Release/tree/main/apk/release/v3.0.7) - 全面优化稳定性和用户体验
### 📦 下载说明 ### 📦 下载说明
- **最新版本**: 根目录的 `mobile.json``leanback.json` 包含最新版本信息 - **最新版本**: 根目录的 `mobile.json``leanback.json` 包含最新版本信息
@@ -133,23 +136,54 @@ 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)
#### ✨ 新功能
* **定时倒计时显示** - 实现定时按钮倒计时显示功能,用户可以实时看到剩余时间
* **主题化图标适配** - 适配pixel主题化图标展示,提升视觉体验
#### 🎨 UI优化
* **TimerDialog优化** - 优化TimerDialog按钮宽度设计,界面更加协调
* **进度条交互改进** - 优化播放进度条交互体验,操作更流畅
* **视觉一致性提升** - 改进界面视觉一致性,整体更加统一
#### 🐛 修复
* **更新链接修复** - 修复更新跳转链接,现在可以正确跳转到具体版本页面
#### 🔧 技术改进
* **定时功能优化** - 提升定时功能用户体验
* **内存优化** - 进一步优化内存使用
* **稳定性增强** - 提升播放稳定性
### v3.0.9 (2025-10-24) ### v3.0.9 (2025-10-24)
#### ✨ 新功能 #### ✨ 新功能
* **直播开关控制** - 新增直播tab显示/隐藏开关,用户可根据需要控制直播功能 * **直播开关控制** - 新增直播tab显示/隐藏开关,用户可根据需要控制直播功能
* **实时倍速显示** - 播放控制对话框新增实时倍速数值显示,提升用户体验 * **实时倍速显示** - 播放控制对话框新增实时倍速数值显示,提升用户体验
* **源管理优化** - 优化源管理模块间距动态调整,界面更加协调
#### 🎨 UI优化 #### 🎨 UI优化
* **滑杆交互优化** - 滑杆圆球大小优化至20dp直径,提升操作体验
* **刻度显示改进** - 改进滑杆刻度显示,非激活轨道显示刻度,激活轨道保持干净 * **刻度显示改进** - 改进滑杆刻度显示,非激活轨道显示刻度,激活轨道保持干净
* **播放进度条增强** - 增强播放进度条动态大小调整功能,修复圆球跳回问题 * **播放进度条增强** - 增强播放进度条动态大小调整功能,修复圆球跳回问题
* **直播开关逻辑** - 完善直播开关逻辑和UI交互,确保功能一致性
#### 🔧 技术改进
* **优化内存使用** - 进一步优化内存管理机制
* **提升播放稳定性** - 增强播放器稳定性
* **增强UI交互体验** - 改进用户界面交互响应
### v3.0.8 (2025-10-14) ### v3.0.8 (2025-10-14)
@@ -162,11 +196,6 @@ XMBOX/
* **修复文字重叠** - 解决跨类和换源按钮的文字重叠问题 * **修复文字重叠** - 解决跨类和换源按钮的文字重叠问题
* **提升视觉一致性** - 整体UI视觉一致性和用户体验优化 * **提升视觉一致性** - 整体UI视觉一致性和用户体验优化
#### 🔧 技术改进
* **优化内存使用** - 改进内存管理机制
* **提升播放稳定性** - 增强播放器稳定性
* **文件结构重组** - 按版本号重新组织发布文件结构
### v3.0.5 (2025-08-20) ### v3.0.5 (2025-08-20)
#### 🎨 界面优化 #### 🎨 界面优化
- 优化导航栏历史记录图标,采用 Material Design 3 规范的列表图标 - 优化导航栏历史记录图标,采用 Material Design 3 规范的列表图标
-62
View File
@@ -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) - 增量签名
-9
View File
@@ -1,9 +0,0 @@
{
"name": "3.0.8",
"desc": "XMBOX TV版 v3.0.8 (Android TV/机顶盒专用)\n\n✨ UI优化:\n• 修复按钮点击效果过于明显的问题\n• 统一使用自定义背景替代系统selectableItemBackgroundBorderless\n• 移除Control.Action样式中的文字阴影效果\n• 优化直播页面选择按钮颜色为主题黄色\n• 调整许可协议页面按钮区域上间距为8dp\n• 修复跨类和换源按钮的文字重叠问题\n• 提升整体UI视觉一致性和用户体验\n\n🔧 改进优化:\n• 优化大屏界面体验\n• 提升播放稳定性\n\n📺 专为电视优化:遥控器导航 | 10-foot UI | ARM64/ARMv7",
"code": 308,
"downloads": {
"arm64_v8a": "v3.0.8/leanback-arm64_v8a-v3.0.8.apk",
"armeabi_v7a": "v3.0.8/leanback-armeabi_v7a-v3.0.8.apk"
}
}
-9
View File
@@ -1,9 +0,0 @@
{
"name": "3.0.8",
"desc": "XMBOX 手机版 v3.0.8\n\n✨ UI优化:\n• 修复按钮点击效果过于明显的问题\n• 统一使用自定义背景替代系统selectableItemBackgroundBorderless\n• 移除Control.Action样式中的文字阴影效果\n• 优化直播页面选择按钮颜色为主题黄色\n• 调整许可协议页面按钮区域上间距为8dp\n• 修复跨类和换源按钮的文字重叠问题\n• 提升整体UI视觉一致性和用户体验\n\n🔧 改进优化:\n• 优化内存使用\n• 提升播放稳定性\n\n📱 支持架构:ARM64-v8a | ARMv7a",
"code": 308,
"downloads": {
"arm64_v8a": "v3.0.8/mobile-arm64_v8a-v3.0.8.apk",
"armeabi_v7a": "v3.0.8/mobile-armeabi_v7a-v3.0.8.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
}
-62
View File
@@ -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) - 增量签名
-9
View File
@@ -1,9 +0,0 @@
{
"name": "3.0.9",
"desc": "XMBOX TV版 v3.0.9 (Android TV/机顶盒专用)\n\n✨ 新功能:\n• 新增直播开关控制功能,可隐藏/显示直播tab\n• 新增实时倍速显示功能,播放控制对话框显示当前倍速\n• 优化源管理模块间距动态调整\n\n🎨 UI优化:\n• 滑杆圆球大小优化至20dp直径,提升操作体验\n• 改进滑杆刻度显示,非激活轨道显示刻度\n• 增强播放进度条动态大小调整功能\n• 修复播放进度条圆球跳回问题\n• 完善直播开关逻辑和UI交互\n\n🔧 改进优化:\n• 优化大屏界面体验\n• 提升播放稳定性\n• 增强UI交互体验\n\n📺 专为电视优化:遥控器导航 | 10-foot UI | ARM64/ARMv7",
"code": 309,
"downloads": {
"arm64_v8a": "v3.0.9/leanback-arm64_v8a-v3.0.9.apk",
"armeabi_v7a": "v3.0.9/leanback-armeabi_v7a-v3.0.9.apk"
}
}
-9
View File
@@ -1,9 +0,0 @@
{
"name": "3.0.9",
"desc": "XMBOX 手机版 v3.0.9\n\n✨ 新功能:\n• 新增直播开关控制功能,可隐藏/显示直播tab\n• 新增实时倍速显示功能,播放控制对话框显示当前倍速\n• 优化源管理模块间距动态调整\n\n🎨 UI优化:\n• 滑杆圆球大小优化至20dp直径,提升操作体验\n• 改进滑杆刻度显示,非激活轨道显示刻度\n• 增强播放进度条动态大小调整功能\n• 修复播放进度条圆球跳回问题\n• 完善直播开关逻辑和UI交互\n\n🔧 改进优化:\n• 优化内存使用\n• 提升播放稳定性\n• 增强UI交互体验\n\n📱 支持架构:ARM64-v8a | ARMv7a",
"code": 309,
"downloads": {
"arm64_v8a": "v3.0.9/mobile-arm64_v8a-v3.0.9.apk",
"armeabi_v7a": "v3.0.9/mobile-armeabi_v7a-v3.0.9.apk"
}
}
-5
View File
@@ -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
}
-5
View File
@@ -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
}
+5 -2
View File
@@ -27,8 +27,11 @@ android {
minSdk 24 minSdk 24
//noinspection ExpiredTargetSdkVersion //noinspection ExpiredTargetSdkVersion
targetSdk 28 targetSdk 28
versionCode 310 versionCode 311
versionName "3.1.0" versionName "3.1.1"
// GitHub Token (可选,用于提高API请求限制)
def githubToken = project.findProperty("GITHUB_TOKEN") ?: ""
buildConfigField "String", "GITHUB_TOKEN", "\"${githubToken}\""
javaCompileOptions { javaCompileOptions {
annotationProcessorOptions { annotationProcessorOptions {
arguments = ["room.schemaLocation": "$projectDir/schemas".toString(), "eventBusIndex": "com.fongmi.android.tv.event.EventIndex"] arguments = ["room.schemaLocation": "$projectDir/schemas".toString(), "eventBusIndex": "com.fongmi.android.tv.event.EventIndex"]
@@ -14,12 +14,14 @@ import com.fongmi.android.tv.utils.Download;
import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.FileUtil;
import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.Notify;
import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.ResUtil;
import com.fongmi.android.tv.utils.UpdateInstaller;
import com.github.catvod.net.OkHttp; import com.github.catvod.net.OkHttp;
import com.github.catvod.utils.Github; import com.github.catvod.utils.Github;
import com.github.catvod.utils.Logger; import com.github.catvod.utils.Logger;
import com.github.catvod.utils.Path; import com.github.catvod.utils.Path;
import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
import java.io.File; import java.io.File;
@@ -28,42 +30,34 @@ import java.util.Locale;
public class Updater implements Download.Callback { public class Updater implements Download.Callback {
private DialogUpdateBinding binding; private DialogUpdateBinding binding;
private final Download download; private Download download;
private AlertDialog dialog; private AlertDialog dialog;
private boolean dev; private boolean dev;
private boolean forceCheck; // 是否为手动检查 private boolean forceCheck; // 是否为手动检查
private boolean autoShow; // 是否自动显示更新对话框(用于自动检查)
private String latestVersion; // 存储检测到的最新版本 private String latestVersion; // 存储检测到的最新版本
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() { private File getFile() {
return Path.root("Download", "XMBOX-update.apk"); // Android 10+ 无法直接访问外部存储的Download目录
} // 使用应用的cache目录,FileProvider可以正常访问
return Path.cache("XMBOX-update.apk");
private String getJson() {
return Github.getJson(dev, BuildConfig.FLAVOR_mode);
} }
private String getApk() { private String getApk() {
// 使用JSON中指定的具体下载路径 // 使用从 GitHub Release 获取的 APK URLjsDelivr CDN
try { if (releaseApkUrl != null && !releaseApkUrl.isEmpty()) {
String response = OkHttp.string(getJson()); Logger.d("APK download URL from Release (jsDelivr): " + releaseApkUrl);
JSONObject object = new JSONObject(response); return releaseApkUrl;
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";
return baseUrl + "/apk/" + (dev ? "dev" : "release") + "/" + downloadPath;
} }
} // 如果没有获取到URL,返回空(不应该发生)
} catch (Exception e) { Logger.e("Updater: 未找到APK下载链接");
Logger.e("Failed to get download path from JSON: " + e.getMessage()); return "";
}
// 回退到原来的方式
return Github.getApk(dev, BuildConfig.FLAVOR_mode + "-" + BuildConfig.FLAVOR_abi);
} }
public static Updater create() { public static Updater create() {
@@ -71,8 +65,9 @@ public class Updater implements Download.Callback {
} }
public Updater() { public Updater() {
this.download = Download.create(getApk(), getFile(), this);
this.forceCheck = false; this.forceCheck = false;
this.autoShow = false;
// download对象将在需要时创建
} }
public Updater force() { public Updater force() {
@@ -82,6 +77,15 @@ public class Updater implements Download.Callback {
return this; return this;
} }
/**
* 设置自动检查模式(应用启动时自动检查)
*/
public Updater auto() {
this.forceCheck = false;
this.autoShow = true; // 自动显示更新对话框
return this;
}
public Updater release() { public Updater release() {
this.dev = false; this.dev = false;
return this; return this;
@@ -98,6 +102,16 @@ public class Updater implements Download.Callback {
} }
public void start(Activity activity) { 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)); App.execute(() -> doInBackground(activity));
} }
@@ -106,22 +120,62 @@ public class Updater implements Download.Callback {
} }
private void doInBackground(Activity activity) { private void doInBackground(Activity activity) {
try { Logger.d("Updater: Starting update check...");
// 直接使用GitHub Releases API检测最新版本 lastCheckTime = System.currentTimeMillis(); // 更新检查时间
String releasesUrl = "https://api.github.com/repos/Tosencen/XMBOX/releases/latest"; // 直接使用 GitHub Releases API 检查更新
String response = OkHttp.string(releasesUrl); checkViaGitHubAPI(activity);
}
// 检查响应是否包含错误信息 private void checkViaGitHubAPI(Activity activity) {
if (response.contains("rate limit exceeded")) { try {
String releasesUrl = "https://api.github.com/repos/Tosencen/XMBOX/releases/latest";
Logger.d("Updater: Trying GitHub Releases API: " + 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);
}
// 检查响应是否为空(可能是网络错误、VPN问题等)
if (response == null || response.isEmpty()) {
Logger.e("Updater: 网络请求失败,响应为空。可能是网络连接问题或VPN配置问题");
if (forceCheck) { if (forceCheck) {
App.post(() -> Notify.show("检查更新失败:API请求过于频繁,请稍后重试")); // 手动检查时,显示错误提示
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(() -> showVersionInfo(activity, BuildConfig.VERSION_NAME, ""));
} }
return; return;
} }
if (response.contains("Not Found") || response.contains("404")) { if (response.contains("Not Found") || response.contains("404")) {
Logger.e("Updater: Release not found");
if (forceCheck) { if (forceCheck) {
App.post(() -> Notify.show("检查更新失败:更新服务暂时不可用")); // 手动检查时,显示版本信息弹窗(不显示错误提示)
App.post(() -> showVersionInfo(activity, BuildConfig.VERSION_NAME, ""));
} }
return; return;
} }
@@ -129,32 +183,112 @@ public class Updater implements Download.Callback {
JSONObject release = new JSONObject(response); JSONObject release = new JSONObject(response);
String tagName = release.optString("tag_name"); String tagName = release.optString("tag_name");
String body = release.optString("body"); String body = release.optString("body");
// 提取版本号(去掉v前缀)
String version = tagName.startsWith("v") ? tagName.substring(1) : tagName; String version = tagName.startsWith("v") ? tagName.substring(1) : tagName;
Logger.d("Updater: GitHub API Remote version: " + version);
// 从 assets 中查找 APK
JSONArray assets = release.optJSONArray("assets");
if (assets != null) {
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);
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)) { if (needUpdate(version)) {
this.latestVersion = version; // 保存最新版本号 this.latestVersion = version;
// 有新版本时,自动显示或手动显示更新对话框
App.post(() -> show(activity, version, body)); App.post(() -> show(activity, version, body));
} else { } else {
// 没有新版本
if (forceCheck) { if (forceCheck) {
App.post(() -> Notify.show("已是最新版本 " + version)); // 手动检查时,显示版本信息弹窗
App.post(() -> showVersionInfo(activity, version, body));
} else if (autoShow) {
// 自动检查时,不显示任何内容(静默检查)
Logger.d("Updater: 自动检查完成,当前已是最新版本");
} }
} }
} catch (Exception e) { } catch (Exception e) {
Logger.e("Updater: GitHub API check failed: " + e.getMessage());
e.printStackTrace(); e.printStackTrace();
if (forceCheck) { if (forceCheck) {
// 手动检查时,显示错误提示
String errorMsg = e.getMessage();
if (errorMsg != null && (errorMsg.contains("network") || errorMsg.contains("timeout") || errorMsg.contains("connect"))) {
App.post(() -> { App.post(() -> {
String errorMsg = "检查更新失败"; Notify.show("检查更新失败:网络连接异常,请检查网络设置或VPN配置");
if (e.getMessage() != null && e.getMessage().contains("rate limit")) { showVersionInfo(activity, BuildConfig.VERSION_NAME, "");
errorMsg = "检查更新失败:请求过于频繁,请稍后重试";
} else if (e.getMessage() != null && e.getMessage().contains("Not Found")) {
errorMsg = "检查更新失败:更新服务暂时不可用";
} else {
errorMsg = "检查更新失败,请稍后重试";
}
Notify.show(errorMsg);
}); });
} else {
App.post(() -> showVersionInfo(activity, BuildConfig.VERSION_NAME, ""));
}
} else {
Logger.w("Updater: 自动检查失败: " + e.getMessage());
} }
} }
} }
@@ -196,42 +330,55 @@ public class Updater implements Download.Callback {
binding.desc.setText(desc); 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) { 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) { private void cancel(View view) {
Setting.putUpdate(false); Setting.putUpdate(false);
if (download != null) {
download.cancel(); download.cancel();
dismiss(); }
dialog.dismiss();
} }
private void confirm(View view) { private void confirm(View view) {
// 跳转到具体版本的GitHub Releases页面 // 开始下载更新(使用jsDelivr CDN,失败时回退到GitHub
try { String downloadUrl = getApk();
String url = "https://github.com/Tosencen/XMBOX/releases/tag/v" + latestVersion; String fallbackUrl = this.fallbackApkUrl;
Logger.d("Updater: Attempting to open URL: " + url);
Intent intent = new Intent(Intent.ACTION_VIEW); // 检查URL是否为空
intent.setData(Uri.parse(url)); if (downloadUrl == null || downloadUrl.isEmpty()) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); Logger.e("Updater: 下载URL为空,无法下载");
Notify.show("无法获取下载链接,请稍后重试或手动下载");
return;
}
// 检查是否有应用可以处理这个Intent Logger.d("Updater: 开始下载,URL: " + downloadUrl);
if (intent.resolveActivity(App.get().getPackageManager()) != null) {
App.get().startActivity(intent); // 创建带回退URL的下载对象
Logger.d("Updater: Successfully started browser intent"); this.download = Download.create(downloadUrl, getFile(), fallbackUrl, this);
dismiss(); this.download.start();
} 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();
}
} }
private void dismiss() { private void dismiss() {
@@ -254,7 +401,30 @@ public class Updater implements Download.Callback {
@Override @Override
public void success(File file) { public void success(File file) {
FileUtil.openFile(file); // 使用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(); dismiss();
} }
} }
}
@@ -245,7 +245,24 @@ public class HomeActivity extends BaseActivity implements CustomTitleView.Listen
} }
private void getHistory(boolean renew) { 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 historyIndex = getHistoryIndex();
int recommendIndex = getRecommendIndex(); int recommendIndex = getRecommendIndex();
boolean exist = recommendIndex - historyIndex == 2; boolean exist = recommendIndex - historyIndex == 2;
@@ -3,6 +3,7 @@ package com.fongmi.android.tv.ui.activity;
import android.Manifest; import android.Manifest;
import android.app.Activity; import android.app.Activity;
import android.content.Intent; import android.content.Intent;
import android.text.TextUtils;
import android.view.View; import android.view.View;
import androidx.viewbinding.ViewBinding; 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.ProxyDialog;
import com.fongmi.android.tv.ui.dialog.RestoreDialog; import com.fongmi.android.tv.ui.dialog.RestoreDialog;
import com.fongmi.android.tv.ui.dialog.SiteDialog; 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.FileChooser;
import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.FileUtil;
import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.Notify;
import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.ResUtil;
import com.fongmi.android.tv.utils.UrlUtil; import com.fongmi.android.tv.utils.UrlUtil;
import com.fongmi.android.tv.utils.WebDAVSyncManager;
import com.github.catvod.bean.Doh; import com.github.catvod.bean.Doh;
import com.github.catvod.net.OkHttp; import com.github.catvod.net.OkHttp;
import com.github.catvod.utils.Path; 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.liveTabVisibleText.setText(getSwitch(Setting.isLiveTabVisible()));
mBinding.sizeText.setText((size = ResUtil.getStringArray(R.array.select_size))[Setting.getSize()]); mBinding.sizeText.setText((size = ResUtil.getStringArray(R.array.select_size))[Setting.getSize()]);
mBinding.qualityText.setText((quality = ResUtil.getStringArray(R.array.select_quality))[Setting.getQuality()]); mBinding.qualityText.setText((quality = ResUtil.getStringArray(R.array.select_quality))[Setting.getQuality()]);
setWebDAVStatus();
setLiveSettingsVisibility(); 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() { private void setLiveSettingsVisibility() {
boolean isLiveTabVisible = !Setting.isLiveTabVisible(); // 注意:这里取反,因为开关是"隐藏直播" boolean isLiveTabVisible = !Setting.isLiveTabVisible(); // 注意:这里取反,因为开关是"隐藏直播"
mBinding.live.setVisibility(isLiveTabVisible ? View.VISIBLE : View.GONE); 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.quality.setOnClickListener(this::setQuality);
mBinding.size.setOnClickListener(this::setSize); mBinding.size.setOnClickListener(this::setSize);
mBinding.doh.setOnClickListener(this::setDoh); mBinding.doh.setOnClickListener(this::setDoh);
mBinding.webdav.setOnClickListener(this::onWebDAV);
} }
@Override @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() { private void initConfig() {
WallConfig.get().init(); WallConfig.get().init();
LiveConfig.get().init().load(); LiveConfig.get().init().load();
VodConfig.get().init().load(getCallback(0)); 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 @Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, 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); params.width = (int) (ResUtil.getScreenWidth() * 0.55f);
dialog.getWindow().setAttributes(params); dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0); dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.setOnDismissListener(this); dialog.setOnDismissListener(this);
dialog.show(); dialog.show();
} }
@@ -23,6 +23,8 @@ public class DescDialog {
DialogDescBinding binding = DialogDescBinding.inflate(LayoutInflater.from(activity)); DialogDescBinding binding = DialogDescBinding.inflate(LayoutInflater.from(activity));
AlertDialog dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).create(); AlertDialog dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).create();
dialog.getWindow().setDimAmount(0); dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
initView(binding.text, desc, activity); initView(binding.text, desc, activity);
dialog.show(); dialog.show();
} }
@@ -55,6 +55,8 @@ public class DohDialog implements DohAdapter.OnClickListener {
params.width = (int) (ResUtil.getScreenWidth() * 0.4f); params.width = (int) (ResUtil.getScreenWidth() * 0.4f);
dialog.getWindow().setAttributes(params); dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0); dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.show(); dialog.show();
} }
@@ -56,6 +56,8 @@ public class HistoryDialog implements ConfigAdapter.OnClickListener {
params.width = (int) (ResUtil.getScreenWidth() * 0.4f); params.width = (int) (ResUtil.getScreenWidth() * 0.4f);
dialog.getWindow().setAttributes(params); dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0); dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.show(); dialog.show();
} }
@@ -57,6 +57,8 @@ public class LiveDialog implements LiveAdapter.OnClickListener {
params.width = (int) (ResUtil.getScreenWidth() * 0.4f); params.width = (int) (ResUtil.getScreenWidth() * 0.4f);
dialog.getWindow().setAttributes(params); dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0); dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.show(); dialog.show();
} }
@@ -54,6 +54,8 @@ public class ProxyDialog implements DialogInterface.OnDismissListener {
params.width = (int) (ResUtil.getScreenWidth() * 0.55f); params.width = (int) (ResUtil.getScreenWidth() * 0.55f);
dialog.getWindow().setAttributes(params); dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0); dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.setOnDismissListener(this); dialog.setOnDismissListener(this);
dialog.show(); dialog.show();
} }
@@ -52,6 +52,8 @@ public class RestoreDialog implements RestoreAdapter.OnClickListener {
params.width = (int) (ResUtil.getScreenWidth() * 0.4f); params.width = (int) (ResUtil.getScreenWidth() * 0.4f);
dialog.getWindow().setAttributes(params); dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0); dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.show(); dialog.show();
} }
@@ -110,6 +110,8 @@ public class SiteDialog implements SiteAdapter.OnClickListener {
params.width = (int) (ResUtil.getScreenWidth() * getWidth()); params.width = (int) (ResUtil.getScreenWidth() * getWidth());
dialog.getWindow().setAttributes(params); dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0); dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.show(); dialog.show();
} }
@@ -55,6 +55,8 @@ public class UaDialog implements DialogInterface.OnDismissListener {
params.width = (int) (ResUtil.getScreenWidth() * 0.55f); params.width = (int) (ResUtil.getScreenWidth() * 0.55f);
dialog.getWindow().setAttributes(params); dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0); dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.setOnDismissListener(this); dialog.setOnDismissListener(this);
dialog.show(); 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); params.width = (int) (ResUtil.getScreenWidth() * 0.8f);
dialog.getWindow().setAttributes(params); dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0); dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.show(); dialog.show();
} }
} }
@@ -235,6 +235,36 @@
</LinearLayout> </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 <LinearLayout
android:id="@+id/incognito" android:id="@+id/incognito"
android:layout_width="match_parent" 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>
-1
View File
@@ -10,7 +10,6 @@
<item name="colorPrimary">@color/primary</item> <item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primaryDark</item> <item name="colorPrimaryDark">@color/primaryDark</item>
<item name="colorAccent">@color/accent</item> <item name="colorAccent">@color/accent</item>
<item name="colorControlHighlight">@color/primary</item>
<item name="android:windowFullscreen">true</item> <item name="android:windowFullscreen">true</item>
<item name="android:windowBackground">@null</item> <item name="android:windowBackground">@null</item>
<item name="android:windowDisablePreview">true</item> <item name="android:windowDisablePreview">true</item>
@@ -12,9 +12,12 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.os.HandlerCompat; 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.ui.activity.CrashActivity;
import com.fongmi.android.tv.utils.CacheCleaner; 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.android.tv.utils.Notify;
import com.fongmi.hook.Hook; import com.fongmi.hook.Hook;
import com.github.catvod.Init; import com.github.catvod.Init;
@@ -43,6 +46,7 @@ public class App extends Application {
private final long time; private final long time;
private Hook hook; private Hook hook;
private final Runnable cleanTask; private final Runnable cleanTask;
private final Runnable syncTask;
private boolean appJustLaunched; private boolean appJustLaunched;
public App() { public App() {
@@ -50,8 +54,11 @@ public class App extends Application {
executor = Executors.newFixedThreadPool(Constant.THREAD_POOL); executor = Executors.newFixedThreadPool(Constant.THREAD_POOL);
handler = HandlerCompat.createAsync(Looper.getMainLooper()); handler = HandlerCompat.createAsync(Looper.getMainLooper());
time = System.currentTimeMillis(); time = System.currentTimeMillis();
gson = new Gson(); gson = new com.google.gson.GsonBuilder()
.disableHtmlEscaping()
.create();
cleanTask = this::checkCacheClean; cleanTask = this::checkCacheClean;
syncTask = this::checkWebDAVSync;
appJustLaunched = true; appJustLaunched = true;
} }
@@ -129,8 +136,11 @@ public class App extends Application {
Logger.addLogAdapter(getLogAdapter()); Logger.addLogAdapter(getLogAdapter());
OkHttp.get().setProxy(Setting.getProxy()); OkHttp.get().setProxy(Setting.getProxy());
OkHttp.get().setDoh(Doh.objectFrom(Setting.getDoh())); 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(); 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();
// 初始化自动缓存清理 // 初始化自动缓存清理
initCacheCleaner(); initCacheCleaner();
@@ -151,6 +161,12 @@ public class App extends Application {
if (activity != activity()) setActivity(activity); if (activity != activity()) setActivity(activity);
// 应用回到前台时检查缓存 // 应用回到前台时检查缓存
checkCacheClean(); checkCacheClean();
// 检查是否有待安装的更新文件(用户从设置页面返回后)
checkPendingInstall();
// 检查WebDAV自动同步
checkWebDAVSync();
// 自动检查更新(如果启用)
checkAutoUpdate(activity);
} }
@Override @Override
@@ -188,6 +204,85 @@ public class App extends Application {
post(cleanTask, 30 * 60 * 1000); 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 @Override
public PackageManager getPackageManager() { public PackageManager getPackageManager() {
return hook != null ? hook : getBaseContext().getPackageManager(); return hook != null ? hook : getBaseContext().getPackageManager();
@@ -332,4 +332,97 @@ public class Setting {
public static void putLiveTabVisible(boolean visible) { public static void putLiveTabVisible(boolean visible) {
Prefers.put("live_tab_visible", 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); 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) { public static History find(String key) {
return AppDatabase.get().getHistoryDao().find(VodConfig.getCid(), key); return AppDatabase.get().getHistoryDao().find(VodConfig.getCid(), key);
} }
@@ -272,8 +276,15 @@ public class History {
} }
public void update() { public void update() {
try {
com.github.catvod.utils.Logger.d("History.update: 开始更新观看记录 key=" + getKey());
merge(find(), false); merge(find(), false);
save(); 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) { public History update(int cid) {
@@ -287,6 +298,7 @@ public class History {
} }
public History save() { public History save() {
com.github.catvod.utils.Logger.d("History.save: key=" + getKey() + ", vodName=" + getVodName());
AppDatabase.get().getHistoryDao().insertOrUpdate(this); AppDatabase.get().getHistoryDao().insertOrUpdate(this);
return this; return this;
} }
@@ -105,12 +105,37 @@ public class Site implements Parcelable {
public static Site objectFrom(JsonElement element) { public static Site objectFrom(JsonElement element) {
try { 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) { } catch (Exception e) {
return new Site(); 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) { public static Site get(String key) {
Site site = new Site(); Site site = new Site();
site.setKey(key); site.setKey(key);
@@ -133,6 +158,13 @@ public class Site implements Parcelable {
public void setKey(@NonNull String key) { public void setKey(@NonNull String key) {
this.key = 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() { public String getName() {
@@ -141,6 +173,15 @@ public class Site implements Parcelable {
public void setName(String name) { public void setName(String name) {
this.name = 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() { 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") @Query("SELECT * FROM History WHERE cid = :cid AND createTime >= :createTime ORDER BY createTime DESC")
public abstract List<History> find(int cid, long createTime); 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") @Query("SELECT * FROM History WHERE cid = :cid AND `key` = :key")
public abstract History find(int cid, String key); public abstract History find(int cid, String key);
@@ -18,53 +18,219 @@ public class Download {
private final File file; private final File file;
private final String url; private final String url;
private final String fallbackUrl;
private Callback callback; private Callback callback;
private static final int MAX_RETRY_COUNT = 3; // 最大重试次数
public static Download create(String url, File file) { public static Download create(String url, File file) {
return create(url, file, null); return create(url, file, null);
} }
public static Download create(String url, File file, Callback callback) { 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) { 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.url = url;
this.file = file; this.file = file;
this.fallbackUrl = fallbackUrl;
this.callback = callback; this.callback = callback;
} }
public void start() { public void start() {
if (url == null || url.isEmpty()) {
if (callback != null) {
App.post(() -> callback.error("下载URL为空"));
}
return;
}
if (url.startsWith("file")) return; if (url.startsWith("file")) return;
if (callback == null) doInBackground(); if (file == null) {
else App.execute(this::doInBackground); 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() { public void cancel() {
OkHttp.cancel(url); OkHttp.cancel(url);
if (fallbackUrl != null) {
OkHttp.cancel(fallbackUrl);
}
Path.clear(file); Path.clear(file);
callback = null; 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 { 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)) { try (BufferedInputStream input = new BufferedInputStream(is); FileOutputStream os = new FileOutputStream(file)) {
byte[] buffer = new byte[4096]; byte[] buffer = new byte[4096];
int readBytes; int readBytes;
@@ -72,39 +238,56 @@ public class Download {
while ((readBytes = input.read(buffer)) != -1) { while ((readBytes = input.read(buffer)) != -1) {
totalBytes += readBytes; totalBytes += readBytes;
os.write(buffer, 0, 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) { private boolean verifyDownloadedFile(File file, long expectedLength) {
try { 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()); Logger.e("File size mismatch: expected " + expectedLength + ", actual " + file.length());
return false; return false;
} }
// 检查APK文件头 (ZIP文件头) // 检查APK文件头 (ZIP文件头)
if (file.length() < 4) return false; if (file.length() < 4) {
Logger.e("File too small: " + file.length() + " bytes");
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");
return false; return false;
} }
// 额外验证:检查APK文件是否完整 try (FileInputStream fis = new FileInputStream(file)) {
// 尝试读取ZIP文件结构 byte[] header = new byte[4];
fis.getChannel().position(0); int bytesRead = fis.read(header);
byte[] buffer = new byte[1024];
int bytesRead = fis.read(buffer);
if (bytesRead < 4) { if (bytesRead < 4) {
Logger.e("APK file too small or corrupted"); Logger.e("Cannot read file header");
return false;
}
// 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; return false;
} }
} }
@@ -113,6 +296,7 @@ public class Download {
return true; return true;
} catch (Exception e) { } catch (Exception e) {
Logger.e("File verification failed: " + e.getMessage()); Logger.e("File verification failed: " + e.getMessage());
e.printStackTrace();
return false; return false;
} }
} }
@@ -4,6 +4,7 @@ import android.Manifest;
import android.app.Notification; import android.app.Notification;
import android.content.Context; import android.content.Context;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.text.TextUtils; import android.text.TextUtils;
@@ -40,7 +41,7 @@ public class Notify {
public static void createChannel() { public static void createChannel() {
NotificationManagerCompat notifyMgr = NotificationManagerCompat.from(App.get()); NotificationManagerCompat notifyMgr = NotificationManagerCompat.from(App.get());
notifyMgr.createNotificationChannel(new NotificationChannelCompat.Builder(DEFAULT, NotificationManagerCompat.IMPORTANCE_LOW).setName("TV").build()); notifyMgr.createNotificationChannel(new NotificationChannelCompat.Builder(DEFAULT, NotificationManagerCompat.IMPORTANCE_LOW).setName("XMBOX").build());
} }
public static String getError(int resId, Throwable e) { public static String getError(int resId, Throwable e) {
@@ -49,7 +50,7 @@ public class Notify {
} }
public static void show(Notification notification) { public static void show(Notification notification) {
if (ActivityCompat.checkSelfPermission(App.get(), Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return; if (Build.VERSION.SDK_INT >= 33 && ActivityCompat.checkSelfPermission(App.get(), Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return;
NotificationManagerCompat.from(App.get()).notify(ID, notification); NotificationManagerCompat.from(App.get()).notify(ID, notification);
} }
@@ -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();
}
/**
* 获取文件URLGitHub 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;
}
}
+1 -1
View File
@@ -91,7 +91,7 @@
<string name="setting_app">应用设置</string> <string name="setting_app">应用设置</string>
<string name="setting_network">网络设置</string> <string name="setting_network">网络设置</string>
<string name="setting_data">数据管理</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> <string name="about_github">在GitHub上查看</string>
<!-- Backup & Restore --> <!-- Backup & Restore -->
+1 -1
View File
@@ -89,7 +89,7 @@
<string name="setting_app">應用設置</string> <string name="setting_app">應用設置</string>
<string name="setting_network">網絡設置</string> <string name="setting_network">網絡設置</string>
<string name="setting_data">數據管理</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> <string name="about_github">在GitHub上查看</string>
<!-- Backup & Restore --> <!-- Backup & Restore -->
+1 -1
View File
@@ -92,7 +92,7 @@
<string name="setting_choose">Choose</string> <string name="setting_choose">Choose</string>
<string name="setting_off">Off</string> <string name="setting_off">Off</string>
<string name="setting_on">On</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> <string name="about_github">View on GitHub</string>
<!-- Backup & Restore --> <!-- Backup & Restore -->
@@ -14,12 +14,14 @@ import com.fongmi.android.tv.utils.Download;
import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.FileUtil;
import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.Notify;
import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.ResUtil;
import com.fongmi.android.tv.utils.UpdateInstaller;
import com.github.catvod.net.OkHttp; import com.github.catvod.net.OkHttp;
import com.github.catvod.utils.Github; import com.github.catvod.utils.Github;
import com.github.catvod.utils.Logger; import com.github.catvod.utils.Logger;
import com.github.catvod.utils.Path; import com.github.catvod.utils.Path;
import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
import java.io.File; import java.io.File;
@@ -28,46 +30,34 @@ import java.util.Locale;
public class Updater implements Download.Callback { public class Updater implements Download.Callback {
private DialogUpdateBinding binding; private DialogUpdateBinding binding;
private final Download download; private Download download;
private AlertDialog dialog; private AlertDialog dialog;
private boolean dev; private boolean dev;
private boolean forceCheck; // 是否为手动检查 private boolean forceCheck; // 是否为手动检查
private boolean autoShow; // 是否自动显示更新对话框用于自动检查
private String latestVersion; // 存储检测到的最新版本 private String latestVersion; // 存储检测到的最新版本
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() { private File getFile() {
return Path.root("Download", "XMBOX-update.apk"); // Android 10+ 无法直接访问外部存储的Download目录
} // 使用应用的cache目录FileProvider可以正常访问
return Path.cache("XMBOX-update.apk");
private String getJson() {
return Github.getJson(dev, BuildConfig.FLAVOR_mode);
} }
private String getApk() { private String getApk() {
// 使用JSON中指定的具体下载路径 // 使用 GitHub Release 获取的 APK URLjsDelivr CDN
try { if (releaseApkUrl != null && !releaseApkUrl.isEmpty()) {
String response = OkHttp.string(getJson()); Logger.d("APK download URL from Release (jsDelivr): " + releaseApkUrl);
JSONObject object = new JSONObject(response); return releaseApkUrl;
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;
} }
} // 如果没有获取到URL返回空不应该发生
} catch (Exception e) { Logger.e("Updater: 未找到APK下载链接");
Logger.e("Failed to get download path from JSON: " + e.getMessage()); return "";
}
// 回退到原来的方式
String fallbackUrl = Github.getApk(dev, BuildConfig.FLAVOR_mode + "-" + BuildConfig.FLAVOR_abi);
Logger.d("APK fallback URL: " + fallbackUrl);
return fallbackUrl;
} }
public static Updater create() { public static Updater create() {
@@ -75,8 +65,9 @@ public class Updater implements Download.Callback {
} }
public Updater() { public Updater() {
this.download = Download.create(getApk(), getFile(), this);
this.forceCheck = false; this.forceCheck = false;
this.autoShow = false;
// download对象将在需要时创建
} }
public Updater force() { public Updater force() {
@@ -86,6 +77,15 @@ public class Updater implements Download.Callback {
return this; return this;
} }
/**
* 设置自动检查模式应用启动时自动检查
*/
public Updater auto() {
this.forceCheck = false;
this.autoShow = true; // 自动显示更新对话框
return this;
}
public Updater release() { public Updater release() {
this.dev = false; this.dev = false;
return this; return this;
@@ -102,6 +102,16 @@ public class Updater implements Download.Callback {
} }
public void start(Activity activity) { 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)); App.execute(() -> doInBackground(activity));
} }
@@ -111,19 +121,52 @@ public class Updater implements Download.Callback {
private void doInBackground(Activity activity) { private void doInBackground(Activity activity) {
Logger.d("Updater: Starting update check..."); Logger.d("Updater: Starting update check...");
lastCheckTime = System.currentTimeMillis(); // 更新检查时间
// 直接使用 GitHub Releases API 检查更新
checkViaGitHubAPI(activity);
}
private void checkViaGitHubAPI(Activity activity) {
try { try {
// 直接使用GitHub Releases API检测最新版本
String releasesUrl = "https://api.github.com/repos/Tosencen/XMBOX/releases/latest"; String releasesUrl = "https://api.github.com/repos/Tosencen/XMBOX/releases/latest";
Logger.d("Updater: GitHub Releases API URL: " + releasesUrl); Logger.d("Updater: Trying GitHub Releases API: " + releasesUrl);
String response = OkHttp.string(releasesUrl); // 检查是否有GitHub Token
Logger.d("Updater: API response length: " + response.length()); 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);
}
// 检查响应是否包含错误信息 // 检查响应是否为空可能是网络错误VPN问题等
if (response.contains("rate limit exceeded")) { 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"); Logger.e("Updater: Rate limit exceeded");
if (forceCheck) { if (forceCheck) {
App.post(() -> Notify.show("检查更新失败:API请求过于频繁,请稍后重试")); // 手动检查时显示版本信息弹窗不显示错误提示
App.post(() -> showVersionInfo(activity, BuildConfig.VERSION_NAME, ""));
} }
return; return;
} }
@@ -131,7 +174,8 @@ public class Updater implements Download.Callback {
if (response.contains("Not Found") || response.contains("404")) { if (response.contains("Not Found") || response.contains("404")) {
Logger.e("Updater: Release not found"); Logger.e("Updater: Release not found");
if (forceCheck) { if (forceCheck) {
App.post(() -> Notify.show("检查更新失败:更新服务暂时不可用")); // 手动检查时显示版本信息弹窗不显示错误提示
App.post(() -> showVersionInfo(activity, BuildConfig.VERSION_NAME, ""));
} }
return; return;
} }
@@ -139,29 +183,113 @@ public class Updater implements Download.Callback {
JSONObject release = new JSONObject(response); JSONObject release = new JSONObject(response);
String tagName = release.optString("tag_name"); String tagName = release.optString("tag_name");
String body = release.optString("body"); String body = release.optString("body");
// 提取版本号去掉v前缀
String version = tagName.startsWith("v") ? tagName.substring(1) : tagName; String version = tagName.startsWith("v") ? tagName.substring(1) : tagName;
Logger.d("Updater: Remote version: " + version); Logger.d("Updater: GitHub API Remote version: " + version);
Logger.d("Updater: Local version: " + BuildConfig.VERSION_NAME);
// assets 中查找 APK
JSONArray assets = release.optJSONArray("assets");
if (assets != null) {
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);
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)) { if (needUpdate(version)) {
Logger.d("Updater: Update needed, showing dialog"); this.latestVersion = version;
this.latestVersion = version; // 保存最新版本号 // 有新版本时自动显示或手动显示更新对话框
App.post(() -> show(activity, version, body)); App.post(() -> show(activity, version, body));
} else { } else {
Logger.d("Updater: No update needed"); // 没有新版本
if (forceCheck) { if (forceCheck) {
App.post(() -> Notify.show("已是最新版本 " + version)); // 手动检查时显示版本信息弹窗
App.post(() -> showVersionInfo(activity, version, body));
} else if (autoShow) {
// 自动检查时不显示任何内容静默检查
Logger.d("Updater: 自动检查完成,当前已是最新版本");
} }
} }
} catch (Exception e) { } catch (Exception e) {
Logger.e("Updater: Exception during update check: " + e.getMessage()); Logger.e("Updater: GitHub API check failed: " + e.getMessage());
e.printStackTrace(); e.printStackTrace();
if (forceCheck) { 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());
} }
} }
} }
@@ -202,42 +330,51 @@ public class Updater implements Download.Callback {
binding.desc.setText(desc); 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) { 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(); 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) { private void cancel(View view) {
Setting.putUpdate(false); Setting.putUpdate(false);
if (download != null) {
download.cancel(); download.cancel();
}
dialog.dismiss(); dialog.dismiss();
} }
private void confirm(View view) { private void confirm(View view) {
// 跳转到具体版本的GitHub Releases页面 // 开始下载更新使用jsDelivr CDN失败时回退到GitHub
try { String downloadUrl = getApk();
String url = "https://github.com/Tosencen/XMBOX/releases/tag/v" + latestVersion; String fallbackUrl = this.fallbackApkUrl;
Logger.d("Updater: Attempting to open URL: " + url);
Intent intent = new Intent(Intent.ACTION_VIEW); // 检查URL是否为空
intent.setData(Uri.parse(url)); if (downloadUrl == null || downloadUrl.isEmpty()) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); Logger.e("Updater: 下载URL为空,无法下载");
Notify.show("无法获取下载链接,请稍后重试或手动下载");
return;
}
// 检查是否有应用可以处理这个Intent Logger.d("Updater: 开始下载,URL: " + downloadUrl);
if (intent.resolveActivity(App.get().getPackageManager()) != null) {
App.get().startActivity(intent); // 创建带回退URL的下载对象
Logger.d("Updater: Successfully started browser intent"); this.download = Download.create(downloadUrl, getFile(), fallbackUrl, this);
dismiss(); this.download.start();
} 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();
}
} }
private void dismiss() { private void dismiss() {
@@ -260,7 +397,30 @@ public class Updater implements Download.Callback {
@Override @Override
public void success(File file) { public void success(File file) {
FileUtil.openFile(file); // 使用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(); dismiss();
} }
} }
}
@@ -58,7 +58,7 @@ public class HistoryActivity extends BaseActivity implements HistoryAdapter.OnCl
} }
private void getHistory() { private void getHistory() {
mAdapter.addAll(History.get()); mAdapter.addAll(History.getAll()); // 显示所有视频源的观看记录
mBinding.delete.setVisibility(mAdapter.getItemCount() > 0 ? View.VISIBLE : View.GONE); mBinding.delete.setVisibility(mAdapter.getItemCount() > 0 ? View.VISIBLE : View.GONE);
updateEmptyState(); updateEmptyState();
} }
@@ -23,10 +23,12 @@ import android.text.Spanned;
import android.text.TextUtils; import android.text.TextUtils;
import android.text.format.DateFormat; import android.text.format.DateFormat;
import android.text.style.ClickableSpan; import android.text.style.ClickableSpan;
import android.view.KeyEvent;
import android.view.MotionEvent; import android.view.MotionEvent;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.WindowManager; import android.view.WindowManager;
import android.media.AudioManager;
import android.widget.RelativeLayout; import android.widget.RelativeLayout;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
@@ -166,6 +168,7 @@ public class VideoActivity extends BaseActivity implements Clock.Callback, Custo
private int mBatteryLevel = -1; private int mBatteryLevel = -1;
private boolean mIsCharging = false; private boolean mIsCharging = false;
private boolean mPausedByScreen = false; private boolean mPausedByScreen = false;
private AudioManager mAudioManager;
public static void push(FragmentActivity activity, String text) { public static void push(FragmentActivity activity, String text) {
if (FileChooser.isValid(activity, Uri.parse(text))) file(activity, FileChooser.getPathFromUri(activity, Uri.parse(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<>(); mDialogs = new ArrayList<>();
mBroken = new ArrayList<>(); mBroken = new ArrayList<>();
mClock = Clock.create(); mClock = Clock.create();
mAudioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
mR1 = this::hideControl; mR1 = this::hideControl;
mR2 = this::setTraffic; mR2 = this::setTraffic;
mR3 = this::setOrient; mR3 = this::setOrient;
@@ -1823,6 +1827,71 @@ public class VideoActivity extends BaseActivity implements Clock.Callback, Custo
setStop(true); 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 @Override
public void onBackPressed() { public void onBackPressed() {
if (isVisible(mBinding.control.getRoot())) { 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.ProxyDialog;
import com.fongmi.android.tv.ui.dialog.RestoreDialog; import com.fongmi.android.tv.ui.dialog.RestoreDialog;
import com.fongmi.android.tv.ui.dialog.SiteDialog; 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.FileChooser;
import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.FileUtil;
import com.fongmi.android.tv.utils.Notify; 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.ResUtil;
import com.fongmi.android.tv.utils.UrlUtil; import com.fongmi.android.tv.utils.UrlUtil;
import com.github.catvod.bean.Doh; 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.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.permissionx.guolindev.PermissionX; 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.ArrayList;
import java.util.List; import java.util.List;
@@ -119,9 +125,32 @@ public class SettingFragment extends BaseFragment implements ConfigCallback, Sit
mBinding.incognitoSwitch.setChecked(Setting.isIncognito()); mBinding.incognitoSwitch.setChecked(Setting.isIncognito());
mBinding.liveTabVisibleSwitch.setChecked(Setting.isLiveTabVisible()); mBinding.liveTabVisibleSwitch.setChecked(Setting.isLiveTabVisible());
mBinding.sizeText.setText((size = ResUtil.getStringArray(R.array.select_size))[Setting.getSize()]); mBinding.sizeText.setText((size = ResUtil.getStringArray(R.array.select_size))[Setting.getSize()]);
setWebDAVStatus();
setLiveSettingsVisibility(); 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() { private void setLiveSettingsVisibility() {
boolean isLiveTabVisible = !Setting.isLiveTabVisible(); // 注意这里取反因为开关是"隐藏直播" boolean isLiveTabVisible = !Setting.isLiveTabVisible(); // 注意这里取反因为开关是"隐藏直播"
@@ -178,6 +207,7 @@ public class SettingFragment extends BaseFragment implements ConfigCallback, Sit
mBinding.liveTabVisibleSwitch.setOnClickListener(this::setLiveTabVisible); mBinding.liveTabVisibleSwitch.setOnClickListener(this::setLiveTabVisible);
mBinding.size.setOnClickListener(this::setSize); mBinding.size.setOnClickListener(this::setSize);
mBinding.doh.setOnClickListener(this::setDoh); mBinding.doh.setOnClickListener(this::setDoh);
mBinding.webdav.setOnClickListener(this::onWebDAV);
} }
@Override @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() { private void initConfig() {
WallConfig.get().init(); WallConfig.get().init();
LiveConfig.get().init().load(); LiveConfig.get().init().load();
VodConfig.get().init().load(getCallback(0)); 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 @Override
public void onHiddenChanged(boolean hidden) { public void onHiddenChanged(boolean hidden) {
if (hidden) return; 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.liveUrl, LiveConfig.getDesc(), R.string.source_hint_live);
// setSourceHintText(mBinding.wallUrl, WallConfig.getDesc(), R.string.source_hint_wall); // 壁纸功能已移除 // setSourceHintText(mBinding.wallUrl, WallConfig.getDesc(), R.string.source_hint_wall); // 壁纸功能已移除
setCacheText(); setCacheText();
setWebDAVStatus(); // 更新WebDAV状态
} }
@Override @Override
+1 -1
View File
@@ -25,7 +25,7 @@
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="本项目仅用于学习Android开发,代码改自FongMi/TV (https://github.com/FongMi/TV)。" android:text="本项目仅用于学习Android开发,代码改自FongMi/TV项目。"
android:textColor="@color/white" android:textColor="@color/white"
android:textSize="14sp" android:textSize="14sp"
android:layout_marginTop="8dp" /> android:layout_marginTop="8dp" />
+206
View File
@@ -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> </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 <LinearLayout
android:id="@+id/incognito" android:id="@+id/incognito"
+69
View File
@@ -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
View File
Regular → Executable
View File
@@ -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() { public static boolean useCnMirror() {
// 如果已经测试过并且在24小时内直接返回上次的结果 // 如果已经测试过并且在24小时内直接返回上次的结果
+40
View File
@@ -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"
+1 -1
View File
@@ -491,7 +491,7 @@ include ':thunder'
include ':tvbus' include ':tvbus'
include ':zlive' include ':zlive'
rootProject.name = "TV" rootProject.name = "XMBOX"
EOF EOF
echo -e "${GREEN}依赖修复完成!${NC}" echo -e "${GREEN}依赖修复完成!${NC}"
-25
View File
@@ -1,25 +0,0 @@
# 配置Java路径,使用Java 17而不是默认的Java 21
org.gradle.java.home=/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home
# 增加构建内存
org.gradle.jvmargs=-Xmx4096m -XX:MaxPermSize=1024m -XX:+HeapDumpOnOutOfMemoryError
# 启用并行构建
org.gradle.parallel=true
org.gradle.caching=true
# 配置网络设置
systemProp.https.protocols=TLSv1.2,TLSv1.3
systemProp.https.proxyPort=0
systemProp.https.nonProxyHosts=localhost
# Android相关配置
android.useAndroidX=true
android.enableJetifier=true
android.jetifier.ignorelist=bcprov-jdk15on,annotation-experimental-1.4.1.aar,activity-1.8.0.aar,nextlib-media3ext-0.8.4.aar,sardine-android-0.9.aar,bcprov-jdk18on-1.79.jar
# 允许高版本的SDK
android.suppressUnsupportedCompileSdk=35
# 禁用增量编译以解决某些兼容性问题
android.enableBuildIncremental=false
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

-69
View File
@@ -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解析时生效",
"隐私保护": "所有拦截在本地进行,不上传任何数据"
}
}
-75
View File
@@ -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"
]
}
-75
View File
@@ -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"
]
}
-70
View File
@@ -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"
]
}
-70
View File
@@ -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"
]
}
Binary file not shown.
-7
View File
@@ -1,7 +0,0 @@
git clone --mirror https://github.com/FongMi/Release.git
java -jar bfg.jar --delete-files *.apk Release.git
java -jar bfg.jar --delete-files *.json Release.git
cd Release.git
git reflog expire --expire=now --all && git gc --prune=now --aggressive
git push
git gc
+2 -2
View File
@@ -20,6 +20,6 @@ dependencyResolutionManagement {
} }
include ':app' include ':app'
include ':catvod' include ':catvod'
include ':chaquo' // include ':chaquo' // Python支持
include ':quickjs' include ':quickjs'
rootProject.name = "TV" rootProject.name = "XMBOX"