feat: 同步本地代码到远程仓库

- 新增WebDAV同步功能相关文件
- 新增CustomSwitch自定义开关组件
- 新增SyncCodeManager、UpdateInstaller、WebDAVSyncManager工具类
- 新增build_all_release.sh构建脚本
- 更新多个Dialog和Activity文件
- 更新字符串资源文件
- 删除apk/release目录下的旧文件
This commit is contained in:
您的名字
2025-11-10 19:05:21 +08:00
parent e7e215628b
commit e0aee44d5a
43 changed files with 4244 additions and 391 deletions
-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.1.0",
"desc": "XMBOX TV版 v3.1.0\n\n✨ 新功能:\n• 实现定时按钮倒计时显示功能\n• 适配pixel主题化图标展示\n\n🎨 UI优化:\n• 优化TimerDialog按钮宽度设计\n• 优化播放进度条交互体验\n• 改进界面视觉一致性\n\n🐛 修复:\n• 修复更新跳转链接,跳转到具体版本页面\n\n🔧 改进优化:\n• 提升定时功能用户体验\n• 优化内存使用\n• 提升播放稳定性\n\n📺 支持架构:ARM64-v8a | ARMv7a",
"code": 310,
"downloads": {
"arm64_v8a": "v3.1.0/leanback-arm64_v8a-v3.1.0.apk",
"armeabi_v7a": "v3.1.0/leanback-armeabi_v7a-v3.1.0.apk"
}
}
-9
View File
@@ -1,9 +0,0 @@
{
"name": "3.1.0",
"desc": "XMBOX 手机版 v3.1.0\n\n✨ 新功能:\n• 实现定时按钮倒计时显示功能\n• 适配pixel主题化图标展示\n\n🎨 UI优化:\n• 优化TimerDialog按钮宽度设计\n• 优化播放进度条交互体验\n• 改进界面视觉一致性\n\n🐛 修复:\n• 修复更新跳转链接,跳转到具体版本页面\n\n🔧 改进优化:\n• 提升定时功能用户体验\n• 优化内存使用\n• 提升播放稳定性\n\n📱 支持架构:ARM64-v8a | ARMv7a",
"code": 310,
"downloads": {
"arm64_v8a": "v3.1.0/mobile-arm64_v8a-v3.1.0.apk",
"armeabi_v7a": "v3.1.0/mobile-armeabi_v7a-v3.1.0.apk"
}
}
-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
}
-10
View File
@@ -1,10 +0,0 @@
{
"name": "3.1.0",
"desc": "XMBOX TV版 v3.1.0\n\n✨ 新功能:\n• 实现定时按钮倒计时显示功能\n• 适配pixel主题化图标展示\n\n🎨 UI优化:\n• 优化TimerDialog按钮宽度设计\n• 优化播放进度条交互体验\n• 改进界面视觉一致性\n\n🐛 修复:\n• 修复更新跳转链接,跳转到具体版本页面\n\n🔧 改进优化:\n• 提升定时功能用户体验\n• 优化内存使用\n• 提升播放稳定性\n\n📺 支持架构:ARM64-v8a | ARMv7a",
"code": 310,
"downloads": {
"arm64_v8a": "v3.1.0/leanback-arm64_v8a-v3.1.0.apk",
"armeabi_v7a": "v3.1.0/leanback-armeabi_v7a-v3.1.0.apk"
}
}
-10
View File
@@ -1,10 +0,0 @@
{
"name": "3.1.0",
"desc": "XMBOX 手机版 v3.1.0\n\n✨ 新功能:\n• 实现定时按钮倒计时显示功能\n• 适配pixel主题化图标展示\n\n🎨 UI优化:\n• 优化TimerDialog按钮宽度设计\n• 优化播放进度条交互体验\n• 改进界面视觉一致性\n\n🐛 修复:\n• 修复更新跳转链接,跳转到具体版本页面\n\n🔧 改进优化:\n• 提升定时功能用户体验\n• 优化内存使用\n• 提升播放稳定性\n\n📱 支持架构:ARM64-v8a | ARMv7a",
"code": 310,
"downloads": {
"arm64_v8a": "v3.1.0/mobile-arm64_v8a-v3.1.0.apk",
"armeabi_v7a": "v3.1.0/mobile-armeabi_v7a-v3.1.0.apk"
}
}
+5 -2
View File
@@ -27,8 +27,11 @@ android {
minSdk 24
//noinspection ExpiredTargetSdkVersion
targetSdk 28
versionCode 310
versionName "3.1.0"
versionCode 311
versionName "3.1.1"
// GitHub Token (可选,用于提高API请求限制)
def githubToken = project.findProperty("GITHUB_TOKEN") ?: ""
buildConfigField "String", "GITHUB_TOKEN", "\"${githubToken}\""
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation": "$projectDir/schemas".toString(), "eventBusIndex": "com.fongmi.android.tv.event.EventIndex"]
@@ -14,6 +14,7 @@ import com.fongmi.android.tv.utils.Download;
import com.fongmi.android.tv.utils.FileUtil;
import com.fongmi.android.tv.utils.Notify;
import com.fongmi.android.tv.utils.ResUtil;
import com.fongmi.android.tv.utils.UpdateInstaller;
import com.github.catvod.net.OkHttp;
import com.github.catvod.utils.Github;
import com.github.catvod.utils.Logger;
@@ -29,53 +30,34 @@ import java.util.Locale;
public class Updater implements Download.Callback {
private DialogUpdateBinding binding;
private final Download download;
private Download download;
private AlertDialog dialog;
private boolean dev;
private boolean forceCheck; // 是否为手动检查
private boolean autoShow; // 是否自动显示更新对话框(用于自动检查)
private String latestVersion; // 存储检测到的最新版本
private String releaseApkUrl; // 从 GitHub Release 获取的 APK 下载链接
private String releaseApkUrl; // 从 GitHub Release 获取的 APK 下载链接jsDelivr CDN
private String fallbackApkUrl; // 备用下载链接(GitHub原始URL)
// 静态变量:记录上次检查时间(用于时间间隔限制)
private static long lastCheckTime = 0;
private static final long CHECK_INTERVAL = 60 * 60 * 1000; // 1小时(毫秒)
private File getFile() {
return Path.root("Download", "XMBOX-update.apk");
}
private String getJson() {
return Github.getJson(dev, BuildConfig.FLAVOR_mode);
// Android 10+ 无法直接访问外部存储的Download目录
// 使用应用的cache目录,FileProvider可以正常访问
return Path.cache("XMBOX-update.apk");
}
private String getApk() {
// 优先使用从 GitHub Release 获取的 APK URL
// 使用从 GitHub Release 获取的 APK URLjsDelivr CDN
if (releaseApkUrl != null && !releaseApkUrl.isEmpty()) {
Logger.d("APK download URL from Release: " + releaseApkUrl);
Logger.d("APK download URL from Release (jsDelivr): " + releaseApkUrl);
return releaseApkUrl;
}
// 使用JSON中指定的具体下载路径
try {
String response = OkHttp.string(getJson());
JSONObject object = new JSONObject(response);
JSONObject downloads = object.optJSONObject("downloads");
if (downloads != null) {
String abi = BuildConfig.FLAVOR_abi;
String downloadPath = downloads.optString(abi);
if (!downloadPath.isEmpty()) {
// 直接构建完整URL,不通过Github.getApk()避免重复添加路径
String baseUrl = Github.useCnMirror() ?
"https://gitee.com/ochenoktochen/XMBOX-Release/raw/main" :
"https://raw.githubusercontent.com/Tosencen/XMBOX-Release/main";
String fullUrl = baseUrl + "/apk/" + (dev ? "dev" : "release") + "/" + downloadPath;
Logger.d("APK download URL: " + fullUrl);
return fullUrl;
}
}
} catch (Exception e) {
Logger.e("Failed to get download path from JSON: " + e.getMessage());
}
// 回退到原来的方式
String fallbackUrl = Github.getApk(dev, BuildConfig.FLAVOR_mode + "-" + BuildConfig.FLAVOR_abi);
Logger.d("APK fallback URL: " + fallbackUrl);
return fallbackUrl;
// 如果没有获取到URL,返回空(不应该发生)
Logger.e("Updater: 未找到APK下载链接");
return "";
}
public static Updater create() {
@@ -83,8 +65,9 @@ public class Updater implements Download.Callback {
}
public Updater() {
this.download = Download.create(getApk(), getFile(), this);
this.forceCheck = false;
this.autoShow = false;
// download对象将在需要时创建
}
public Updater force() {
@@ -94,6 +77,15 @@ public class Updater implements Download.Callback {
return this;
}
/**
* 设置自动检查模式(应用启动时自动检查)
*/
public Updater auto() {
this.forceCheck = false;
this.autoShow = true; // 自动显示更新对话框
return this;
}
public Updater release() {
this.dev = false;
return this;
@@ -110,6 +102,16 @@ public class Updater implements Download.Callback {
}
public void start(Activity activity) {
// 如果是自动检查,检查时间间隔
if (autoShow && !forceCheck) {
long currentTime = System.currentTimeMillis();
long timeSinceLastCheck = currentTime - lastCheckTime;
// 1小时内只检查一次
if (lastCheckTime > 0 && timeSinceLastCheck < CHECK_INTERVAL) {
Logger.d("Updater: 距离上次检查仅 " + (timeSinceLastCheck / 1000 / 60) + " 分钟,跳过本次检查");
return;
}
}
App.execute(() -> doInBackground(activity));
}
@@ -119,48 +121,9 @@ public class Updater implements Download.Callback {
private void doInBackground(Activity activity) {
Logger.d("Updater: Starting update check...");
try {
// 优先使用 JSON 方式检测更新(兼容性更好)
String response = OkHttp.string(getJson());
JSONObject object = new JSONObject(response);
String name = object.optString("name");
String desc = object.optString("desc");
int code = object.optInt("code");
Logger.d("Updater: JSON Remote version: " + name + ", code: " + code);
Logger.d("Updater: Local version: " + BuildConfig.VERSION_NAME + ", code: " + BuildConfig.VERSION_CODE);
// 使用 JSON 中的版本信息
if (need(code, name)) {
Logger.d("Updater: Update needed (from JSON), showing dialog");
this.latestVersion = name; // 保存最新版本号
// 从 JSON 获取下载链接
JSONObject downloads = object.optJSONObject("downloads");
if (downloads != null) {
String abi = BuildConfig.FLAVOR_abi;
String downloadPath = downloads.optString(abi);
if (!downloadPath.isEmpty()) {
String baseUrl = Github.useCnMirror() ?
"https://gitee.com/ochenoktochen/XMBOX-Release/raw/main" :
"https://raw.githubusercontent.com/Tosencen/XMBOX-Release/main";
this.releaseApkUrl = baseUrl + "/apk/" + (dev ? "dev" : "release") + "/" + downloadPath;
Logger.d("Updater: APK URL from JSON: " + this.releaseApkUrl);
}
}
App.post(() -> show(activity, name, desc));
} else {
Logger.d("Updater: No update needed (from JSON)");
if (forceCheck) {
App.post(() -> Notify.show("已是最新版本 " + name));
}
}
} catch (Exception e) {
Logger.e("Updater: JSON check failed, trying GitHub API: " + e.getMessage());
// JSON 检测失败,尝试使用 GitHub Releases API
checkViaGitHubAPI(activity);
}
lastCheckTime = System.currentTimeMillis(); // 更新检查时间
// 直接使用 GitHub Releases API 检查更新
checkViaGitHubAPI(activity);
}
private void checkViaGitHubAPI(Activity activity) {
@@ -168,12 +131,42 @@ public class Updater implements Download.Callback {
String releasesUrl = "https://api.github.com/repos/Tosencen/XMBOX/releases/latest";
Logger.d("Updater: Trying GitHub Releases API: " + releasesUrl);
String response = OkHttp.string(releasesUrl);
// 检查是否有GitHub Token
String githubToken = BuildConfig.GITHUB_TOKEN;
String response;
if (githubToken != null && !githubToken.isEmpty()) {
// 使用token进行认证请求(5000次/小时)
java.util.Map<String, String> headers = new java.util.HashMap<>();
headers.put("Authorization", "Bearer " + githubToken);
headers.put("Accept", "application/vnd.github.v3+json");
Logger.d("Updater: Using GitHub Token for authenticated request");
response = OkHttp.string(releasesUrl, headers);
} else {
// 使用未认证请求(60次/小时)
Logger.d("Updater: Using unauthenticated request (60 requests/hour limit)");
response = OkHttp.string(releasesUrl);
}
if (response.contains("rate limit exceeded")) {
// 检查响应是否为空(可能是网络错误、VPN问题等)
if (response == null || response.isEmpty()) {
Logger.e("Updater: 网络请求失败,响应为空。可能是网络连接问题或VPN配置问题");
if (forceCheck) {
// 手动检查时,显示错误提示
App.post(() -> {
Notify.show("检查更新失败:网络连接异常,请检查网络设置或VPN配置");
showVersionInfo(activity, BuildConfig.VERSION_NAME, "");
});
} else {
Logger.w("Updater: 自动检查失败,网络不可用");
}
return;
}
if (response.contains("rate limit exceeded") || response.contains("API rate limit exceeded")) {
Logger.e("Updater: Rate limit exceeded");
if (forceCheck) {
App.post(() -> Notify.show("检查更新失败:API请求过于频繁,请稍后重试"));
// 手动检查时,显示版本信息弹窗(不显示错误提示)
App.post(() -> showVersionInfo(activity, BuildConfig.VERSION_NAME, ""));
}
return;
}
@@ -181,7 +174,8 @@ public class Updater implements Download.Callback {
if (response.contains("Not Found") || response.contains("404")) {
Logger.e("Updater: Release not found");
if (forceCheck) {
App.post(() -> Notify.show("检查更新失败:无法连接到更新服务器"));
// 手动检查时,显示版本信息弹窗(不显示错误提示)
App.post(() -> showVersionInfo(activity, BuildConfig.VERSION_NAME, ""));
}
return;
}
@@ -196,26 +190,105 @@ public class Updater implements Download.Callback {
// 从 assets 中查找 APK
JSONArray assets = release.optJSONArray("assets");
if (assets != null) {
String targetApkName = BuildConfig.FLAVOR_mode + "-" + BuildConfig.FLAVOR_abi + "-v" + version + ".apk";
for (int i = 0; i < assets.length(); i++) {
String mode = BuildConfig.FLAVOR_mode;
String abi = BuildConfig.FLAVOR_abi;
// 尝试多种文件名格式
String[] possibleNames = {
mode + "-" + abi + "-v" + version + ".apk", // leanback-arm64_v8a-v3.1.0.apk
mode + "-" + abi + "-release.apk", // leanback-arm64_v8a-release.apk
mode + "-" + abi + ".apk", // leanback-arm64_v8a.apk
mode + "-" + abi + "-" + version + ".apk" // leanback-arm64_v8a-3.1.0.apk
};
boolean found = false;
for (int i = 0; i < assets.length() && !found; i++) {
JSONObject asset = assets.getJSONObject(i);
if (targetApkName.equals(asset.optString("name"))) {
this.releaseApkUrl = asset.optString("browser_download_url");
break;
String assetName = asset.optString("name");
// 检查是否匹配任何可能的文件名格式
for (String targetName : possibleNames) {
if (targetName.equals(assetName)) {
String githubUrl = asset.optString("browser_download_url");
// jsDelivr无法访问GitHub Release文件,直接使用GitHub Release URL
this.releaseApkUrl = githubUrl;
this.fallbackApkUrl = githubUrl;
Logger.d("Updater: 找到匹配的APK: " + assetName);
Logger.d("Updater: APK URL (GitHub Release): " + this.releaseApkUrl);
found = true;
break;
}
}
}
// 如果精确匹配失败,尝试模糊匹配(包含mode和abi的APK文件)
if (!found) {
Logger.w("Updater: 未找到精确匹配的APK,尝试模糊匹配...");
for (int i = 0; i < assets.length(); i++) {
JSONObject asset = assets.getJSONObject(i);
String assetName = asset.optString("name");
// 检查文件名是否包含mode和abi,且是APK文件
if (assetName.endsWith(".apk") &&
assetName.contains(mode) &&
assetName.contains(abi.replace("_", "-"))) {
String githubUrl = asset.optString("browser_download_url");
// jsDelivr无法访问GitHub Release文件,直接使用GitHub Release URL
this.releaseApkUrl = githubUrl;
this.fallbackApkUrl = githubUrl;
Logger.d("Updater: 找到模糊匹配的APK: " + assetName);
Logger.d("Updater: APK URL (GitHub Release): " + this.releaseApkUrl);
found = true;
break;
}
}
}
if (!found) {
Logger.e("Updater: 在Release中未找到匹配的APK文件");
Logger.e("Updater: 期望的格式: " + mode + "-" + abi + "-v" + version + ".apk");
Logger.e("Updater: 可用的assets:");
for (int i = 0; i < assets.length(); i++) {
JSONObject asset = assets.getJSONObject(i);
String assetName = asset.optString("name");
if (assetName.endsWith(".apk")) {
Logger.e("Updater: - " + assetName);
}
}
}
} else {
Logger.e("Updater: Release中没有assets数组");
}
if (needUpdate(version)) {
this.latestVersion = version;
// 有新版本时,自动显示或手动显示更新对话框
App.post(() -> show(activity, version, body));
} else if (forceCheck) {
App.post(() -> Notify.show("已是最新版本 " + version));
} else {
// 没有新版本
if (forceCheck) {
// 手动检查时,显示版本信息弹窗
App.post(() -> showVersionInfo(activity, version, body));
} else if (autoShow) {
// 自动检查时,不显示任何内容(静默检查)
Logger.d("Updater: 自动检查完成,当前已是最新版本");
}
}
} catch (Exception e) {
Logger.e("Updater: GitHub API check failed: " + e.getMessage());
e.printStackTrace();
if (forceCheck) {
App.post(() -> Notify.show("检查更新失败:无法连接到更新服务器"));
// 手动检查时,显示错误提示
String errorMsg = e.getMessage();
if (errorMsg != null && (errorMsg.contains("network") || errorMsg.contains("timeout") || errorMsg.contains("connect"))) {
App.post(() -> {
Notify.show("检查更新失败:网络连接异常,请检查网络设置或VPN配置");
showVersionInfo(activity, BuildConfig.VERSION_NAME, "");
});
} else {
App.post(() -> showVersionInfo(activity, BuildConfig.VERSION_NAME, ""));
}
} else {
Logger.w("Updater: 自动检查失败: " + e.getMessage());
}
}
}
@@ -257,42 +330,55 @@ public class Updater implements Download.Callback {
binding.desc.setText(desc);
}
/**
* 显示版本信息弹窗(无更新时)
*/
private void showVersionInfo(Activity activity, String remoteVersion, String desc) {
binding = DialogUpdateBinding.inflate(LayoutInflater.from(activity));
// 先设置所有内容,再显示对话框
binding.version.setText("最新版本");
binding.desc.setText(BuildConfig.VERSION_NAME); // 只显示当前版本号,不使用远程信息
binding.confirm.setVisibility(View.GONE);
binding.cancel.setText("确定");
binding.cancel.setOnClickListener(v -> {
if (dialog != null) dialog.dismiss();
});
check().create(activity).show();
}
private AlertDialog create(Activity activity) {
return dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).setCancelable(false).create();
dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).setCancelable(false).create();
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.getWindow().setDimAmount(0);
return dialog;
}
private void cancel(View view) {
Setting.putUpdate(false);
download.cancel();
dismiss();
if (download != null) {
download.cancel();
}
dialog.dismiss();
}
private void confirm(View view) {
// 跳转到具体版本的GitHub Releases页面
try {
String url = "https://github.com/Tosencen/XMBOX/releases/tag/v" + latestVersion;
Logger.d("Updater: Attempting to open URL: " + url);
// 开始下载更新(使用jsDelivr CDN,失败时回退到GitHub
String downloadUrl = getApk();
String fallbackUrl = this.fallbackApkUrl;
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse(url));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// 检查是否有应用可以处理这个Intent
if (intent.resolveActivity(App.get().getPackageManager()) != null) {
App.get().startActivity(intent);
Logger.d("Updater: Successfully started browser intent");
dismiss();
} else {
Logger.e("Updater: No app can handle the URL");
Notify.show("没有找到可以打开链接的应用,请手动访问GitHub下载");
dismiss();
}
} catch (Exception e) {
Logger.e("Updater: Failed to open GitHub releases page: " + e.getMessage());
e.printStackTrace();
Notify.show("无法打开更新页面,请手动访问GitHub下载");
dismiss();
// 检查URL是否为空
if (downloadUrl == null || downloadUrl.isEmpty()) {
Logger.e("Updater: 下载URL为空,无法下载");
Notify.show("无法获取下载链接,请稍后重试或手动下载");
return;
}
Logger.d("Updater: 开始下载,URL: " + downloadUrl);
// 创建带回退URL的下载对象
this.download = Download.create(downloadUrl, getFile(), fallbackUrl, this);
this.download.start();
}
private void dismiss() {
@@ -315,7 +401,30 @@ public class Updater implements Download.Callback {
@Override
public void success(File file) {
FileUtil.openFile(file);
dismiss();
// 使用UpdateInstaller处理安装,包括权限检查和请求
UpdateInstaller installer = UpdateInstaller.get();
// 检查安装权限
if (!installer.hasInstallPermission()) {
// 没有权限,请求权限并保存待安装的文件
Logger.d("Updater: 没有安装权限,请求权限");
installer.requestInstallPermission();
// 保存待安装的文件,将在权限授予后自动安装
installer.install(file, true); // checkPermission=true会保存文件
Notify.show("请授予安装权限以完成更新");
dismiss();
return;
}
// 有权限,直接安装
boolean success = installer.install(file, false);
if (success) {
Logger.d("Updater: 已启动安装程序");
dismiss();
} else {
Logger.e("Updater: 启动安装程序失败");
Notify.show("无法启动安装程序,请检查文件是否完整");
dismiss();
}
}
}
@@ -3,6 +3,7 @@ package com.fongmi.android.tv.ui.activity;
import android.Manifest;
import android.app.Activity;
import android.content.Intent;
import android.text.TextUtils;
import android.view.View;
import androidx.viewbinding.ViewBinding;
@@ -35,11 +36,13 @@ import com.fongmi.android.tv.ui.dialog.LiveDialog;
import com.fongmi.android.tv.ui.dialog.ProxyDialog;
import com.fongmi.android.tv.ui.dialog.RestoreDialog;
import com.fongmi.android.tv.ui.dialog.SiteDialog;
import com.fongmi.android.tv.ui.dialog.WebDAVDialog;
import com.fongmi.android.tv.utils.FileChooser;
import com.fongmi.android.tv.utils.FileUtil;
import com.fongmi.android.tv.utils.Notify;
import com.fongmi.android.tv.utils.ResUtil;
import com.fongmi.android.tv.utils.UrlUtil;
import com.fongmi.android.tv.utils.WebDAVSyncManager;
import com.github.catvod.bean.Doh;
import com.github.catvod.net.OkHttp;
import com.github.catvod.utils.Path;
@@ -102,9 +105,32 @@ public class SettingActivity extends BaseActivity implements ConfigCallback, Sit
mBinding.liveTabVisibleText.setText(getSwitch(Setting.isLiveTabVisible()));
mBinding.sizeText.setText((size = ResUtil.getStringArray(R.array.select_size))[Setting.getSize()]);
mBinding.qualityText.setText((quality = ResUtil.getStringArray(R.array.select_quality))[Setting.getQuality()]);
setWebDAVStatus();
setLiveSettingsVisibility();
}
private void setWebDAVStatus() {
WebDAVSyncManager manager = WebDAVSyncManager.get();
if (manager.isConfigured()) {
// 显示账号昵称(用户名)
String username = Setting.getWebDAVUsername();
if (!TextUtils.isEmpty(username)) {
// 如果用户名是邮箱,只显示@前面的部分
String displayName = username;
if (username.contains("@")) {
displayName = username.substring(0, username.indexOf("@"));
}
String status = Setting.isWebDAVAutoSync() ? displayName + "(自动同步)" : displayName;
mBinding.webdavStatusText.setText(status);
} else {
String status = Setting.isWebDAVAutoSync() ? "已配置(自动同步)" : "已配置";
mBinding.webdavStatusText.setText(status);
}
} else {
mBinding.webdavStatusText.setText("未配置");
}
}
private void setLiveSettingsVisibility() {
boolean isLiveTabVisible = !Setting.isLiveTabVisible(); // 注意:这里取反,因为开关是"隐藏直播"
mBinding.live.setVisibility(isLiveTabVisible ? View.VISIBLE : View.GONE);
@@ -147,6 +173,7 @@ public class SettingActivity extends BaseActivity implements ConfigCallback, Sit
mBinding.quality.setOnClickListener(this::setQuality);
mBinding.size.setOnClickListener(this::setSize);
mBinding.doh.setOnClickListener(this::setDoh);
mBinding.webdav.setOnClickListener(this::onWebDAV);
}
@Override
@@ -405,12 +432,33 @@ public class SettingActivity extends BaseActivity implements ConfigCallback, Sit
}));
}
private void onWebDAV(View view) {
WebDAVDialog.create(this).show();
}
private void initConfig() {
WallConfig.get().init();
LiveConfig.get().init().load();
VodConfig.get().init().load(getCallback(0));
}
@Override
public void onRefreshEvent(RefreshEvent event) {
super.onRefreshEvent(event);
if (event.getType() == RefreshEvent.Type.CONFIG) {
setWebDAVStatus();
}
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) {
// 当Activity重新获得焦点时,更新WebDAV状态(例如从对话框返回后)
setWebDAVStatus();
}
}
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
@@ -0,0 +1,137 @@
package com.fongmi.android.tv.ui.custom;
import android.animation.ArgbEvaluator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.view.View;
import androidx.appcompat.widget.AppCompatCheckBox;
public class CustomSwitch extends AppCompatCheckBox {
private Paint trackPaint;
private Paint thumbPaint;
private RectF trackRect;
private RectF thumbRect;
private float thumbPosition = 0f; // 0 = 左边, 1 = 右边
private int currentTrackColor;
private int currentThumbColor;
private static final int TRACK_COLOR_OFF = 0xFF555555; // 灰色
private static final int TRACK_COLOR_ON = 0xFFFFEB3B; // 黄色
private static final int THUMB_COLOR_OFF = 0xFFFFFFFF; // 白色
private static final int THUMB_COLOR_ON = 0xFF000000; // 黑色
private ValueAnimator animator;
public CustomSwitch(Context context) {
super(context);
init();
}
public CustomSwitch(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public CustomSwitch(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
// 隐藏默认的checkbox样式
setButtonDrawable(null);
trackPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
thumbPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
trackRect = new RectF();
thumbRect = new RectF();
currentTrackColor = TRACK_COLOR_OFF;
currentThumbColor = THUMB_COLOR_OFF;
// 监听状态变化
setOnCheckedChangeListener((buttonView, isChecked) -> animateSwitch(isChecked));
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 固定尺寸:50dp × 30dp
int width = (int) (50 * getResources().getDisplayMetrics().density);
int height = (int) (30 * getResources().getDisplayMetrics().density);
setMeasuredDimension(width, height);
}
@Override
protected void onDraw(Canvas canvas) {
int width = getWidth();
int height = getHeight();
float radius = height / 2f;
// 绘制轨道
trackRect.set(0, 0, width, height);
trackPaint.setColor(currentTrackColor);
canvas.drawRoundRect(trackRect, radius, radius, trackPaint);
// 计算小圆位置
float thumbSize = height - 8 * getResources().getDisplayMetrics().density; // 22dp
float padding = 4 * getResources().getDisplayMetrics().density;
float thumbLeft = padding + thumbPosition * (width - thumbSize - 2 * padding);
float thumbTop = padding;
// 绘制小圆
thumbRect.set(thumbLeft, thumbTop, thumbLeft + thumbSize, thumbTop + thumbSize);
thumbPaint.setColor(currentThumbColor);
canvas.drawOval(thumbRect, thumbPaint);
}
private void animateSwitch(boolean isChecked) {
if (animator != null && animator.isRunning()) {
animator.cancel();
}
float targetPosition = isChecked ? 1f : 0f;
int targetTrackColor = isChecked ? TRACK_COLOR_ON : TRACK_COLOR_OFF;
int targetThumbColor = isChecked ? THUMB_COLOR_ON : THUMB_COLOR_OFF;
animator = ValueAnimator.ofFloat(thumbPosition, targetPosition);
animator.setDuration(250); // 250ms动画时长
final ArgbEvaluator colorEvaluator = new ArgbEvaluator();
animator.addUpdateListener(animation -> {
thumbPosition = (float) animation.getAnimatedValue();
// 颜色渐变
currentTrackColor = (int) colorEvaluator.evaluate(
thumbPosition, TRACK_COLOR_OFF, TRACK_COLOR_ON
);
currentThumbColor = (int) colorEvaluator.evaluate(
thumbPosition, THUMB_COLOR_OFF, THUMB_COLOR_ON
);
invalidate();
});
animator.start();
}
@Override
public void setChecked(boolean checked) {
super.setChecked(checked);
// 初始化时不播放动画
if (!isAttachedToWindow()) {
thumbPosition = checked ? 1f : 0f;
currentTrackColor = checked ? TRACK_COLOR_ON : TRACK_COLOR_OFF;
currentThumbColor = checked ? THUMB_COLOR_ON : THUMB_COLOR_OFF;
}
}
}
@@ -73,6 +73,8 @@ public class ConfigDialog implements DialogInterface.OnDismissListener {
params.width = (int) (ResUtil.getScreenWidth() * 0.55f);
dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.setOnDismissListener(this);
dialog.show();
}
@@ -23,6 +23,8 @@ public class DescDialog {
DialogDescBinding binding = DialogDescBinding.inflate(LayoutInflater.from(activity));
AlertDialog dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).create();
dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
initView(binding.text, desc, activity);
dialog.show();
}
@@ -55,6 +55,8 @@ public class DohDialog implements DohAdapter.OnClickListener {
params.width = (int) (ResUtil.getScreenWidth() * 0.4f);
dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.show();
}
@@ -56,6 +56,8 @@ public class HistoryDialog implements ConfigAdapter.OnClickListener {
params.width = (int) (ResUtil.getScreenWidth() * 0.4f);
dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.show();
}
@@ -57,6 +57,8 @@ public class LiveDialog implements LiveAdapter.OnClickListener {
params.width = (int) (ResUtil.getScreenWidth() * 0.4f);
dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.show();
}
@@ -54,6 +54,8 @@ public class ProxyDialog implements DialogInterface.OnDismissListener {
params.width = (int) (ResUtil.getScreenWidth() * 0.55f);
dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.setOnDismissListener(this);
dialog.show();
}
@@ -52,6 +52,8 @@ public class RestoreDialog implements RestoreAdapter.OnClickListener {
params.width = (int) (ResUtil.getScreenWidth() * 0.4f);
dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.show();
}
@@ -110,6 +110,8 @@ public class SiteDialog implements SiteAdapter.OnClickListener {
params.width = (int) (ResUtil.getScreenWidth() * getWidth());
dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.show();
}
@@ -55,6 +55,8 @@ public class UaDialog implements DialogInterface.OnDismissListener {
params.width = (int) (ResUtil.getScreenWidth() * 0.55f);
dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.setOnDismissListener(this);
dialog.show();
}
@@ -0,0 +1,637 @@
package com.fongmi.android.tv.ui.dialog;
import android.content.DialogInterface;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.Editable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.WindowManager;
import android.view.inputmethod.EditorInfo;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.FragmentActivity;
import com.fongmi.android.tv.App;
import com.fongmi.android.tv.R;
import com.fongmi.android.tv.Setting;
import com.fongmi.android.tv.databinding.DialogWebdavBinding;
import com.fongmi.android.tv.event.RefreshEvent;
import com.fongmi.android.tv.utils.Notify;
import com.fongmi.android.tv.utils.ResUtil;
import com.fongmi.android.tv.utils.WebDAVSyncManager;
import com.github.catvod.utils.Logger;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
public class WebDAVDialog {
// 预设的WebDAV服务提供商
private static final String[] PROVIDERS = {
"坚果云",
"Nextcloud",
"ownCloud",
"自定义"
};
private static final String[] PROVIDER_URLS = {
"https://dav.jianguoyun.com/dav/XMBOX/", // 坚果云(添加XMBOX子目录,方便在网页版查看)
"", // Nextcloud(需要用户输入)
"", // ownCloud(需要用户输入)
"" // 自定义(需要用户输入)
};
private final DialogWebdavBinding binding;
private final FragmentActivity activity;
private AlertDialog dialog;
private WebDAVSyncManager syncManager;
private int selectedProvider = 0; // 默认选择坚果云
private boolean isInitializing = false; // 标记是否正在初始化,防止初始化时触发监听器
private Handler statusHandler = new Handler(Looper.getMainLooper());
private Runnable hideStatusRunnable; // 用于延迟隐藏状态消息
public static WebDAVDialog create(FragmentActivity activity) {
return new WebDAVDialog(activity);
}
public WebDAVDialog(FragmentActivity activity) {
this.activity = activity;
this.binding = DialogWebdavBinding.inflate(LayoutInflater.from(activity));
this.syncManager = WebDAVSyncManager.get();
}
public void show() {
initDialog();
initView();
initEvent();
}
private void initDialog() {
dialog = new MaterialAlertDialogBuilder(activity)
.setView(binding.getRoot())
.create();
// 设置对话框大小(适合TV屏幕)
WindowManager.LayoutParams params = dialog.getWindow().getAttributes();
params.width = (int) (ResUtil.getScreenWidth() * 0.45f);
dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.show();
}
private void initView() {
isInitializing = true; // 标记开始初始化
// 加载已保存的配置
String url = Setting.getWebDAVUrl();
String username = Setting.getWebDAVUsername();
String password = Setting.getWebDAVPassword();
boolean autoSync = Setting.isWebDAVAutoSync();
int interval = Setting.getWebDAVSyncInterval();
// 根据保存的URL判断是哪个服务提供商
selectedProvider = getProviderIndexByUrl(url);
binding.providerText.setText(PROVIDERS[selectedProvider]);
// 根据选择的服务提供商决定是否显示URL输入框
if (selectedProvider == PROVIDERS.length - 1) {
// 自定义,显示URL输入框
binding.urlInput.setVisibility(View.VISIBLE);
binding.urlText.setText(url);
if (!TextUtils.isEmpty(url)) {
binding.urlText.setSelection(url.length());
}
} else if (selectedProvider == 0) {
// 坚果云,永远隐藏输入框(有预设URL)
binding.urlInput.setVisibility(View.GONE);
} else {
// Nextcloud或ownCloud需要用户输入URL
binding.urlInput.setVisibility(View.VISIBLE);
binding.urlText.setText(url);
if (!TextUtils.isEmpty(url)) {
binding.urlText.setSelection(url.length());
}
}
binding.usernameText.setText(username);
binding.passwordText.setText(password);
binding.autoSyncSwitch.setChecked(autoSync);
binding.syncIntervalText.setText(String.valueOf(interval));
// 根据自动同步开关显示/隐藏同步间隔
updateSyncIntervalVisibility(autoSync);
isInitializing = false; // 初始化完成
}
/**
* 根据URL判断是哪个服务提供商
*/
private int getProviderIndexByUrl(String url) {
if (TextUtils.isEmpty(url)) {
return 0; // 默认坚果云
}
if (url.contains("jianguoyun.com")) {
return 0; // 坚果云
}
if (url.contains("nextcloud")) {
return 1; // Nextcloud
}
if (url.contains("owncloud")) {
return 2; // ownCloud
}
return PROVIDERS.length - 1; // 自定义
}
/**
* 获取当前选择的服务提供商的URL
*/
private String getProviderUrl() {
if (selectedProvider < PROVIDER_URLS.length && !TextUtils.isEmpty(PROVIDER_URLS[selectedProvider])) {
return PROVIDER_URLS[selectedProvider];
}
return "";
}
private void initEvent() {
// 服务提供商选择
binding.providerText.setOnClickListener(v -> onSelectProvider());
// 自动同步开关监听(立即保存状态)
// 使用setOnClickListener而不是setOnCheckedChangeListener,避免覆盖CustomSwitch内部的动画监听器
// AppCompatCheckBox会自动处理状态切换,我们只需要在状态切换后获取新状态
binding.autoSyncSwitch.setOnClickListener(v -> {
// 防止初始化时触发监听器
if (isInitializing) {
return;
}
// 使用post()确保在状态切换后获取新状态
binding.autoSyncSwitch.post(() -> {
boolean newState = binding.autoSyncSwitch.isChecked();
// 立即保存自动同步状态
Setting.putWebDAVAutoSync(newState);
// 更新同步间隔的可见性
updateSyncIntervalVisibility(newState);
});
});
// 测试连接按钮
binding.testButton.setOnClickListener(v -> onTestConnection());
// 立即同步按钮
binding.syncButton.setOnClickListener(v -> onSyncNow());
// 同步间隔点击(弹出选择对话框)
binding.syncIntervalContainer.setOnClickListener(v -> onSelectInterval());
// 保存按钮
binding.positive.setOnClickListener(v -> onPositive(null, 0));
// 取消按钮
binding.negative.setOnClickListener(v -> onNegative(null, 0));
// 密码输入框回车键
binding.passwordText.setOnEditorActionListener((textView, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_DONE) {
binding.positive.performClick();
return true;
}
return false;
});
// 监听输入框内容变化,清除状态提示
TextWatcher clearStatusWatcher = new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
clearStatus();
}
@Override
public void afterTextChanged(Editable s) {}
};
binding.urlText.addTextChangedListener(clearStatusWatcher);
binding.usernameText.addTextChangedListener(clearStatusWatcher);
binding.passwordText.addTextChangedListener(clearStatusWatcher);
}
private void onSelectProvider() {
AlertDialog providerDialog = new MaterialAlertDialogBuilder(activity)
.setTitle("选择服务提供商")
.setSingleChoiceItems(PROVIDERS, selectedProvider, (dialog, which) -> {
selectedProvider = which;
binding.providerText.setText(PROVIDERS[which]);
// 如果是自定义,显示URL输入框
if (which == PROVIDERS.length - 1) {
binding.urlInput.setVisibility(View.VISIBLE);
String currentUrl = binding.urlText.getText().toString().trim();
if (TextUtils.isEmpty(currentUrl)) {
binding.urlText.setText("");
}
} else {
// 使用预设的URL
binding.urlInput.setVisibility(View.GONE);
String providerUrl = getProviderUrl();
if (!TextUtils.isEmpty(providerUrl)) {
// URL会在保存时自动填充
} else {
// Nextcloud或ownCloud需要用户输入URL
binding.urlInput.setVisibility(View.VISIBLE);
binding.urlText.setHint("请输入" + PROVIDERS[which] + "服务器地址");
}
}
dialog.dismiss();
})
.setNegativeButton("取消", null)
.create();
// 设置对话框深色背景
providerDialog.getWindow().setBackgroundDrawableResource(R.color.black_90);
providerDialog.getWindow().setDimAmount(0);
providerDialog.show();
// 设置标题和按钮文字颜色为白色
setDialogTextColor(providerDialog, R.color.white);
// 设置列表项文字颜色为白色(使用 post 确保在列表渲染后设置)
android.widget.ListView listView = providerDialog.getListView();
if (listView != null) {
listView.post(() -> {
for (int i = 0; i < listView.getChildCount(); i++) {
View itemView = listView.getChildAt(i);
setTextViewColorRecursive(itemView, R.color.white);
}
});
// 监听列表滚动,确保新显示的项目也是白色
listView.setOnScrollListener(new android.widget.AbsListView.OnScrollListener() {
@Override
public void onScrollStateChanged(android.widget.AbsListView view, int scrollState) {
if (scrollState == android.widget.AbsListView.OnScrollListener.SCROLL_STATE_IDLE) {
for (int i = 0; i < view.getChildCount(); i++) {
setTextViewColorRecursive(view.getChildAt(i), R.color.white);
}
}
}
@Override
public void onScroll(android.widget.AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
}
});
}
}
private void updateSyncIntervalVisibility(boolean visible) {
binding.syncIntervalContainer.setVisibility(visible ? View.VISIBLE : View.GONE);
}
/**
* 递归设置 View 及其子 View 中所有 TextView 的文字颜色
*/
private void setTextViewColorRecursive(View view, int colorResId) {
if (view == null) return;
if (view instanceof android.widget.TextView) {
((android.widget.TextView) view).setTextColor(activity.getResources().getColor(colorResId));
} else if (view instanceof android.view.ViewGroup) {
android.view.ViewGroup group = (android.view.ViewGroup) view;
for (int i = 0; i < group.getChildCount(); i++) {
setTextViewColorRecursive(group.getChildAt(i), colorResId);
}
}
}
/**
* 设置对话框中的标题和按钮文字颜色
*/
private void setDialogTextColor(AlertDialog dialog, int colorResId) {
if (dialog == null) return;
int color = activity.getResources().getColor(colorResId);
// 设置标题文字颜色
int titleId = activity.getResources().getIdentifier("alertTitle", "id", "android");
if (titleId != 0) {
View titleView = dialog.findViewById(titleId);
if (titleView instanceof android.widget.TextView) {
((android.widget.TextView) titleView).setTextColor(color);
}
}
// 使用 post 延迟设置按钮文字颜色(按钮可能在显示后才创建)
dialog.getWindow().getDecorView().post(() -> {
android.widget.Button negativeButton = dialog.getButton(DialogInterface.BUTTON_NEGATIVE);
if (negativeButton != null) {
negativeButton.setTextColor(color);
}
});
}
private void onTestConnection() {
String url = getServerUrl();
String username = binding.usernameText.getText().toString().trim();
String password = binding.passwordText.getText().toString().trim();
if (TextUtils.isEmpty(url)) {
showStatus("请选择服务提供商或输入服务器地址", false);
return;
}
if (TextUtils.isEmpty(username)) {
showStatus("请输入用户名", false);
return;
}
if (TextUtils.isEmpty(password)) {
showStatus("请输入密码", false);
return;
}
// 临时保存配置用于测试
Setting.putWebDAVUrl(url);
Setting.putWebDAVUsername(username);
Setting.putWebDAVPassword(password);
syncManager.reloadConfig();
showStatus("正在测试连接...", true);
binding.testButton.setEnabled(false);
App.execute(() -> {
WebDAVSyncManager.TestResult result = syncManager.testConnectionWithMessage();
App.post(() -> {
// 检查对话框是否还存在
if (binding == null || dialog == null || !dialog.isShowing()) {
return;
}
binding.testButton.setEnabled(true);
showStatus(result.message, result.success);
if (!result.success) {
// 显示详细错误信息
Logger.e("WebDAV测试连接失败: " + result.message);
}
});
});
}
private void onSyncNow() {
// 先临时保存当前配置用于测试同步
String url = getServerUrl();
String username = binding.usernameText.getText().toString().trim();
String password = binding.passwordText.getText().toString().trim();
// 验证输入
if (TextUtils.isEmpty(url)) {
showStatus("请选择服务提供商或输入服务器地址", false);
return;
}
if (TextUtils.isEmpty(username)) {
showStatus("请输入用户名", false);
return;
}
if (TextUtils.isEmpty(password)) {
showStatus("请输入密码", false);
return;
}
// 临时保存配置用于同步
Setting.putWebDAVUrl(url);
Setting.putWebDAVUsername(username);
Setting.putWebDAVPassword(password);
syncManager.reloadConfig();
if (!syncManager.isConfigured()) {
showStatus("配置无效,无法同步", false);
return;
}
showStatus("正在同步...", true);
binding.syncButton.setEnabled(false);
// 在后台线程执行同步
App.execute(() -> {
try {
// 先上传本地记录
syncManager.uploadHistory();
// 再下载远程记录并合并
boolean downloadSuccess = syncManager.downloadHistory();
App.post(() -> {
// 检查对话框是否还存在
if (binding == null || dialog == null || !dialog.isShowing()) {
return;
}
binding.syncButton.setEnabled(true);
if (downloadSuccess) {
showStatus("同步完成", true);
Notify.show("同步完成");
} else {
showStatus("同步完成(本地数据已上传)", true);
Notify.show("同步完成");
}
});
} catch (Exception e) {
App.post(() -> {
// 检查对话框是否还存在
if (binding == null || dialog == null || !dialog.isShowing()) {
return;
}
binding.syncButton.setEnabled(true);
showStatus("同步失败:" + e.getMessage(), false);
Notify.show("同步失败");
Logger.e("WebDAV: 同步失败: " + e.getMessage());
});
}
});
}
private void onSelectInterval() {
String[] intervals = {"15", "30", "60", "120", "240"};
int currentInterval = Setting.getWebDAVSyncInterval();
int selectedIndex = 0;
for (int i = 0; i < intervals.length; i++) {
if (Integer.parseInt(intervals[i]) == currentInterval) {
selectedIndex = i;
break;
}
}
AlertDialog intervalDialog = new MaterialAlertDialogBuilder(activity)
.setTitle("选择同步间隔")
.setSingleChoiceItems(intervals, selectedIndex, (dialog, which) -> {
int interval = Integer.parseInt(intervals[which]);
binding.syncIntervalText.setText(String.valueOf(interval));
// 立即保存同步间隔
Setting.putWebDAVSyncInterval(interval);
dialog.dismiss();
})
.setNegativeButton("取消", null)
.create();
// 设置对话框深色背景
intervalDialog.getWindow().setBackgroundDrawableResource(R.color.black_90);
intervalDialog.getWindow().setDimAmount(0);
intervalDialog.show();
// 设置标题和按钮文字颜色为白色
setDialogTextColor(intervalDialog, R.color.white);
// 设置列表项文字颜色为白色(使用 post 确保在列表渲染后设置)
android.widget.ListView listView = intervalDialog.getListView();
if (listView != null) {
listView.post(() -> {
for (int i = 0; i < listView.getChildCount(); i++) {
View itemView = listView.getChildAt(i);
setTextViewColorRecursive(itemView, R.color.white);
}
});
// 监听列表滚动,确保新显示的项目也是白色
listView.setOnScrollListener(new android.widget.AbsListView.OnScrollListener() {
@Override
public void onScrollStateChanged(android.widget.AbsListView view, int scrollState) {
if (scrollState == android.widget.AbsListView.OnScrollListener.SCROLL_STATE_IDLE) {
for (int i = 0; i < view.getChildCount(); i++) {
setTextViewColorRecursive(view.getChildAt(i), R.color.white);
}
}
}
@Override
public void onScroll(android.widget.AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
}
});
}
}
private void showStatus(String message, boolean isSuccess) {
// 检查对话框是否还存在
if (binding == null || dialog == null || !dialog.isShowing()) {
return;
}
// 取消之前的隐藏任务
if (hideStatusRunnable != null) {
statusHandler.removeCallbacks(hideStatusRunnable);
hideStatusRunnable = null;
}
binding.statusText.setText(message);
binding.statusText.setVisibility(TextUtils.isEmpty(message) ? View.GONE : View.VISIBLE);
binding.statusText.setTextColor(isSuccess ?
activity.getResources().getColor(R.color.white) :
activity.getResources().getColor(android.R.color.holo_red_dark));
// 3秒后自动隐藏状态消息
if (!TextUtils.isEmpty(message)) {
hideStatusRunnable = () -> clearStatus();
statusHandler.postDelayed(hideStatusRunnable, 3000);
}
}
/**
* 清除状态提示
*/
private void clearStatus() {
// 检查对话框是否还存在
if (binding == null || dialog == null || !dialog.isShowing()) {
return;
}
if (hideStatusRunnable != null) {
statusHandler.removeCallbacks(hideStatusRunnable);
hideStatusRunnable = null;
}
binding.statusText.setText("");
binding.statusText.setVisibility(View.GONE);
}
/**
* 获取服务器URL(根据选择的服务提供商)
*/
private String getServerUrl() {
if (selectedProvider == PROVIDERS.length - 1) {
// 自定义,从输入框获取
return binding.urlText.getText().toString().trim();
} else {
// 使用预设URL或从输入框获取(Nextcloud/ownCloud
String providerUrl = getProviderUrl();
if (!TextUtils.isEmpty(providerUrl)) {
return providerUrl;
} else {
// Nextcloud或ownCloud需要用户输入
return binding.urlText.getText().toString().trim();
}
}
}
private void onPositive(DialogInterface dialog, int which) {
String url = getServerUrl();
String username = binding.usernameText.getText().toString().trim();
String password = binding.passwordText.getText().toString().trim();
boolean autoSync = binding.autoSyncSwitch.isChecked();
int interval = Integer.parseInt(binding.syncIntervalText.getText().toString());
// 验证输入
if (TextUtils.isEmpty(url)) {
Notify.show("请选择服务提供商或输入服务器地址");
return;
}
if (TextUtils.isEmpty(username)) {
Notify.show("请输入用户名");
return;
}
if (TextUtils.isEmpty(password)) {
Notify.show("请输入密码");
return;
}
// 保存配置
Setting.putWebDAVUrl(url);
Setting.putWebDAVUsername(username);
Setting.putWebDAVPassword(password);
Setting.putWebDAVAutoSync(autoSync);
Setting.putWebDAVSyncInterval(interval);
// 重新加载配置
syncManager.reloadConfig();
// 配置保存后,立即执行一次同步(下载远程数据)
// 这样新设备配置后就能立即看到其他设备的历史记录
if (syncManager.isConfigured()) {
Notify.show("WebDAV配置已保存,正在同步数据...");
App.execute(() -> {
try {
// 先上传本地记录
syncManager.uploadHistory();
// 再下载远程记录并合并
boolean downloadSuccess = syncManager.downloadHistory();
App.post(() -> {
if (downloadSuccess) {
Notify.show("同步完成,已获取远程观看记录");
} else {
Notify.show("同步完成(本地数据已上传)");
}
});
} catch (Exception e) {
App.post(() -> {
Notify.show("同步失败,请检查网络连接");
});
}
});
} else {
Notify.show("WebDAV配置已保存");
}
clearStatus();
if (this.dialog != null) {
this.dialog.dismiss();
}
// 通知设置界面更新状态(通过RefreshEvent
// 使用App.post确保对话框关闭后再发送事件,让状态能及时更新
App.post(() -> RefreshEvent.config());
}
private void onNegative(DialogInterface dialog, int which) {
clearStatus();
if (this.dialog != null) {
this.dialog.dismiss();
}
}
}
@@ -39,6 +39,8 @@ public class WebDialog {
params.width = (int) (ResUtil.getScreenWidth() * 0.8f);
dialog.getWindow().setAttributes(params);
dialog.getWindow().setDimAmount(0);
// 设置对话框背景为透明,让布局的深色背景显示
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
dialog.show();
}
}
@@ -235,6 +235,36 @@
</LinearLayout>
<LinearLayout
android:id="@+id/webdav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/selector_item"
android:focusable="true"
android:focusableInTouchMode="true"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="WebDAV"
android:textColor="@color/white"
android:textSize="18sp" />
<TextView
android:id="@+id/webdavStatusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="end"
android:text="未配置"
android:textColor="@color/white"
android:textSize="18sp"
android:alpha="0.7" />
</LinearLayout>
<LinearLayout
android:id="@+id/incognito"
android:layout_width="match_parent"
@@ -0,0 +1,296 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxHeight="600dp"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@color/black_90"
android:paddingStart="24dp"
android:paddingTop="16dp"
android:paddingEnd="24dp"
android:paddingBottom="16dp">
<!-- 标题 -->
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="WebDAV 配置"
android:textColor="@color/white"
android:textSize="20sp"
android:textStyle="bold" />
<!-- 说明文字 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="请输入您在WebDAV服务提供商(如坚果云)注册的账号和密码(坚果云的密码为应用密码而非登录密码)"
android:textColor="@color/white"
android:textSize="14sp"
android:alpha="0.7"
android:lineSpacingMultiplier="1.2" />
<!-- 服务提供商选择 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="服务提供商"
android:textColor="@color/white"
android:textSize="18sp" />
<TextView
android:id="@+id/providerText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="end"
android:text="坚果云"
android:textColor="@color/white"
android:textSize="18sp"
android:background="?attr/selectableItemBackground"
android:padding="12dp"
android:clickable="true"
android:focusable="true" />
</LinearLayout>
<!-- 服务器地址(自定义时显示) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/urlInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:visibility="gone"
app:hintEnabled="false"
app:boxBackgroundColor="@color/grey_900"
app:boxStrokeColor="@color/white_50"
app:hintTextColor="@color/white_50"
app:boxBackgroundMode="filled">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/urlText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="WebDAV服务器地址(如:https://example.com/webdav"
android:textColor="@color/white"
android:textColorHint="@color/white_50"
android:background="@color/grey_900"
android:imeOptions="actionNext"
android:importantForAutofill="no"
android:inputType="textUri"
android:singleLine="true"
android:textSize="18sp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- 用户名 -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/usernameInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:hintEnabled="false"
app:boxBackgroundColor="@color/grey_900"
app:boxStrokeColor="@color/white_50"
app:hintTextColor="@color/white_50"
app:boxBackgroundMode="filled">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/usernameText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="用户名"
android:textColor="@color/white"
android:textColorHint="@color/white_50"
android:background="@color/grey_900"
android:imeOptions="actionNext"
android:importantForAutofill="no"
android:inputType="text"
android:singleLine="true"
android:textSize="18sp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- 密码 -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/passwordInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:hintEnabled="false"
app:passwordToggleEnabled="true"
app:boxBackgroundColor="@color/grey_900"
app:boxStrokeColor="@color/white_50"
app:hintTextColor="@color/white_50"
app:passwordToggleTint="@color/white_50"
app:boxBackgroundMode="filled">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/passwordText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="密码"
android:textColor="@color/white"
android:textColorHint="@color/white_50"
android:background="@color/grey_900"
android:imeOptions="actionDone"
android:importantForAutofill="no"
android:inputType="textPassword"
android:singleLine="true"
android:textSize="18sp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- 自动同步开关 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="自动同步"
android:textColor="@color/white"
android:textSize="18sp" />
<com.fongmi.android.tv.ui.custom.CustomSwitch
android:id="@+id/autoSyncSwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<!-- 同步间隔 -->
<LinearLayout
android:id="@+id/syncIntervalContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="同步间隔(分钟)"
android:textColor="@color/white"
android:textSize="18sp" />
<TextView
android:id="@+id/syncIntervalText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="30"
android:textColor="@color/white"
android:textSize="18sp" />
</LinearLayout>
<!-- 操作按钮区域 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="end">
<!-- 测试连接按钮 -->
<com.google.android.material.button.MaterialButton
android:id="@+id/testButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="测试连接"
android:textColor="@color/white"
android:textSize="18sp"
app:strokeColor="@color/white_50"
style="@style/Widget.Material3.Button.OutlinedButton" />
<!-- 立即同步按钮 -->
<com.google.android.material.button.MaterialButton
android:id="@+id/syncButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="立即同步"
android:textColor="@color/white"
android:textSize="18sp"
app:strokeColor="@color/white_50"
style="@style/Widget.Material3.Button.OutlinedButton" />
</LinearLayout>
<!-- 状态提示 -->
<TextView
android:id="@+id/statusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text=""
android:textColor="@color/white"
android:textSize="16sp"
android:visibility="gone" />
<!-- 保存和取消按钮 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="horizontal"
android:gravity="end">
<TextView
android:id="@+id/positive"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_weight="1"
android:background="@drawable/selector_text"
android:focusable="true"
android:focusableInTouchMode="true"
android:gravity="center"
android:singleLine="true"
android:text="@string/dialog_positive"
android:textColor="@color/button_text"
android:textSize="18sp" />
<TextView
android:id="@+id/negative"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/selector_text"
android:focusable="true"
android:focusableInTouchMode="true"
android:gravity="center"
android:singleLine="true"
android:text="@string/dialog_negative"
android:textColor="@color/button_text"
android:textSize="18sp" />
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
@@ -12,9 +12,12 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.os.HandlerCompat;
import com.fongmi.android.tv.event.EventIndex;
import com.fongmi.android.tv.Setting;
// import com.fongmi.android.tv.event.EventIndex; // 暂时注释,如果不存在则删除
import com.fongmi.android.tv.ui.activity.CrashActivity;
import com.fongmi.android.tv.utils.CacheCleaner;
import com.fongmi.android.tv.utils.UpdateInstaller;
import com.fongmi.android.tv.utils.WebDAVSyncManager;
import com.fongmi.android.tv.utils.Notify;
import com.fongmi.hook.Hook;
import com.github.catvod.Init;
@@ -43,6 +46,7 @@ public class App extends Application {
private final long time;
private Hook hook;
private final Runnable cleanTask;
private final Runnable syncTask;
private boolean appJustLaunched;
public App() {
@@ -52,6 +56,7 @@ public class App extends Application {
time = System.currentTimeMillis();
gson = new Gson();
cleanTask = this::checkCacheClean;
syncTask = this::checkWebDAVSync;
appJustLaunched = true;
}
@@ -129,7 +134,8 @@ public class App extends Application {
Logger.addLogAdapter(getLogAdapter());
OkHttp.get().setProxy(Setting.getProxy());
OkHttp.get().setDoh(Doh.objectFrom(Setting.getDoh()));
EventBus.builder().addIndex(new EventIndex()).installDefaultEventBus();
// EventBus.builder().addIndex(new EventIndex()).installDefaultEventBus(); // 暂时注释,如果EventIndex不存在则删除
EventBus.getDefault(); // 使用默认EventBus
CaocConfig.Builder.create().backgroundMode(CaocConfig.BACKGROUND_MODE_SILENT).errorActivity(CrashActivity.class).apply();
// Ensure default notification channel exists for foreground playback service (TV flavor too)
Notify.createChannel();
@@ -153,6 +159,12 @@ public class App extends Application {
if (activity != activity()) setActivity(activity);
// 应用回到前台时检查缓存
checkCacheClean();
// 检查是否有待安装的更新文件(用户从设置页面返回后)
checkPendingInstall();
// 检查WebDAV自动同步
checkWebDAVSync();
// 自动检查更新(如果启用)
checkAutoUpdate(activity);
}
@Override
@@ -190,6 +202,93 @@ public class App extends Application {
post(cleanTask, 30 * 60 * 1000);
}
/**
* 检查是否有待安装的更新文件
* 当用户从设置页面授予安装权限后返回时,自动安装
*/
private void checkPendingInstall() {
UpdateInstaller installer = UpdateInstaller.get();
if (installer.hasPendingInstall()) {
Logger.d("App: 检测到待安装文件且权限已授予,自动安装");
boolean success = installer.autoRetryInstall();
if (success) {
Notify.show("正在安装更新...");
} else {
Logger.e("App: 自动安装失败");
}
}
}
/**
* 检查并执行WebDAV自动同步
*/
private void checkWebDAVSync() {
WebDAVSyncManager manager = WebDAVSyncManager.get();
if (manager.isConfigured()) {
// 应用启动时,如果已配置WebDAV,立即执行一次同步(下载远程数据)
// 这样新设备配置后,下次启动应用时就能看到其他设备的历史记录
App.execute(() -> {
try {
Logger.d("App: 应用启动,执行WebDAV同步");
// 先上传本地记录
manager.uploadHistory();
// 再下载远程记录并合并
manager.downloadHistory();
Logger.d("App: WebDAV同步完成");
} catch (Exception e) {
Logger.e("App: WebDAV同步失败: " + e.getMessage());
}
});
// 如果启用了自动同步,设置定期同步
if (Setting.isWebDAVAutoSync()) {
int interval = Setting.getWebDAVSyncInterval();
// 延迟执行下次同步,避免影响启动速度
post(syncTask, interval * 60 * 1000L);
}
}
}
/**
* 执行WebDAV同步
*/
private void doWebDAVSync() {
App.execute(() -> {
WebDAVSyncManager manager = WebDAVSyncManager.get();
if (manager.isConfigured()) {
Logger.d("App: 开始WebDAV自动同步");
manager.syncAll();
// 设置下次同步
int interval = Setting.getWebDAVSyncInterval();
post(syncTask, interval * 60 * 1000L);
}
});
}
/**
* 自动检查更新(如果启用)
*/
private void checkAutoUpdate(Activity activity) {
// 检查是否启用自动更新检查
if (!Setting.getAutoUpdateCheck()) {
return;
}
// 检查是否启用更新功能
if (!Setting.getUpdate()) {
return;
}
// 延迟一小段时间,避免影响应用启动速度
post(() -> {
if (activity != null && !activity.isFinishing() && !activity.isDestroyed()) {
Logger.d("App: 开始自动检查更新");
Updater.create().auto().release().start(activity);
}
}, 2000); // 延迟2秒
}
@Override
public PackageManager getPackageManager() {
return hook != null ? hook : getBaseContext().getPackageManager();
@@ -332,4 +332,97 @@ public class Setting {
public static void putLiveTabVisible(boolean visible) {
Prefers.put("live_tab_visible", visible);
}
// WebDAV 同步配置
public static String getWebDAVUrl() {
return Prefers.getString("webdav_url");
}
public static void putWebDAVUrl(String url) {
Prefers.put("webdav_url", url);
}
public static String getWebDAVUsername() {
return Prefers.getString("webdav_username");
}
public static void putWebDAVUsername(String username) {
Prefers.put("webdav_username", username);
}
public static String getWebDAVPassword() {
return Prefers.getString("webdav_password");
}
public static void putWebDAVPassword(String password) {
Prefers.put("webdav_password", password);
}
public static boolean isWebDAVAutoSync() {
return Prefers.getBoolean("webdav_auto_sync", false);
}
public static void putWebDAVAutoSync(boolean autoSync) {
Prefers.put("webdav_auto_sync", autoSync);
}
public static int getWebDAVSyncInterval() {
return Prefers.getInt("webdav_sync_interval", 30); // 默认30分钟
}
public static void putWebDAVSyncInterval(int minutes) {
Prefers.put("webdav_sync_interval", minutes);
}
// WebDAV 同步模式:ACCOUNT(账号模式)或 CODE(同步码模式)
public static String getWebDAVSyncMode() {
return Prefers.getString("webdav_sync_mode", "ACCOUNT");
}
public static void putWebDAVSyncMode(String mode) {
Prefers.put("webdav_sync_mode", mode);
}
// 同步码(用于同步码模式)
public static String getWebDAVSyncCode() {
return Prefers.getString("webdav_sync_code");
}
public static void putWebDAVSyncCode(String code) {
Prefers.put("webdav_sync_code", code);
}
// 公开存储URL(用于同步码模式,如GitHub Gist URL
public static String getWebDAVPublicUrl() {
return Prefers.getString("webdav_public_url");
}
public static void putWebDAVPublicUrl(String url) {
Prefers.put("webdav_public_url", url);
}
// GitHub Gist相关(用于同步码模式)
public static String getWebDAVGistId() {
return Prefers.getString("webdav_gist_id");
}
public static void putWebDAVGistId(String gistId) {
Prefers.put("webdav_gist_id", gistId);
}
public static String getWebDAVGistRawUrl() {
return Prefers.getString("webdav_gist_raw_url");
}
public static void putWebDAVGistRawUrl(String url) {
Prefers.put("webdav_gist_raw_url", url);
}
public static String getWebDAVGistToken() {
return Prefers.getString("webdav_gist_token");
}
public static void putWebDAVGistToken(String token) {
Prefers.put("webdav_gist_token", token);
}
}
@@ -18,53 +18,219 @@ public class Download {
private final File file;
private final String url;
private final String fallbackUrl;
private Callback callback;
private static final int MAX_RETRY_COUNT = 3; // 最大重试次数
public static Download create(String url, File file) {
return create(url, file, null);
}
public static Download create(String url, File file, Callback callback) {
return new Download(url, file, callback);
return create(url, file, null, callback);
}
public static Download create(String url, File file, String fallbackUrl, Callback callback) {
return new Download(url, file, fallbackUrl, callback);
}
public Download(String url, File file, Callback callback) {
this(url, file, null, callback);
}
public Download(String url, File file, String fallbackUrl, Callback callback) {
this.url = url;
this.file = file;
this.fallbackUrl = fallbackUrl;
this.callback = callback;
}
public void start() {
if (url == null || url.isEmpty()) {
if (callback != null) {
App.post(() -> callback.error("下载URL为空"));
}
return;
}
if (url.startsWith("file")) return;
if (callback == null) doInBackground();
else App.execute(this::doInBackground);
if (file == null) {
if (callback != null) {
App.post(() -> callback.error("保存文件路径为空"));
}
return;
}
if (callback == null) {
// 无回调时,直接执行(同步)
doInBackgroundWithFallback();
} else {
// 有回调时,异步执行
App.execute(this::doInBackgroundWithFallback);
}
}
/**
* 带智能回退的下载方法
* 先尝试主URL(通常是jsDelivr CDN),失败后回退到备用URL
*/
private void doInBackgroundWithFallback() {
// 先尝试主URL
boolean mainSuccess = doInBackground(url, "主URL");
if (mainSuccess) {
return;
}
// 主URL失败,如果有回退URL,尝试回退URL
if (fallbackUrl != null && !fallbackUrl.equals(url)) {
Logger.d("Download: 主URL下载失败,回退到备用URL: " + fallbackUrl);
doInBackground(fallbackUrl, "备用URL");
}
}
/**
* 使用指定URL下载文件(带重试机制)
*/
private boolean doInBackground(String downloadUrl, String source) {
Exception lastException = null;
for (int attempt = 1; attempt <= MAX_RETRY_COUNT; attempt++) {
try {
if (callback != null) {
App.post(() -> callback.progress(0));
}
boolean success = downloadWithUrl(downloadUrl, source, attempt);
if (success) {
return true;
}
} catch (Exception e) {
lastException = e;
Logger.w("Download: 下载失败 (来源: " + source + ", 尝试 " + attempt + "/" + MAX_RETRY_COUNT + "): " + e.getMessage());
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_COUNT) {
try {
long retryDelay = 500L * attempt; // 递增延迟
Thread.sleep(retryDelay);
Logger.d("Download: 等待 " + retryDelay + "ms 后重试...");
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
}
}
}
// 所有尝试都失败
if (callback != null && lastException != null) {
String errorMsg = lastException.getMessage();
App.post(() -> callback.error(errorMsg != null ? errorMsg : "下载失败"));
}
return false;
}
/**
* 使用指定URL下载文件
*/
private boolean downloadWithUrl(String downloadUrl, String source, int attempt) throws Exception {
if (downloadUrl == null || downloadUrl.isEmpty()) {
throw new Exception("下载URL为空");
}
if (file == null) {
throw new Exception("保存文件路径为空");
}
Response res = null;
InputStream inputStream = null;
try {
res = OkHttp.newCall(downloadUrl, downloadUrl).execute();
// 检查HTTP响应状态码
if (!res.isSuccessful()) {
throw new Exception("下载失败: HTTP " + res.code() + " " + (res.message() != null ? res.message() : "未知错误"));
}
// 检查响应体是否存在
if (res.body() == null) {
throw new Exception("下载失败: 响应体为空");
}
// 获取输入流
inputStream = res.body().byteStream();
if (inputStream == null) {
throw new Exception("下载失败: 无法获取输入流");
}
Path.create(file);
// 获取文件大小,如果无法获取则使用-1表示未知大小
String contentLengthStr = res.header(HttpHeaders.CONTENT_LENGTH);
long expectedLength = -1;
if (contentLengthStr != null && !contentLengthStr.isEmpty()) {
try {
expectedLength = Long.parseLong(contentLengthStr);
if (expectedLength < 0) {
expectedLength = -1;
}
} catch (NumberFormatException e) {
Logger.w("Download: 无法解析Content-Length: " + contentLengthStr);
expectedLength = -1;
}
}
// 下载文件
download(inputStream, expectedLength);
// 验证下载的文件(如果知道预期大小)
if (expectedLength > 0 && !verifyDownloadedFile(file, expectedLength)) {
throw new Exception("下载的文件可能已损坏,请重试");
}
Logger.d("Download: 下载成功 (来源: " + source + ", 尝试 " + attempt + "/" + MAX_RETRY_COUNT + ")");
if (callback != null) {
App.post(() -> callback.success(file));
}
return true;
} catch (Exception e) {
// 如果下载失败,删除可能不完整的文件
if (file != null && file.exists()) {
try {
file.delete();
} catch (Exception ignored) {
}
}
throw e;
} finally {
// 关闭输入流
if (inputStream != null) {
try {
inputStream.close();
} catch (Exception ignored) {
}
}
// 关闭响应
if (res != null) {
try {
res.close();
} catch (Exception ignored) {
}
}
}
}
public void cancel() {
OkHttp.cancel(url);
if (fallbackUrl != null) {
OkHttp.cancel(fallbackUrl);
}
Path.clear(file);
callback = null;
}
private void doInBackground() {
try (Response res = OkHttp.newCall(url, url).execute()) {
Path.create(file);
long expectedLength = Long.parseLong(res.header(HttpHeaders.CONTENT_LENGTH, "0"));
download(res.body().byteStream(), expectedLength);
// 验证下载的文件
if (!verifyDownloadedFile(file, expectedLength)) {
App.post(() -> {if (callback != null) callback.error("下载的文件可能已损坏,请重试");});
return;
}
App.post(() -> {if (callback != null) callback.success(file);});
} catch (Exception e) {
App.post(() -> {if (callback != null) callback.error(e.getMessage());});
}
}
private void download(InputStream is, long length) throws Exception {
if (is == null) {
throw new Exception("输入流为空,无法下载");
}
try (BufferedInputStream input = new BufferedInputStream(is); FileOutputStream os = new FileOutputStream(file)) {
byte[] buffer = new byte[4096];
int readBytes;
@@ -72,39 +238,56 @@ public class Download {
while ((readBytes = input.read(buffer)) != -1) {
totalBytes += readBytes;
os.write(buffer, 0, readBytes);
int progress = (int) (totalBytes / length * 100.0);
App.post(() -> {if (callback != null) callback.progress(progress);});
// 只有当知道文件大小时才计算进度
if (length > 0 && callback != null) {
int progress = (int) (totalBytes * 100.0 / length);
final int finalProgress = Math.min(progress, 100); // 确保不超过100%,并设为final
App.post(() -> callback.progress(finalProgress));
} else if (callback != null) {
// 不知道文件大小时,显示不确定进度
App.post(() -> callback.progress(-1));
}
}
// 下载完成后,如果不知道文件大小,显示100%
if (length <= 0 && callback != null) {
App.post(() -> callback.progress(100));
}
}
}
private boolean verifyDownloadedFile(File file, long expectedLength) {
try {
// 检查文件大小
if (file.length() != expectedLength) {
// 如果文件不存在或为空,验证失败
if (file == null || !file.exists() || file.length() == 0) {
Logger.e("File verification failed: file does not exist or is empty");
return false;
}
// 如果知道预期大小,检查文件大小是否匹配
if (expectedLength > 0 && file.length() != expectedLength) {
Logger.e("File size mismatch: expected " + expectedLength + ", actual " + file.length());
return false;
}
// 检查APK文件头 (ZIP文件头)
if (file.length() < 4) return false;
if (file.length() < 4) {
Logger.e("File too small: " + file.length() + " bytes");
return false;
}
try (FileInputStream fis = new FileInputStream(file)) {
byte[] header = new byte[4];
fis.read(header);
// ZIP文件头应该是 0x504B0304 (PK..)
if (header[0] != 0x50 || header[1] != 0x4B || header[2] != 0x03 || header[3] != 0x04) {
Logger.e("Invalid APK file header");
int bytesRead = fis.read(header);
if (bytesRead < 4) {
Logger.e("Cannot read file header");
return false;
}
// 额外验证:检查APK文件是否完整
// 尝试读取ZIP文件结构
fis.getChannel().position(0);
byte[] buffer = new byte[1024];
int bytesRead = fis.read(buffer);
if (bytesRead < 4) {
Logger.e("APK file too small or corrupted");
// ZIP文件头应该是 0x504B0304 (PK..)
if (header[0] != 0x50 || header[1] != 0x4B || header[2] != 0x03 || header[3] != 0x04) {
Logger.e("Invalid APK file header: " + String.format("%02X %02X %02X %02X", header[0], header[1], header[2], header[3]));
return false;
}
}
@@ -113,6 +296,7 @@ public class Download {
return true;
} catch (Exception e) {
Logger.e("File verification failed: " + e.getMessage());
e.printStackTrace();
return false;
}
}
@@ -0,0 +1,312 @@
package com.fongmi.android.tv.utils;
import android.text.TextUtils;
import com.fongmi.android.tv.Setting;
import com.fongmi.android.tv.App;
import com.fongmi.android.tv.bean.Backup;
import com.fongmi.android.tv.bean.History;
import com.fongmi.android.tv.db.AppDatabase;
import com.github.catvod.net.OkHttp;
import com.github.catvod.utils.Logger;
import com.github.catvod.utils.Prefers;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.lang.reflect.Type;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import okhttp3.MediaType;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
/**
* 同步码管理器(无需WebDAV账号)
* 使用公开的HTTP存储服务,通过同步码区分用户
*
* 方案:使用GitHub Gist作为存储
* - 用户创建一个公开的GitHub Gist
* - 通过同步码作为文件名的一部分来区分不同用户
* - 所有知道同步码的设备可以共享数据
*/
public class SyncCodeManager {
private static final String HISTORY_FILE_PREFIX = "xmbox_history_";
private static final String SETTINGS_FILE_PREFIX = "xmbox_settings_";
private static final String BACKUP_FILE_PREFIX = "xmbox_backup_";
private static final String FILE_SUFFIX = ".json";
private static SyncCodeManager instance;
private String syncCode;
private String gistId; // GitHub Gist ID
private String gistToken; // GitHub Personal Access Token(用于上传,可选)
public static SyncCodeManager get() {
if (instance == null) {
instance = new SyncCodeManager();
}
return instance;
}
private SyncCodeManager() {
loadConfig();
}
/**
* 加载配置
*/
private void loadConfig() {
syncCode = Setting.getWebDAVSyncCode();
gistId = Setting.getWebDAVGistId();
gistToken = Setting.getWebDAVGistToken();
}
/**
* 检查是否已配置
*/
public boolean isConfigured() {
return !TextUtils.isEmpty(syncCode) && !TextUtils.isEmpty(gistId);
}
/**
* 生成同步码
* @return 8位随机同步码(字母+数字)
*/
public static String generateSyncCode() {
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
Random random = new Random();
StringBuilder code = new StringBuilder();
for (int i = 0; i < 8; i++) {
code.append(chars.charAt(random.nextInt(chars.length())));
}
return code.toString();
}
/**
* 获取文件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,759 @@
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.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 {
// 获取所有观看记录
List<History> historyList = AppDatabase.get().getHistoryDao().findAll();
if (historyList == null) {
historyList = new java.util.ArrayList<>();
}
Logger.d("WebDAV: 准备上传观看记录,共 " + historyList.size() + "");
String json = App.gson().toJson(historyList);
if (TextUtils.isEmpty(json)) {
Logger.w("WebDAV: JSON数据为空");
json = "[]"; // 确保至少有一个有效的JSON数组
}
// 确保目录存在(如果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: 未配置,无法下载观看记录");
return false;
}
try {
String fileUrl = getFileUrl(HISTORY_FILE);
// 检查文件是否存在
if (!sardine.exists(fileUrl)) {
Logger.d("WebDAV: 观看记录文件不存在,跳过下载");
return false;
}
// 下载文件(使用循环读取,避免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().findAll();
// 创建本地记录的映射(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);
}
}
// 合并远程记录
List<History> toInsert = new java.util.ArrayList<>();
List<History> toUpdate = new java.util.ArrayList<>();
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) {
// 本地没有,直接添加
toInsert.add(remote);
} else {
// 本地有,比较createTime,保留较新的
if (remote.getCreateTime() > local.getCreateTime()) {
// 远程更新,更新本地
toUpdate.add(remote);
} else if (remote.getCreateTime() == local.getCreateTime()) {
// 时间相同,比较position,保留进度更靠后的
// 注意:position可能是C.TIME_UNSET(负数),需要处理
long remotePos = remote.getPosition();
long localPos = local.getPosition();
// 如果都是有效值(>=0),比较大小;如果有无效值,保留有效值
if (remotePos >= 0 && localPos >= 0) {
if (remotePos > localPos) {
toUpdate.add(remote);
}
} else if (remotePos >= 0 && localPos < 0) {
// 远程有效,本地无效,更新
toUpdate.add(remote);
}
// 否则保留本地,不更新
}
// 否则保留本地,不更新
}
}
// 执行插入和更新
if (!toInsert.isEmpty()) {
AppDatabase.get().getHistoryDao().insert(toInsert);
Logger.d("WebDAV: 新增 " + toInsert.size() + " 条观看记录");
}
if (!toUpdate.isEmpty()) {
AppDatabase.get().getHistoryDao().update(toUpdate);
Logger.d("WebDAV: 更新 " + toUpdate.size() + " 条观看记录");
}
Logger.d("WebDAV: 观看记录合并完成,远程 " + remoteHistoryList.size() + " 条,本地 " + localHistoryList.size() + "");
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();
}
}
+1 -1
View File
@@ -91,7 +91,7 @@
<string name="setting_app">应用设置</string>
<string name="setting_network">网络设置</string>
<string name="setting_data">数据管理</string>
<string name="app_version">v3.0.9</string>
<string name="app_version">v3.1.1</string>
<string name="about_github">在GitHub上查看</string>
<!-- Backup & Restore -->
+1 -1
View File
@@ -89,7 +89,7 @@
<string name="setting_app">應用設置</string>
<string name="setting_network">網絡設置</string>
<string name="setting_data">數據管理</string>
<string name="app_version">v3.0.9</string>
<string name="app_version">v3.1.1</string>
<string name="about_github">在GitHub上查看</string>
<!-- Backup & Restore -->
+1 -1
View File
@@ -92,7 +92,7 @@
<string name="setting_choose">Choose</string>
<string name="setting_off">Off</string>
<string name="setting_on">On</string>
<string name="app_version">v3.0.9</string>
<string name="app_version">v3.1.1</string>
<string name="about_github">View on GitHub</string>
<!-- Backup & Restore -->
@@ -14,6 +14,7 @@ import com.fongmi.android.tv.utils.Download;
import com.fongmi.android.tv.utils.FileUtil;
import com.fongmi.android.tv.utils.Notify;
import com.fongmi.android.tv.utils.ResUtil;
import com.fongmi.android.tv.utils.UpdateInstaller;
import com.github.catvod.net.OkHttp;
import com.github.catvod.utils.Github;
import com.github.catvod.utils.Logger;
@@ -29,53 +30,34 @@ import java.util.Locale;
public class Updater implements Download.Callback {
private DialogUpdateBinding binding;
private final Download download;
private Download download;
private AlertDialog dialog;
private boolean dev;
private boolean forceCheck; // 是否为手动检查
private boolean autoShow; // 是否自动显示更新对话框(用于自动检查)
private String latestVersion; // 存储检测到的最新版本
private String releaseApkUrl; // 从 GitHub Release 获取的 APK 下载链接
private String releaseApkUrl; // 从 GitHub Release 获取的 APK 下载链接jsDelivr CDN
private String fallbackApkUrl; // 备用下载链接(GitHub原始URL)
// 静态变量:记录上次检查时间(用于时间间隔限制)
private static long lastCheckTime = 0;
private static final long CHECK_INTERVAL = 60 * 60 * 1000; // 1小时(毫秒)
private File getFile() {
return Path.root("Download", "XMBOX-update.apk");
}
private String getJson() {
return Github.getJson(dev, BuildConfig.FLAVOR_mode);
// Android 10+ 无法直接访问外部存储的Download目录
// 使用应用的cache目录,FileProvider可以正常访问
return Path.cache("XMBOX-update.apk");
}
private String getApk() {
// 优先使用从 GitHub Release 获取的 APK URL
// 使用从 GitHub Release 获取的 APK URLjsDelivr CDN
if (releaseApkUrl != null && !releaseApkUrl.isEmpty()) {
Logger.d("APK download URL from Release: " + releaseApkUrl);
Logger.d("APK download URL from Release (jsDelivr): " + releaseApkUrl);
return releaseApkUrl;
}
// 使用JSON中指定的具体下载路径
try {
String response = OkHttp.string(getJson());
JSONObject object = new JSONObject(response);
JSONObject downloads = object.optJSONObject("downloads");
if (downloads != null) {
String abi = BuildConfig.FLAVOR_abi;
String downloadPath = downloads.optString(abi);
if (!downloadPath.isEmpty()) {
// 直接构建完整URL,不通过Github.getApk()避免重复添加路径
String baseUrl = Github.useCnMirror() ?
"https://gitee.com/ochenoktochen/XMBOX-Release/raw/main" :
"https://raw.githubusercontent.com/Tosencen/XMBOX-Release/main";
String fullUrl = baseUrl + "/apk/" + (dev ? "dev" : "release") + "/" + downloadPath;
Logger.d("APK download URL: " + fullUrl);
return fullUrl;
}
}
} catch (Exception e) {
Logger.e("Failed to get download path from JSON: " + e.getMessage());
}
// 回退到原来的方式
String fallbackUrl = Github.getApk(dev, BuildConfig.FLAVOR_mode + "-" + BuildConfig.FLAVOR_abi);
Logger.d("APK fallback URL: " + fallbackUrl);
return fallbackUrl;
// 如果没有获取到URL,返回空(不应该发生)
Logger.e("Updater: 未找到APK下载链接");
return "";
}
public static Updater create() {
@@ -83,8 +65,9 @@ public class Updater implements Download.Callback {
}
public Updater() {
this.download = Download.create(getApk(), getFile(), this);
this.forceCheck = false;
this.autoShow = false;
// download对象将在需要时创建
}
public Updater force() {
@@ -94,6 +77,15 @@ public class Updater implements Download.Callback {
return this;
}
/**
* 设置自动检查模式(应用启动时自动检查)
*/
public Updater auto() {
this.forceCheck = false;
this.autoShow = true; // 自动显示更新对话框
return this;
}
public Updater release() {
this.dev = false;
return this;
@@ -110,6 +102,16 @@ public class Updater implements Download.Callback {
}
public void start(Activity activity) {
// 如果是自动检查,检查时间间隔
if (autoShow && !forceCheck) {
long currentTime = System.currentTimeMillis();
long timeSinceLastCheck = currentTime - lastCheckTime;
// 1小时内只检查一次
if (lastCheckTime > 0 && timeSinceLastCheck < CHECK_INTERVAL) {
Logger.d("Updater: 距离上次检查仅 " + (timeSinceLastCheck / 1000 / 60) + " 分钟,跳过本次检查");
return;
}
}
App.execute(() -> doInBackground(activity));
}
@@ -119,48 +121,9 @@ public class Updater implements Download.Callback {
private void doInBackground(Activity activity) {
Logger.d("Updater: Starting update check...");
try {
// 优先使用 JSON 方式检测更新(兼容性更好)
String response = OkHttp.string(getJson());
JSONObject object = new JSONObject(response);
String name = object.optString("name");
String desc = object.optString("desc");
int code = object.optInt("code");
Logger.d("Updater: JSON Remote version: " + name + ", code: " + code);
Logger.d("Updater: Local version: " + BuildConfig.VERSION_NAME + ", code: " + BuildConfig.VERSION_CODE);
// 使用 JSON 中的版本信息
if (need(code, name)) {
Logger.d("Updater: Update needed (from JSON), showing dialog");
this.latestVersion = name; // 保存最新版本号
// 从 JSON 获取下载链接
JSONObject downloads = object.optJSONObject("downloads");
if (downloads != null) {
String abi = BuildConfig.FLAVOR_abi;
String downloadPath = downloads.optString(abi);
if (!downloadPath.isEmpty()) {
String baseUrl = Github.useCnMirror() ?
"https://gitee.com/ochenoktochen/XMBOX-Release/raw/main" :
"https://raw.githubusercontent.com/Tosencen/XMBOX-Release/main";
this.releaseApkUrl = baseUrl + "/apk/" + (dev ? "dev" : "release") + "/" + downloadPath;
Logger.d("Updater: APK URL from JSON: " + this.releaseApkUrl);
}
}
App.post(() -> show(activity, name, desc));
} else {
Logger.d("Updater: No update needed (from JSON)");
if (forceCheck) {
App.post(() -> Notify.show("已是最新版本 " + name));
}
}
} catch (Exception e) {
Logger.e("Updater: JSON check failed, trying GitHub API: " + e.getMessage());
// JSON 检测失败,尝试使用 GitHub Releases API
checkViaGitHubAPI(activity);
}
lastCheckTime = System.currentTimeMillis(); // 更新检查时间
// 直接使用 GitHub Releases API 检查更新
checkViaGitHubAPI(activity);
}
private void checkViaGitHubAPI(Activity activity) {
@@ -168,12 +131,42 @@ public class Updater implements Download.Callback {
String releasesUrl = "https://api.github.com/repos/Tosencen/XMBOX/releases/latest";
Logger.d("Updater: Trying GitHub Releases API: " + releasesUrl);
String response = OkHttp.string(releasesUrl);
// 检查是否有GitHub Token
String githubToken = BuildConfig.GITHUB_TOKEN;
String response;
if (githubToken != null && !githubToken.isEmpty()) {
// 使用token进行认证请求(5000次/小时)
java.util.Map<String, String> headers = new java.util.HashMap<>();
headers.put("Authorization", "Bearer " + githubToken);
headers.put("Accept", "application/vnd.github.v3+json");
Logger.d("Updater: Using GitHub Token for authenticated request");
response = OkHttp.string(releasesUrl, headers);
} else {
// 使用未认证请求(60次/小时)
Logger.d("Updater: Using unauthenticated request (60 requests/hour limit)");
response = OkHttp.string(releasesUrl);
}
if (response.contains("rate limit exceeded")) {
// 检查响应是否为空(可能是网络错误、VPN问题等)
if (response == null || response.isEmpty()) {
Logger.e("Updater: 网络请求失败,响应为空。可能是网络连接问题或VPN配置问题");
if (forceCheck) {
// 手动检查时,显示错误提示
App.post(() -> {
Notify.show("检查更新失败:网络连接异常,请检查网络设置或VPN配置");
showVersionInfo(activity, BuildConfig.VERSION_NAME, "");
});
} else {
Logger.w("Updater: 自动检查失败,网络不可用");
}
return;
}
if (response.contains("rate limit exceeded") || response.contains("API rate limit exceeded")) {
Logger.e("Updater: Rate limit exceeded");
if (forceCheck) {
App.post(() -> Notify.show("检查更新失败:API请求过于频繁,请稍后重试"));
// 手动检查时,显示版本信息弹窗(不显示错误提示)
App.post(() -> showVersionInfo(activity, BuildConfig.VERSION_NAME, ""));
}
return;
}
@@ -181,7 +174,8 @@ public class Updater implements Download.Callback {
if (response.contains("Not Found") || response.contains("404")) {
Logger.e("Updater: Release not found");
if (forceCheck) {
App.post(() -> Notify.show("检查更新失败:无法连接到更新服务器"));
// 手动检查时,显示版本信息弹窗(不显示错误提示)
App.post(() -> showVersionInfo(activity, BuildConfig.VERSION_NAME, ""));
}
return;
}
@@ -196,26 +190,106 @@ public class Updater implements Download.Callback {
// 从 assets 中查找 APK
JSONArray assets = release.optJSONArray("assets");
if (assets != null) {
String targetApkName = BuildConfig.FLAVOR_mode + "-" + BuildConfig.FLAVOR_abi + "-v" + version + ".apk";
for (int i = 0; i < assets.length(); i++) {
String mode = BuildConfig.FLAVOR_mode;
String abi = BuildConfig.FLAVOR_abi;
// 尝试多种文件名格式
String[] possibleNames = {
mode + "-" + abi + "-v" + version + ".apk", // mobile-arm64_v8a-v3.1.0.apk
mode + "-" + abi + "-release.apk", // mobile-arm64_v8a-release.apk
mode + "-" + abi + ".apk", // mobile-arm64_v8a.apk
mode + "-" + abi + "-" + version + ".apk" // mobile-arm64_v8a-3.1.0.apk
};
boolean found = false;
for (int i = 0; i < assets.length() && !found; i++) {
JSONObject asset = assets.getJSONObject(i);
if (targetApkName.equals(asset.optString("name"))) {
this.releaseApkUrl = asset.optString("browser_download_url");
break;
String assetName = asset.optString("name");
// 检查是否匹配任何可能的文件名格式
for (String targetName : possibleNames) {
if (targetName.equals(assetName)) {
String githubUrl = asset.optString("browser_download_url");
// jsDelivr无法访问GitHub Release文件,直接使用GitHub Release URL
// 如果GitHub访问慢,可以配置代理或使用其他CDN
this.releaseApkUrl = githubUrl;
this.fallbackApkUrl = githubUrl;
Logger.d("Updater: 找到匹配的APK: " + assetName);
Logger.d("Updater: APK URL (GitHub Release): " + this.releaseApkUrl);
found = true;
break;
}
}
}
// 如果精确匹配失败,尝试模糊匹配(包含mode和abi的APK文件)
if (!found) {
Logger.w("Updater: 未找到精确匹配的APK,尝试模糊匹配...");
for (int i = 0; i < assets.length(); i++) {
JSONObject asset = assets.getJSONObject(i);
String assetName = asset.optString("name");
// 检查文件名是否包含mode和abi,且是APK文件
if (assetName.endsWith(".apk") &&
assetName.contains(mode) &&
assetName.contains(abi.replace("_", "-"))) {
String githubUrl = asset.optString("browser_download_url");
// jsDelivr无法访问GitHub Release文件,直接使用GitHub Release URL
this.releaseApkUrl = githubUrl;
this.fallbackApkUrl = githubUrl;
Logger.d("Updater: 找到模糊匹配的APK: " + assetName);
Logger.d("Updater: APK URL (GitHub Release): " + this.releaseApkUrl);
found = true;
break;
}
}
}
if (!found) {
Logger.e("Updater: 在Release中未找到匹配的APK文件");
Logger.e("Updater: 期望的格式: " + mode + "-" + abi + "-v" + version + ".apk");
Logger.e("Updater: 可用的assets:");
for (int i = 0; i < assets.length(); i++) {
JSONObject asset = assets.getJSONObject(i);
String assetName = asset.optString("name");
if (assetName.endsWith(".apk")) {
Logger.e("Updater: - " + assetName);
}
}
}
} else {
Logger.e("Updater: Release中没有assets数组");
}
if (needUpdate(version)) {
this.latestVersion = version;
// 有新版本时,自动显示或手动显示更新对话框
App.post(() -> show(activity, version, body));
} else if (forceCheck) {
App.post(() -> Notify.show("已是最新版本 " + version));
} else {
// 没有新版本
if (forceCheck) {
// 手动检查时,显示版本信息弹窗
App.post(() -> showVersionInfo(activity, version, body));
} else if (autoShow) {
// 自动检查时,不显示任何内容(静默检查)
Logger.d("Updater: 自动检查完成,当前已是最新版本");
}
}
} catch (Exception e) {
Logger.e("Updater: GitHub API check failed: " + e.getMessage());
e.printStackTrace();
if (forceCheck) {
App.post(() -> Notify.show("检查更新失败:无法连接到更新服务器"));
// 手动检查时,显示错误提示
String errorMsg = e.getMessage();
if (errorMsg != null && (errorMsg.contains("network") || errorMsg.contains("timeout") || errorMsg.contains("connect"))) {
App.post(() -> {
Notify.show("检查更新失败:网络连接异常,请检查网络设置或VPN配置");
showVersionInfo(activity, BuildConfig.VERSION_NAME, "");
});
} else {
App.post(() -> showVersionInfo(activity, BuildConfig.VERSION_NAME, ""));
}
} else {
Logger.w("Updater: 自动检查失败: " + e.getMessage());
}
}
}
@@ -256,42 +330,51 @@ public class Updater implements Download.Callback {
binding.desc.setText(desc);
}
/**
* 显示版本信息弹窗(无更新时)
*/
private void showVersionInfo(Activity activity, String remoteVersion, String desc) {
binding = DialogUpdateBinding.inflate(LayoutInflater.from(activity));
// 先设置内容,只显示当前版本号,不使用远程信息
binding.desc.setText(BuildConfig.VERSION_NAME);
check().create(activity, "最新版本").show();
// 隐藏确认按钮,只显示取消按钮(改为"确定")
dialog.getButton(DialogInterface.BUTTON_POSITIVE).setVisibility(View.GONE);
dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setText("确定");
dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener(v -> {
if (dialog != null) dialog.dismiss();
});
}
private AlertDialog create(Activity activity, String title) {
return dialog = new MaterialAlertDialogBuilder(activity).setTitle(title).setView(binding.getRoot()).setPositiveButton(R.string.update_confirm, null).setNegativeButton(R.string.dialog_negative, null).setCancelable(false).create();
}
private void cancel(View view) {
Setting.putUpdate(false);
download.cancel();
if (download != null) {
download.cancel();
}
dialog.dismiss();
}
private void confirm(View view) {
// 跳转到具体版本的GitHub Releases页面
try {
String url = "https://github.com/Tosencen/XMBOX/releases/tag/v" + latestVersion;
Logger.d("Updater: Attempting to open URL: " + url);
// 开始下载更新(使用jsDelivr CDN,失败时回退到GitHub
String downloadUrl = getApk();
String fallbackUrl = this.fallbackApkUrl;
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse(url));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// 检查是否有应用可以处理这个Intent
if (intent.resolveActivity(App.get().getPackageManager()) != null) {
App.get().startActivity(intent);
Logger.d("Updater: Successfully started browser intent");
dismiss();
} else {
Logger.e("Updater: No app can handle the URL");
Notify.show("没有找到可以打开链接的应用,请手动访问GitHub下载");
dismiss();
}
} catch (Exception e) {
Logger.e("Updater: Failed to open GitHub releases page: " + e.getMessage());
e.printStackTrace();
Notify.show("无法打开更新页面,请手动访问GitHub下载");
dismiss();
// 检查URL是否为空
if (downloadUrl == null || downloadUrl.isEmpty()) {
Logger.e("Updater: 下载URL为空,无法下载");
Notify.show("无法获取下载链接,请稍后重试或手动下载");
return;
}
Logger.d("Updater: 开始下载,URL: " + downloadUrl);
// 创建带回退URL的下载对象
this.download = Download.create(downloadUrl, getFile(), fallbackUrl, this);
this.download.start();
}
private void dismiss() {
@@ -314,7 +397,30 @@ public class Updater implements Download.Callback {
@Override
public void success(File file) {
FileUtil.openFile(file);
dismiss();
// 使用UpdateInstaller处理安装,包括权限检查和请求
UpdateInstaller installer = UpdateInstaller.get();
// 检查安装权限
if (!installer.hasInstallPermission()) {
// 没有权限,请求权限并保存待安装的文件
Logger.d("Updater: 没有安装权限,请求权限");
installer.requestInstallPermission();
// 保存待安装的文件,将在权限授予后自动安装
installer.install(file, true); // checkPermission=true会保存文件
Notify.show("请授予安装权限以完成更新");
dismiss();
return;
}
// 有权限,直接安装
boolean success = installer.install(file, false);
if (success) {
Logger.d("Updater: 已启动安装程序");
dismiss();
} else {
Logger.e("Updater: 启动安装程序失败");
Notify.show("无法启动安装程序,请检查文件是否完整");
dismiss();
}
}
}
@@ -23,10 +23,12 @@ import android.text.Spanned;
import android.text.TextUtils;
import android.text.format.DateFormat;
import android.text.style.ClickableSpan;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.media.AudioManager;
import android.widget.RelativeLayout;
import android.widget.TextView;
import android.widget.Toast;
@@ -166,6 +168,7 @@ public class VideoActivity extends BaseActivity implements Clock.Callback, Custo
private int mBatteryLevel = -1;
private boolean mIsCharging = false;
private boolean mPausedByScreen = false;
private AudioManager mAudioManager;
public static void push(FragmentActivity activity, String text) {
if (FileChooser.isValid(activity, Uri.parse(text))) file(activity, FileChooser.getPathFromUri(activity, Uri.parse(text)));
@@ -308,6 +311,7 @@ public class VideoActivity extends BaseActivity implements Clock.Callback, Custo
mDialogs = new ArrayList<>();
mBroken = new ArrayList<>();
mClock = Clock.create();
mAudioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
mR1 = this::hideControl;
mR2 = this::setTraffic;
mR3 = this::setOrient;
@@ -1823,6 +1827,71 @@ public class VideoActivity extends BaseActivity implements Clock.Callback, Custo
setStop(true);
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
// 只在视频播放时处理键盘事件
if (mPlayers != null && !mPlayers.isEmpty()) {
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_LEFT:
// 左方向键:快退10秒
if (mPlayers.isPlaying() || mPlayers.getPosition() > 0) {
long currentPosition = mPlayers.getPosition();
long seekTime = -10000; // 快退10秒
long newPosition = Math.max(0, currentPosition + seekTime);
mPlayers.seekTo(newPosition);
// 显示快退提示
onSeek(seekTime);
App.post(() -> {
mBinding.widget.seek.setVisibility(View.GONE);
}, 1000);
return true;
}
break;
case KeyEvent.KEYCODE_DPAD_RIGHT:
// 右方向键:快进10秒
if (mPlayers.isPlaying() || mPlayers.getPosition() > 0) {
long currentPosition = mPlayers.getPosition();
long duration = mPlayers.getDuration();
long seekTime = 10000; // 快进10秒
long newPosition = Math.min(duration > 0 ? duration : Long.MAX_VALUE, currentPosition + seekTime);
mPlayers.seekTo(newPosition);
// 显示快进提示
onSeek(seekTime);
App.post(() -> {
mBinding.widget.seek.setVisibility(View.GONE);
}, 1000);
return true;
}
break;
case KeyEvent.KEYCODE_DPAD_UP:
// 上方向键:增加音量
if (mAudioManager != null) {
int currentVolume = mAudioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
int maxVolume = mAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
int newVolume = Math.min(maxVolume, currentVolume + 1);
mAudioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0);
onVolume((int) (newVolume * 100.0f / maxVolume));
App.post(() -> onVolumeEnd(), 1000);
return true;
}
break;
case KeyEvent.KEYCODE_DPAD_DOWN:
// 下方向键:减少音量
if (mAudioManager != null) {
int currentVolume = mAudioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
int maxVolume = mAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
int newVolume = Math.max(0, currentVolume - 1);
mAudioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0);
onVolume((int) (newVolume * 100.0f / maxVolume));
App.post(() -> onVolumeEnd(), 1000);
return true;
}
break;
}
}
return super.onKeyDown(keyCode, event);
}
@Override
public void onBackPressed() {
if (isVisible(mBinding.control.getRoot())) {
@@ -0,0 +1,474 @@
package com.fongmi.android.tv.ui.dialog;
import android.content.DialogInterface;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.inputmethod.EditorInfo;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.Fragment;
import com.fongmi.android.tv.App;
import com.fongmi.android.tv.R;
import com.fongmi.android.tv.Setting;
import com.fongmi.android.tv.databinding.DialogWebdavBinding;
import com.fongmi.android.tv.event.RefreshEvent;
import com.fongmi.android.tv.utils.Notify;
import com.fongmi.android.tv.utils.WebDAVSyncManager;
import com.github.catvod.utils.Logger;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
public class WebDAVDialog {
// 预设的WebDAV服务提供商
private static final String[] PROVIDERS = {
"坚果云",
"Nextcloud",
"ownCloud",
"自定义"
};
private static final String[] PROVIDER_URLS = {
"https://dav.jianguoyun.com/dav/XMBOX/", // 坚果云添加XMBOX子目录方便在网页版查看
"", // Nextcloud需要用户输入
"", // ownCloud需要用户输入
"" // 自定义需要用户输入
};
private final DialogWebdavBinding binding;
private final Fragment fragment;
private AlertDialog dialog;
private WebDAVSyncManager syncManager;
private int selectedProvider = 0; // 默认选择坚果云
private boolean isInitializing = false; // 标记是否正在初始化防止初始化时触发监听器
public static WebDAVDialog create(Fragment fragment) {
return new WebDAVDialog(fragment);
}
public WebDAVDialog(Fragment fragment) {
this.fragment = fragment;
this.binding = DialogWebdavBinding.inflate(LayoutInflater.from(fragment.getContext()));
this.syncManager = WebDAVSyncManager.get();
}
public void show() {
initDialog();
initView();
initEvent();
}
private void initDialog() {
dialog = new MaterialAlertDialogBuilder(binding.getRoot().getContext())
.setTitle("WebDAV 配置")
.setView(binding.getRoot())
.setPositiveButton("保存", this::onPositive)
.setNegativeButton("取消", this::onNegative)
.create();
dialog.getWindow().setDimAmount(0);
dialog.show();
}
private void initView() {
isInitializing = true; // 标记开始初始化
// 加载已保存的配置
String url = Setting.getWebDAVUrl();
String username = Setting.getWebDAVUsername();
String password = Setting.getWebDAVPassword();
boolean autoSync = Setting.isWebDAVAutoSync();
int interval = Setting.getWebDAVSyncInterval();
// 根据保存的URL判断是哪个服务提供商
selectedProvider = getProviderIndexByUrl(url);
binding.providerText.setText(PROVIDERS[selectedProvider]);
// 根据选择的服务提供商决定是否显示URL输入框
if (selectedProvider == PROVIDERS.length - 1) {
// 自定义显示URL输入框
binding.urlInput.setVisibility(View.VISIBLE);
binding.urlText.setText(url);
if (!TextUtils.isEmpty(url)) {
binding.urlText.setSelection(url.length());
}
} else if (selectedProvider == 0) {
// 坚果云永远隐藏输入框有预设URL
binding.urlInput.setVisibility(View.GONE);
} else {
// Nextcloud或ownCloud需要用户输入URL
binding.urlInput.setVisibility(View.VISIBLE);
binding.urlText.setText(url);
if (!TextUtils.isEmpty(url)) {
binding.urlText.setSelection(url.length());
}
}
binding.usernameText.setText(username);
binding.passwordText.setText(password);
binding.autoSyncSwitch.setChecked(autoSync);
binding.syncIntervalText.setText(String.valueOf(interval));
// 根据自动同步开关显示/隐藏同步间隔
updateSyncIntervalVisibility(autoSync);
isInitializing = false; // 初始化完成
}
/**
* 根据URL判断是哪个服务提供商
*/
private int getProviderIndexByUrl(String url) {
if (TextUtils.isEmpty(url)) {
return 0; // 默认坚果云
}
if (url.contains("jianguoyun.com")) {
return 0; // 坚果云
}
if (url.contains("nextcloud")) {
return 1; // Nextcloud
}
if (url.contains("owncloud")) {
return 2; // ownCloud
}
return PROVIDERS.length - 1; // 自定义
}
/**
* 获取当前选择的服务提供商的URL
*/
private String getProviderUrl() {
if (selectedProvider < PROVIDER_URLS.length && !TextUtils.isEmpty(PROVIDER_URLS[selectedProvider])) {
return PROVIDER_URLS[selectedProvider];
}
return "";
}
private void initEvent() {
// 服务提供商选择
binding.providerText.setOnClickListener(v -> onSelectProvider());
// 自动同步开关监听立即保存状态
// 使用setOnClickListener而不是setOnCheckedChangeListener避免覆盖CustomSwitch内部的动画监听器
// AppCompatCheckBox会自动处理状态切换我们只需要在状态切换后获取新状态
binding.autoSyncSwitch.setOnClickListener(v -> {
// 防止初始化时触发监听器
if (isInitializing) {
return;
}
// 使用post()确保在状态切换后获取新状态
binding.autoSyncSwitch.post(() -> {
boolean newState = binding.autoSyncSwitch.isChecked();
// 立即保存自动同步状态
Setting.putWebDAVAutoSync(newState);
// 更新同步间隔的可见性
updateSyncIntervalVisibility(newState);
});
});
// 测试连接按钮
binding.testButton.setOnClickListener(v -> onTestConnection());
// 立即同步按钮
binding.syncButton.setOnClickListener(v -> onSyncNow());
// 同步间隔点击弹出选择对话框
binding.syncIntervalContainer.setOnClickListener(v -> onSelectInterval());
// 密码输入框回车键
binding.passwordText.setOnEditorActionListener((textView, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_DONE) {
dialog.getButton(DialogInterface.BUTTON_POSITIVE).performClick();
return true;
}
return false;
});
}
private void onSelectProvider() {
new MaterialAlertDialogBuilder(binding.getRoot().getContext())
.setTitle("选择服务提供商")
.setSingleChoiceItems(PROVIDERS, selectedProvider, (dialog, which) -> {
selectedProvider = which;
binding.providerText.setText(PROVIDERS[which]);
// 如果是自定义显示URL输入框
if (which == PROVIDERS.length - 1) {
binding.urlInput.setVisibility(View.VISIBLE);
String currentUrl = binding.urlText.getText().toString().trim();
if (TextUtils.isEmpty(currentUrl)) {
binding.urlText.setText("");
}
} else {
// 使用预设的URL
binding.urlInput.setVisibility(View.GONE);
String providerUrl = getProviderUrl();
if (!TextUtils.isEmpty(providerUrl)) {
// URL会在保存时自动填充
} else {
// Nextcloud或ownCloud需要用户输入URL
binding.urlInput.setVisibility(View.VISIBLE);
binding.urlText.setHint("请输入" + PROVIDERS[which] + "服务器地址");
}
}
dialog.dismiss();
})
.setNegativeButton("取消", null)
.show();
}
private void updateSyncIntervalVisibility(boolean visible) {
binding.syncIntervalContainer.setVisibility(visible ? View.VISIBLE : View.GONE);
}
private void onTestConnection() {
String url = getServerUrl();
String username = binding.usernameText.getText().toString().trim();
String password = binding.passwordText.getText().toString().trim();
if (TextUtils.isEmpty(url)) {
showStatus("请选择服务提供商或输入服务器地址", false);
return;
}
if (TextUtils.isEmpty(username)) {
showStatus("请输入用户名", false);
return;
}
if (TextUtils.isEmpty(password)) {
showStatus("请输入密码", false);
return;
}
// 临时保存配置用于测试
Setting.putWebDAVUrl(url);
Setting.putWebDAVUsername(username);
Setting.putWebDAVPassword(password);
// 重新加载配置
syncManager.reloadConfig();
showStatus("正在测试连接...", true);
binding.testButton.setEnabled(false);
// 在后台线程测试连接
App.execute(() -> {
boolean success = syncManager.testConnection();
App.post(() -> {
// 检查对话框是否还存在
if (binding == null || dialog == null || !dialog.isShowing()) {
return;
}
binding.testButton.setEnabled(true);
if (success) {
showStatus("连接成功!", true);
} else {
showStatus("连接失败,请检查配置", false);
}
});
});
}
private void onSyncNow() {
// 先临时保存当前配置用于测试同步
String url = getServerUrl();
String username = binding.usernameText.getText().toString().trim();
String password = binding.passwordText.getText().toString().trim();
// 验证输入
if (TextUtils.isEmpty(url)) {
showStatus("请选择服务提供商或输入服务器地址", false);
return;
}
if (TextUtils.isEmpty(username)) {
showStatus("请输入用户名", false);
return;
}
if (TextUtils.isEmpty(password)) {
showStatus("请输入密码", false);
return;
}
// 临时保存配置用于同步
Setting.putWebDAVUrl(url);
Setting.putWebDAVUsername(username);
Setting.putWebDAVPassword(password);
syncManager.reloadConfig();
if (!syncManager.isConfigured()) {
showStatus("配置无效,无法同步", false);
return;
}
showStatus("正在同步...", true);
binding.syncButton.setEnabled(false);
// 在后台线程执行同步
App.execute(() -> {
try {
// 先上传本地记录
syncManager.uploadHistory();
// 再下载远程记录并合并
boolean downloadSuccess = syncManager.downloadHistory();
App.post(() -> {
// 检查对话框是否还存在
if (binding == null || dialog == null || !dialog.isShowing()) {
return;
}
binding.syncButton.setEnabled(true);
if (downloadSuccess) {
showStatus("同步完成", true);
Notify.show("同步完成");
} else {
showStatus("同步完成(本地数据已上传)", true);
Notify.show("同步完成");
}
});
} catch (Exception e) {
App.post(() -> {
// 检查对话框是否还存在
if (binding == null || dialog == null || !dialog.isShowing()) {
return;
}
binding.syncButton.setEnabled(true);
showStatus("同步失败:" + e.getMessage(), false);
Notify.show("同步失败");
Logger.e("WebDAV: 同步失败: " + e.getMessage());
});
}
});
}
private void onSelectInterval() {
String[] intervals = {"15", "30", "60", "120", "240"};
int currentInterval = Setting.getWebDAVSyncInterval();
int selectedIndex = 0;
for (int i = 0; i < intervals.length; i++) {
if (Integer.parseInt(intervals[i]) == currentInterval) {
selectedIndex = i;
break;
}
}
new MaterialAlertDialogBuilder(binding.getRoot().getContext())
.setTitle("选择同步间隔")
.setSingleChoiceItems(intervals, selectedIndex, (dialog, which) -> {
int interval = Integer.parseInt(intervals[which]);
binding.syncIntervalText.setText(String.valueOf(interval));
// 立即保存同步间隔
Setting.putWebDAVSyncInterval(interval);
dialog.dismiss();
})
.setNegativeButton("取消", null)
.show();
}
private void showStatus(String message, boolean isSuccess) {
// 检查对话框是否还存在
if (binding == null || dialog == null || !dialog.isShowing()) {
return;
}
binding.statusText.setText(message);
binding.statusText.setVisibility(TextUtils.isEmpty(message) ? View.GONE : View.VISIBLE);
// 可以根据isSuccess设置不同的颜色
binding.statusText.setTextColor(isSuccess ?
fragment.getResources().getColor(R.color.white) :
fragment.getResources().getColor(android.R.color.holo_red_dark));
}
/**
* 获取服务器URL根据选择的服务提供商
*/
private String getServerUrl() {
if (selectedProvider == PROVIDERS.length - 1) {
// 自定义从输入框获取
return binding.urlText.getText().toString().trim();
} else {
// 使用预设URL或从输入框获取Nextcloud/ownCloud
String providerUrl = getProviderUrl();
if (!TextUtils.isEmpty(providerUrl)) {
return providerUrl;
} else {
// Nextcloud或ownCloud需要用户输入
return binding.urlText.getText().toString().trim();
}
}
}
private void onPositive(DialogInterface dialog, int which) {
String url = getServerUrl();
String username = binding.usernameText.getText().toString().trim();
String password = binding.passwordText.getText().toString().trim();
boolean autoSync = binding.autoSyncSwitch.isChecked();
int interval = Integer.parseInt(binding.syncIntervalText.getText().toString());
// 验证输入
if (TextUtils.isEmpty(url)) {
Notify.show("请选择服务提供商或输入服务器地址");
return;
}
if (TextUtils.isEmpty(username)) {
Notify.show("请输入用户名");
return;
}
if (TextUtils.isEmpty(password)) {
Notify.show("请输入密码");
return;
}
// 保存配置
Setting.putWebDAVUrl(url);
Setting.putWebDAVUsername(username);
Setting.putWebDAVPassword(password);
Setting.putWebDAVAutoSync(autoSync);
Setting.putWebDAVSyncInterval(interval);
// 重新加载配置
syncManager.reloadConfig();
// 配置保存后立即执行一次同步下载远程数据
// 这样新设备配置后就能立即看到其他设备的历史记录
if (syncManager.isConfigured()) {
Notify.show("WebDAV配置已保存,正在同步数据...");
App.execute(() -> {
try {
// 先上传本地记录
syncManager.uploadHistory();
// 再下载远程记录并合并
boolean downloadSuccess = syncManager.downloadHistory();
App.post(() -> {
if (downloadSuccess) {
Notify.show("同步完成,已获取远程观看记录");
} else {
Notify.show("同步完成(本地数据已上传)");
}
});
} catch (Exception e) {
App.post(() -> {
Notify.show("同步失败,请检查网络连接");
});
}
});
} else {
Notify.show("WebDAV配置已保存");
}
dialog.dismiss();
// 通知设置界面更新状态通过RefreshEvent
// 使用App.post确保对话框关闭后再发送事件让状态能及时更新
App.post(() -> RefreshEvent.config());
}
private void onNegative(DialogInterface dialog, int which) {
dialog.dismiss();
}
/**
* 重新加载配置用于外部调用
*/
public void reloadConfig() {
syncManager.reloadConfig();
}
}
@@ -48,9 +48,11 @@ import com.fongmi.android.tv.ui.dialog.LiveDialog;
import com.fongmi.android.tv.ui.dialog.ProxyDialog;
import com.fongmi.android.tv.ui.dialog.RestoreDialog;
import com.fongmi.android.tv.ui.dialog.SiteDialog;
import com.fongmi.android.tv.ui.dialog.WebDAVDialog;
import com.fongmi.android.tv.utils.FileChooser;
import com.fongmi.android.tv.utils.FileUtil;
import com.fongmi.android.tv.utils.Notify;
import com.fongmi.android.tv.utils.WebDAVSyncManager;
import com.fongmi.android.tv.utils.ResUtil;
import com.fongmi.android.tv.utils.UrlUtil;
import com.github.catvod.bean.Doh;
@@ -59,6 +61,10 @@ import com.github.catvod.utils.Path;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.permissionx.guolindev.PermissionX;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import java.util.ArrayList;
import java.util.List;
@@ -119,9 +125,32 @@ public class SettingFragment extends BaseFragment implements ConfigCallback, Sit
mBinding.incognitoSwitch.setChecked(Setting.isIncognito());
mBinding.liveTabVisibleSwitch.setChecked(Setting.isLiveTabVisible());
mBinding.sizeText.setText((size = ResUtil.getStringArray(R.array.select_size))[Setting.getSize()]);
setWebDAVStatus();
setLiveSettingsVisibility();
}
private void setWebDAVStatus() {
WebDAVSyncManager manager = WebDAVSyncManager.get();
if (manager.isConfigured()) {
// 显示账号昵称用户名
String username = Setting.getWebDAVUsername();
if (!TextUtils.isEmpty(username)) {
// 如果用户名是邮箱只显示@前面的部分
String displayName = username;
if (username.contains("@")) {
displayName = username.substring(0, username.indexOf("@"));
}
String status = Setting.isWebDAVAutoSync() ? displayName + "(自动同步)" : displayName;
mBinding.webdavStatusText.setText(status);
} else {
String status = Setting.isWebDAVAutoSync() ? "已配置(自动同步)" : "已配置";
mBinding.webdavStatusText.setText(status);
}
} else {
mBinding.webdavStatusText.setText("未配置");
}
}
private void setLiveSettingsVisibility() {
boolean isLiveTabVisible = !Setting.isLiveTabVisible(); // 注意这里取反因为开关是"隐藏直播"
@@ -178,6 +207,7 @@ public class SettingFragment extends BaseFragment implements ConfigCallback, Sit
mBinding.liveTabVisibleSwitch.setOnClickListener(this::setLiveTabVisible);
mBinding.size.setOnClickListener(this::setSize);
mBinding.doh.setOnClickListener(this::setDoh);
mBinding.webdav.setOnClickListener(this::onWebDAV);
}
@Override
@@ -498,12 +528,35 @@ public class SettingFragment extends BaseFragment implements ConfigCallback, Sit
}));
}
private void onWebDAV(View view) {
WebDAVDialog.create(this).show();
}
private void initConfig() {
WallConfig.get().init();
LiveConfig.get().init().load();
VodConfig.get().init().load(getCallback(0));
}
@Override
public void onResume() {
super.onResume();
EventBus.getDefault().register(this);
}
@Override
public void onPause() {
super.onPause();
EventBus.getDefault().unregister(this);
}
@Subscribe(threadMode = ThreadMode.MAIN)
public void onRefreshEvent(RefreshEvent event) {
if (event.getType() == RefreshEvent.Type.CONFIG) {
setWebDAVStatus();
}
}
@Override
public void onHiddenChanged(boolean hidden) {
if (hidden) return;
@@ -511,6 +564,7 @@ public class SettingFragment extends BaseFragment implements ConfigCallback, Sit
setSourceHintText(mBinding.liveUrl, LiveConfig.getDesc(), R.string.source_hint_live);
// setSourceHintText(mBinding.wallUrl, WallConfig.getDesc(), R.string.source_hint_wall); // 壁纸功能已移除
setCacheText();
setWebDAVStatus(); // 更新WebDAV状态
}
@Override
+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>
<!-- WebDAV同步配置 -->
<LinearLayout
android:id="@+id/webdav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingTop="12dp"
android:paddingBottom="12dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="16dp"
android:src="@drawable/ic_fab_link"
android:tint="@color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="WebDAV"
android:textColor="@color/white"
android:textSize="16sp" />
<TextView
android:id="@+id/webdavStatusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="end"
android:text="未配置"
android:textColor="@color/white"
android:textSize="14sp"
android:alpha="0.7" />
</LinearLayout>
<!-- 无痕模式 -->
<LinearLayout
android:id="@+id/incognito"
+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() {
// 如果已经测试过并且在24小时内直接返回上次的结果