From e0aee44d5a7ca535d9a225eb67a1e1ed39108319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=82=A8=E7=9A=84=E5=90=8D=E5=AD=97?= <您的邮箱> Date: Mon, 10 Nov 2025 19:05:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A5=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=88=B0=E8=BF=9C=E7=A8=8B=E4=BB=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增WebDAV同步功能相关文件 - 新增CustomSwitch自定义开关组件 - 新增SyncCodeManager、UpdateInstaller、WebDAVSyncManager工具类 - 新增build_all_release.sh构建脚本 - 更新多个Dialog和Activity文件 - 更新字符串资源文件 - 删除apk/release目录下的旧文件 --- apk/release/README.md | 62 -- apk/release/leanback.json | 9 - apk/release/mobile.json | 9 - apk/release/v3.0.7/leanback.json | 5 - apk/release/v3.0.7/mobile.json | 5 - apk/release/v3.1.0/leanback.json | 10 - apk/release/v3.1.0/mobile.json | 10 - app/build.gradle | 7 +- .../java/com/fongmi/android/tv/Updater.java | 347 +++++--- .../tv/ui/activity/SettingActivity.java | 48 ++ .../android/tv/ui/custom/CustomSwitch.java | 137 ++++ .../android/tv/ui/dialog/ConfigDialog.java | 2 + .../android/tv/ui/dialog/DescDialog.java | 2 + .../android/tv/ui/dialog/DohDialog.java | 2 + .../android/tv/ui/dialog/HistoryDialog.java | 2 + .../android/tv/ui/dialog/LiveDialog.java | 2 + .../android/tv/ui/dialog/ProxyDialog.java | 2 + .../android/tv/ui/dialog/RestoreDialog.java | 2 + .../android/tv/ui/dialog/SiteDialog.java | 2 + .../fongmi/android/tv/ui/dialog/UaDialog.java | 2 + .../android/tv/ui/dialog/WebDAVDialog.java | 637 +++++++++++++++ .../android/tv/ui/dialog/WebDialog.java | 2 + .../leanback/res/layout/activity_setting.xml | 30 + app/src/leanback/res/layout/dialog_webdav.xml | 296 +++++++ .../main/java/com/fongmi/android/tv/App.java | 103 ++- .../java/com/fongmi/android/tv/Setting.java | 93 +++ .../com/fongmi/android/tv/utils/Download.java | 260 +++++- .../android/tv/utils/SyncCodeManager.java | 312 +++++++ .../android/tv/utils/UpdateInstaller.java | 150 ++++ .../android/tv/utils/WebDAVSyncManager.java | 759 ++++++++++++++++++ app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values-zh-rTW/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../java/com/fongmi/android/tv/Updater.java | 340 +++++--- .../android/tv/ui/activity/VideoActivity.java | 69 ++ .../android/tv/ui/dialog/WebDAVDialog.java | 474 +++++++++++ .../tv/ui/fragment/SettingFragment.java | 54 ++ app/src/mobile/res/layout/dialog_webdav.xml | 206 +++++ .../mobile/res/layout/fragment_setting.xml | 37 + build_all_release.sh | 69 ++ build_debug.sh | 0 build_test.sh | 0 .../java/com/github/catvod/utils/Github.java | 71 ++ 43 files changed, 4244 insertions(+), 391 deletions(-) delete mode 100644 apk/release/README.md delete mode 100644 apk/release/leanback.json delete mode 100644 apk/release/mobile.json delete mode 100644 apk/release/v3.0.7/leanback.json delete mode 100644 apk/release/v3.0.7/mobile.json delete mode 100644 apk/release/v3.1.0/leanback.json delete mode 100644 apk/release/v3.1.0/mobile.json create mode 100644 app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomSwitch.java create mode 100644 app/src/leanback/java/com/fongmi/android/tv/ui/dialog/WebDAVDialog.java create mode 100644 app/src/leanback/res/layout/dialog_webdav.xml create mode 100644 app/src/main/java/com/fongmi/android/tv/utils/SyncCodeManager.java create mode 100644 app/src/main/java/com/fongmi/android/tv/utils/UpdateInstaller.java create mode 100644 app/src/main/java/com/fongmi/android/tv/utils/WebDAVSyncManager.java create mode 100644 app/src/mobile/java/com/fongmi/android/tv/ui/dialog/WebDAVDialog.java create mode 100644 app/src/mobile/res/layout/dialog_webdav.xml create mode 100755 build_all_release.sh mode change 100644 => 100755 build_debug.sh mode change 100644 => 100755 build_test.sh diff --git a/apk/release/README.md b/apk/release/README.md deleted file mode 100644 index c3b139e1..00000000 --- a/apk/release/README.md +++ /dev/null @@ -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) - 增量签名 diff --git a/apk/release/leanback.json b/apk/release/leanback.json deleted file mode 100644 index 05459802..00000000 --- a/apk/release/leanback.json +++ /dev/null @@ -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" - } -} diff --git a/apk/release/mobile.json b/apk/release/mobile.json deleted file mode 100644 index 5eca1430..00000000 --- a/apk/release/mobile.json +++ /dev/null @@ -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" - } -} diff --git a/apk/release/v3.0.7/leanback.json b/apk/release/v3.0.7/leanback.json deleted file mode 100644 index 51ff1305..00000000 --- a/apk/release/v3.0.7/leanback.json +++ /dev/null @@ -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 -} diff --git a/apk/release/v3.0.7/mobile.json b/apk/release/v3.0.7/mobile.json deleted file mode 100644 index 8abf49f5..00000000 --- a/apk/release/v3.0.7/mobile.json +++ /dev/null @@ -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 -} diff --git a/apk/release/v3.1.0/leanback.json b/apk/release/v3.1.0/leanback.json deleted file mode 100644 index c3b2378d..00000000 --- a/apk/release/v3.1.0/leanback.json +++ /dev/null @@ -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" - } -} - diff --git a/apk/release/v3.1.0/mobile.json b/apk/release/v3.1.0/mobile.json deleted file mode 100644 index 338135c2..00000000 --- a/apk/release/v3.1.0/mobile.json +++ /dev/null @@ -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" - } -} - diff --git a/app/build.gradle b/app/build.gradle index 88954611..1121ff6e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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"] diff --git a/app/src/leanback/java/com/fongmi/android/tv/Updater.java b/app/src/leanback/java/com/fongmi/android/tv/Updater.java index f9583643..88e7b196 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/Updater.java +++ b/app/src/leanback/java/com/fongmi/android/tv/Updater.java @@ -14,6 +14,7 @@ import com.fongmi.android.tv.utils.Download; import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; +import com.fongmi.android.tv.utils.UpdateInstaller; import com.github.catvod.net.OkHttp; import com.github.catvod.utils.Github; import com.github.catvod.utils.Logger; @@ -29,53 +30,34 @@ import java.util.Locale; public class Updater implements Download.Callback { private DialogUpdateBinding binding; - private final Download download; + private Download download; private AlertDialog dialog; private boolean dev; private boolean forceCheck; // 是否为手动检查 + private boolean autoShow; // 是否自动显示更新对话框(用于自动检查) private String latestVersion; // 存储检测到的最新版本 - private String releaseApkUrl; // 从 GitHub Release 获取的 APK 下载链接 + private String releaseApkUrl; // 从 GitHub Release 获取的 APK 下载链接(jsDelivr CDN) + private String fallbackApkUrl; // 备用下载链接(GitHub原始URL) + + // 静态变量:记录上次检查时间(用于时间间隔限制) + private static long lastCheckTime = 0; + private static final long CHECK_INTERVAL = 60 * 60 * 1000; // 1小时(毫秒) private File getFile() { - return Path.root("Download", "XMBOX-update.apk"); - } - - private String getJson() { - return Github.getJson(dev, BuildConfig.FLAVOR_mode); + // Android 10+ 无法直接访问外部存储的Download目录 + // 使用应用的cache目录,FileProvider可以正常访问 + return Path.cache("XMBOX-update.apk"); } private String getApk() { - // 优先使用从 GitHub Release 获取的 APK URL + // 使用从 GitHub Release 获取的 APK URL(jsDelivr CDN) if (releaseApkUrl != null && !releaseApkUrl.isEmpty()) { - Logger.d("APK download URL from Release: " + releaseApkUrl); + Logger.d("APK download URL from Release (jsDelivr): " + releaseApkUrl); return releaseApkUrl; } - - // 使用JSON中指定的具体下载路径 - try { - String response = OkHttp.string(getJson()); - JSONObject object = new JSONObject(response); - JSONObject downloads = object.optJSONObject("downloads"); - if (downloads != null) { - String abi = BuildConfig.FLAVOR_abi; - String downloadPath = downloads.optString(abi); - if (!downloadPath.isEmpty()) { - // 直接构建完整URL,不通过Github.getApk()避免重复添加路径 - String baseUrl = Github.useCnMirror() ? - "https://gitee.com/ochenoktochen/XMBOX-Release/raw/main" : - "https://raw.githubusercontent.com/Tosencen/XMBOX-Release/main"; - String fullUrl = baseUrl + "/apk/" + (dev ? "dev" : "release") + "/" + downloadPath; - Logger.d("APK download URL: " + fullUrl); - return fullUrl; - } - } - } catch (Exception e) { - Logger.e("Failed to get download path from JSON: " + e.getMessage()); - } - // 回退到原来的方式 - String fallbackUrl = Github.getApk(dev, BuildConfig.FLAVOR_mode + "-" + BuildConfig.FLAVOR_abi); - Logger.d("APK fallback URL: " + fallbackUrl); - return fallbackUrl; + // 如果没有获取到URL,返回空(不应该发生) + Logger.e("Updater: 未找到APK下载链接"); + return ""; } public static Updater create() { @@ -83,8 +65,9 @@ public class Updater implements Download.Callback { } public Updater() { - this.download = Download.create(getApk(), getFile(), this); this.forceCheck = false; + this.autoShow = false; + // download对象将在需要时创建 } public Updater force() { @@ -93,6 +76,15 @@ public class Updater implements Download.Callback { this.forceCheck = true; // 标记为手动检查 return this; } + + /** + * 设置自动检查模式(应用启动时自动检查) + */ + public Updater auto() { + this.forceCheck = false; + this.autoShow = true; // 自动显示更新对话框 + return this; + } public Updater release() { this.dev = false; @@ -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 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); - - 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(); + // 开始下载更新(使用jsDelivr CDN,失败时回退到GitHub) + String downloadUrl = getApk(); + String fallbackUrl = this.fallbackApkUrl; + + // 检查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(); + } } } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingActivity.java index 739cd118..5a82585a 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingActivity.java @@ -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); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomSwitch.java b/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomSwitch.java new file mode 100644 index 00000000..a6d8cd9a --- /dev/null +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomSwitch.java @@ -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; + } + } +} + diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java index fb45da27..2d83fc8d 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java @@ -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(); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/DescDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/DescDialog.java index 1b565d31..d57cfe5c 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/DescDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/DescDialog.java @@ -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(); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/DohDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/DohDialog.java index c8d7b5a5..af2dd444 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/DohDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/DohDialog.java @@ -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(); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java index 6be3d5b5..fcc5c2fa 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java @@ -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(); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/LiveDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/LiveDialog.java index 506795df..34e6bfb1 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/LiveDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/LiveDialog.java @@ -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(); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ProxyDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ProxyDialog.java index 3299a706..7c2bcc17 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ProxyDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ProxyDialog.java @@ -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(); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/RestoreDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/RestoreDialog.java index 3f73775a..d85549c6 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/RestoreDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/RestoreDialog.java @@ -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(); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java index 0a548e4e..c8e19578 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java @@ -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(); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/UaDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/UaDialog.java index b25bf0a9..0365a9aa 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/UaDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/UaDialog.java @@ -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(); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/WebDAVDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/WebDAVDialog.java new file mode 100644 index 00000000..26d810b0 --- /dev/null +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/WebDAVDialog.java @@ -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(); + } + } +} + diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/WebDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/WebDialog.java index d5e9748a..52a7d473 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/WebDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/WebDialog.java @@ -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(); } } diff --git a/app/src/leanback/res/layout/activity_setting.xml b/app/src/leanback/res/layout/activity_setting.xml index 960b96bb..aa86a9bc 100644 --- a/app/src/leanback/res/layout/activity_setting.xml +++ b/app/src/leanback/res/layout/activity_setting.xml @@ -235,6 +235,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/fongmi/android/tv/App.java b/app/src/main/java/com/fongmi/android/tv/App.java index 2b8230fb..b03fbe34 100644 --- a/app/src/main/java/com/fongmi/android/tv/App.java +++ b/app/src/main/java/com/fongmi/android/tv/App.java @@ -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 @@ -189,6 +201,93 @@ public class App extends Application { // 每30分钟定期检查缓存 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() { diff --git a/app/src/main/java/com/fongmi/android/tv/Setting.java b/app/src/main/java/com/fongmi/android/tv/Setting.java index 04507e11..051dc581 100644 --- a/app/src/main/java/com/fongmi/android/tv/Setting.java +++ b/app/src/main/java/com/fongmi/android/tv/Setting.java @@ -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); + } } diff --git a/app/src/main/java/com/fongmi/android/tv/utils/Download.java b/app/src/main/java/com/fongmi/android/tv/utils/Download.java index ec9a7ea5..c1cc7b7d 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/Download.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/Download.java @@ -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; } } diff --git a/app/src/main/java/com/fongmi/android/tv/utils/SyncCodeManager.java b/app/src/main/java/com/fongmi/android/tv/utils/SyncCodeManager.java new file mode 100644 index 00000000..c97bfd24 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/utils/SyncCodeManager.java @@ -0,0 +1,312 @@ +package com.fongmi.android.tv.utils; + +import android.text.TextUtils; + +import com.fongmi.android.tv.Setting; + +import com.fongmi.android.tv.App; +import com.fongmi.android.tv.bean.Backup; +import com.fongmi.android.tv.bean.History; +import com.fongmi.android.tv.db.AppDatabase; +import com.github.catvod.net.OkHttp; +import com.github.catvod.utils.Logger; +import com.github.catvod.utils.Prefers; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +/** + * 同步码管理器(无需WebDAV账号) + * 使用公开的HTTP存储服务,通过同步码区分用户 + * + * 方案:使用GitHub Gist作为存储 + * - 用户创建一个公开的GitHub Gist + * - 通过同步码作为文件名的一部分来区分不同用户 + * - 所有知道同步码的设备可以共享数据 + */ +public class SyncCodeManager { + + private static final String HISTORY_FILE_PREFIX = "xmbox_history_"; + private static final String SETTINGS_FILE_PREFIX = "xmbox_settings_"; + private static final String BACKUP_FILE_PREFIX = "xmbox_backup_"; + private static final String FILE_SUFFIX = ".json"; + + private static SyncCodeManager instance; + private String syncCode; + private String gistId; // GitHub Gist ID + private String gistToken; // GitHub Personal Access Token(用于上传,可选) + + public static SyncCodeManager get() { + if (instance == null) { + instance = new SyncCodeManager(); + } + return instance; + } + + private SyncCodeManager() { + loadConfig(); + } + + /** + * 加载配置 + */ + private void loadConfig() { + syncCode = Setting.getWebDAVSyncCode(); + gistId = Setting.getWebDAVGistId(); + gistToken = Setting.getWebDAVGistToken(); + } + + /** + * 检查是否已配置 + */ + public boolean isConfigured() { + return !TextUtils.isEmpty(syncCode) && !TextUtils.isEmpty(gistId); + } + + /** + * 生成同步码 + * @return 8位随机同步码(字母+数字) + */ + public static String generateSyncCode() { + String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + Random random = new Random(); + StringBuilder code = new StringBuilder(); + for (int i = 0; i < 8; i++) { + code.append(chars.charAt(random.nextInt(chars.length()))); + } + return code.toString(); + } + + /** + * 获取文件URL(GitHub Gist raw URL) + */ + private String getFileUrl(String prefix) { + // GitHub Gist raw URL格式: + // https://gist.githubusercontent.com/{username}/{gist_id}/raw/{filename} + // 文件名格式:{prefix}{syncCode}.json + // 例如:xmbox_history_ABC123XYZ.json + + // 如果用户提供了完整的Gist raw URL + String gistRawUrl = Setting.getWebDAVGistRawUrl(); + if (!TextUtils.isEmpty(gistRawUrl)) { + String filename = prefix + syncCode + FILE_SUFFIX; + return gistRawUrl + "/" + filename; + } + + // 否则需要从Gist ID构建(需要知道username) + // 这里简化处理,要求用户提供完整的raw URL + return null; + } + + /** + * 上传观看记录 + */ + public boolean uploadHistory() { + if (!isConfigured()) { + Logger.e("SyncCode: 未配置,无法上传观看记录"); + return false; + } + + try { + // 获取所有观看记录 + List 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>(){}.getType(); + List remoteHistoryList = App.gson().fromJson(json, listType); + + // 智能合并(与WebDAV相同的逻辑) + if (!remoteHistoryList.isEmpty()) { + List localHistoryList = AppDatabase.get().getHistoryDao().findAll(); + + Map localMap = new HashMap<>(); + for (History local : localHistoryList) { + localMap.put(local.getKey(), local); + } + + List toInsert = new java.util.ArrayList<>(); + List 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 requestBody = new HashMap<>(); + Map files = new HashMap<>(); + Map 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(); + } +} + diff --git a/app/src/main/java/com/fongmi/android/tv/utils/UpdateInstaller.java b/app/src/main/java/com/fongmi/android/tv/utils/UpdateInstaller.java new file mode 100644 index 00000000..2d35b7f5 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/utils/UpdateInstaller.java @@ -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; + } +} + diff --git a/app/src/main/java/com/fongmi/android/tv/utils/WebDAVSyncManager.java b/app/src/main/java/com/fongmi/android/tv/utils/WebDAVSyncManager.java new file mode 100644 index 00000000..1c9ce823 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/utils/WebDAVSyncManager.java @@ -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 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>(){}.getType(); + List remoteHistoryList = App.gson().fromJson(json, listType); + + // 验证数据 + if (remoteHistoryList == null) { + Logger.e("WebDAV: JSON解析失败,返回null"); + return false; + } + + // 智能合并:比较本地和远程记录,保留较新的 + List localHistoryList = AppDatabase.get().getHistoryDao().findAll(); + + // 创建本地记录的映射(key -> History) + java.util.Map localMap = new java.util.HashMap<>(); + for (History local : localHistoryList) { + if (local != null && local.getKey() != null) { + localMap.put(local.getKey(), local); + } + } + + // 合并远程记录 + List toInsert = new java.util.ArrayList<>(); + List 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 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 settings = gson.fromJson(json, Map.class); + + // 应用设置(合并,不覆盖已存在的) + if (settings != null && !settings.isEmpty()) { + for (Map.Entry 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(); + } +} + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e8bb1b71..15f57a19 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -91,7 +91,7 @@ 应用设置 网络设置 数据管理 - v3.0.9 + v3.1.1 在GitHub上查看 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index ee72c949..e866c9bc 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -89,7 +89,7 @@ 應用設置 網絡設置 數據管理 - v3.0.9 + v3.1.1 在GitHub上查看 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2fb77317..c38c8684 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -92,7 +92,7 @@ Choose Off On - v3.0.9 + v3.1.1 View on GitHub diff --git a/app/src/mobile/java/com/fongmi/android/tv/Updater.java b/app/src/mobile/java/com/fongmi/android/tv/Updater.java index 1f059064..3b943b30 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/Updater.java +++ b/app/src/mobile/java/com/fongmi/android/tv/Updater.java @@ -14,6 +14,7 @@ import com.fongmi.android.tv.utils.Download; import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; +import com.fongmi.android.tv.utils.UpdateInstaller; import com.github.catvod.net.OkHttp; import com.github.catvod.utils.Github; import com.github.catvod.utils.Logger; @@ -29,53 +30,34 @@ import java.util.Locale; public class Updater implements Download.Callback { private DialogUpdateBinding binding; - private final Download download; + private Download download; private AlertDialog dialog; private boolean dev; private boolean forceCheck; // 是否为手动检查 + private boolean autoShow; // 是否自动显示更新对话框(用于自动检查) private String latestVersion; // 存储检测到的最新版本 - private String releaseApkUrl; // 从 GitHub Release 获取的 APK 下载链接 + private String releaseApkUrl; // 从 GitHub Release 获取的 APK 下载链接(jsDelivr CDN) + private String fallbackApkUrl; // 备用下载链接(GitHub原始URL) + + // 静态变量:记录上次检查时间(用于时间间隔限制) + private static long lastCheckTime = 0; + private static final long CHECK_INTERVAL = 60 * 60 * 1000; // 1小时(毫秒) private File getFile() { - return Path.root("Download", "XMBOX-update.apk"); - } - - private String getJson() { - return Github.getJson(dev, BuildConfig.FLAVOR_mode); + // Android 10+ 无法直接访问外部存储的Download目录 + // 使用应用的cache目录,FileProvider可以正常访问 + return Path.cache("XMBOX-update.apk"); } private String getApk() { - // 优先使用从 GitHub Release 获取的 APK URL + // 使用从 GitHub Release 获取的 APK URL(jsDelivr CDN) if (releaseApkUrl != null && !releaseApkUrl.isEmpty()) { - Logger.d("APK download URL from Release: " + releaseApkUrl); + Logger.d("APK download URL from Release (jsDelivr): " + releaseApkUrl); return releaseApkUrl; } - - // 使用JSON中指定的具体下载路径 - try { - String response = OkHttp.string(getJson()); - JSONObject object = new JSONObject(response); - JSONObject downloads = object.optJSONObject("downloads"); - if (downloads != null) { - String abi = BuildConfig.FLAVOR_abi; - String downloadPath = downloads.optString(abi); - if (!downloadPath.isEmpty()) { - // 直接构建完整URL,不通过Github.getApk()避免重复添加路径 - String baseUrl = Github.useCnMirror() ? - "https://gitee.com/ochenoktochen/XMBOX-Release/raw/main" : - "https://raw.githubusercontent.com/Tosencen/XMBOX-Release/main"; - String fullUrl = baseUrl + "/apk/" + (dev ? "dev" : "release") + "/" + downloadPath; - Logger.d("APK download URL: " + fullUrl); - return fullUrl; - } - } - } catch (Exception e) { - Logger.e("Failed to get download path from JSON: " + e.getMessage()); - } - // 回退到原来的方式 - String fallbackUrl = Github.getApk(dev, BuildConfig.FLAVOR_mode + "-" + BuildConfig.FLAVOR_abi); - Logger.d("APK fallback URL: " + fallbackUrl); - return fallbackUrl; + // 如果没有获取到URL,返回空(不应该发生) + Logger.e("Updater: 未找到APK下载链接"); + return ""; } public static Updater create() { @@ -83,8 +65,9 @@ public class Updater implements Download.Callback { } public Updater() { - this.download = Download.create(getApk(), getFile(), this); this.forceCheck = false; + this.autoShow = false; + // download对象将在需要时创建 } public Updater force() { @@ -93,6 +76,15 @@ public class Updater implements Download.Callback { this.forceCheck = true; // 标记为手动检查 return this; } + + /** + * 设置自动检查模式(应用启动时自动检查) + */ + public Updater auto() { + this.forceCheck = false; + this.autoShow = true; // 自动显示更新对话框 + return this; + } public Updater release() { this.dev = false; @@ -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 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); - - 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(); + // 开始下载更新(使用jsDelivr CDN,失败时回退到GitHub) + String downloadUrl = getApk(); + String fallbackUrl = this.fallbackApkUrl; + + // 检查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(); + } } } \ No newline at end of file diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java index f7d0b65d..dced35db 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java @@ -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())) { diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/WebDAVDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/WebDAVDialog.java new file mode 100644 index 00000000..be375a0e --- /dev/null +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/WebDAVDialog.java @@ -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(); + } +} + diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingFragment.java index b64cf5e5..6ba22f58 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingFragment.java @@ -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,8 +125,31 @@ 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 diff --git a/app/src/mobile/res/layout/dialog_webdav.xml b/app/src/mobile/res/layout/dialog_webdav.xml new file mode 100644 index 00000000..9c8b0fb9 --- /dev/null +++ b/app/src/mobile/res/layout/dialog_webdav.xml @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/mobile/res/layout/fragment_setting.xml b/app/src/mobile/res/layout/fragment_setting.xml index 01cbfb2e..3314a2f5 100644 --- a/app/src/mobile/res/layout/fragment_setting.xml +++ b/app/src/mobile/res/layout/fragment_setting.xml @@ -252,6 +252,43 @@ + + + + + + + + + + + = 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小时内,直接返回上次的结果