feat: 同步本地代码到远程仓库
- 新增WebDAV同步功能相关文件 - 新增CustomSwitch自定义开关组件 - 新增SyncCodeManager、UpdateInstaller、WebDAVSyncManager工具类 - 新增build_all_release.sh构建脚本 - 更新多个Dialog和Activity文件 - 更新字符串资源文件 - 删除apk/release目录下的旧文件
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -332,4 +332,97 @@ public class Setting {
|
||||
public static void putLiveTabVisible(boolean visible) {
|
||||
Prefers.put("live_tab_visible", visible);
|
||||
}
|
||||
|
||||
// WebDAV 同步配置
|
||||
public static String getWebDAVUrl() {
|
||||
return Prefers.getString("webdav_url");
|
||||
}
|
||||
|
||||
public static void putWebDAVUrl(String url) {
|
||||
Prefers.put("webdav_url", url);
|
||||
}
|
||||
|
||||
public static String getWebDAVUsername() {
|
||||
return Prefers.getString("webdav_username");
|
||||
}
|
||||
|
||||
public static void putWebDAVUsername(String username) {
|
||||
Prefers.put("webdav_username", username);
|
||||
}
|
||||
|
||||
public static String getWebDAVPassword() {
|
||||
return Prefers.getString("webdav_password");
|
||||
}
|
||||
|
||||
public static void putWebDAVPassword(String password) {
|
||||
Prefers.put("webdav_password", password);
|
||||
}
|
||||
|
||||
public static boolean isWebDAVAutoSync() {
|
||||
return Prefers.getBoolean("webdav_auto_sync", false);
|
||||
}
|
||||
|
||||
public static void putWebDAVAutoSync(boolean autoSync) {
|
||||
Prefers.put("webdav_auto_sync", autoSync);
|
||||
}
|
||||
|
||||
public static int getWebDAVSyncInterval() {
|
||||
return Prefers.getInt("webdav_sync_interval", 30); // 默认30分钟
|
||||
}
|
||||
|
||||
public static void putWebDAVSyncInterval(int minutes) {
|
||||
Prefers.put("webdav_sync_interval", minutes);
|
||||
}
|
||||
|
||||
// WebDAV 同步模式:ACCOUNT(账号模式)或 CODE(同步码模式)
|
||||
public static String getWebDAVSyncMode() {
|
||||
return Prefers.getString("webdav_sync_mode", "ACCOUNT");
|
||||
}
|
||||
|
||||
public static void putWebDAVSyncMode(String mode) {
|
||||
Prefers.put("webdav_sync_mode", mode);
|
||||
}
|
||||
|
||||
// 同步码(用于同步码模式)
|
||||
public static String getWebDAVSyncCode() {
|
||||
return Prefers.getString("webdav_sync_code");
|
||||
}
|
||||
|
||||
public static void putWebDAVSyncCode(String code) {
|
||||
Prefers.put("webdav_sync_code", code);
|
||||
}
|
||||
|
||||
// 公开存储URL(用于同步码模式,如GitHub Gist URL)
|
||||
public static String getWebDAVPublicUrl() {
|
||||
return Prefers.getString("webdav_public_url");
|
||||
}
|
||||
|
||||
public static void putWebDAVPublicUrl(String url) {
|
||||
Prefers.put("webdav_public_url", url);
|
||||
}
|
||||
|
||||
// GitHub Gist相关(用于同步码模式)
|
||||
public static String getWebDAVGistId() {
|
||||
return Prefers.getString("webdav_gist_id");
|
||||
}
|
||||
|
||||
public static void putWebDAVGistId(String gistId) {
|
||||
Prefers.put("webdav_gist_id", gistId);
|
||||
}
|
||||
|
||||
public static String getWebDAVGistRawUrl() {
|
||||
return Prefers.getString("webdav_gist_raw_url");
|
||||
}
|
||||
|
||||
public static void putWebDAVGistRawUrl(String url) {
|
||||
Prefers.put("webdav_gist_raw_url", url);
|
||||
}
|
||||
|
||||
public static String getWebDAVGistToken() {
|
||||
return Prefers.getString("webdav_gist_token");
|
||||
}
|
||||
|
||||
public static void putWebDAVGistToken(String token) {
|
||||
Prefers.put("webdav_gist_token", token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,53 +18,219 @@ public class Download {
|
||||
|
||||
private final File file;
|
||||
private final String url;
|
||||
private final String fallbackUrl;
|
||||
private Callback callback;
|
||||
private static final int MAX_RETRY_COUNT = 3; // 最大重试次数
|
||||
|
||||
public static Download create(String url, File file) {
|
||||
return create(url, file, null);
|
||||
}
|
||||
|
||||
public static Download create(String url, File file, Callback callback) {
|
||||
return new Download(url, file, callback);
|
||||
return create(url, file, null, callback);
|
||||
}
|
||||
|
||||
public static Download create(String url, File file, String fallbackUrl, Callback callback) {
|
||||
return new Download(url, file, fallbackUrl, callback);
|
||||
}
|
||||
|
||||
public Download(String url, File file, Callback callback) {
|
||||
this(url, file, null, callback);
|
||||
}
|
||||
|
||||
public Download(String url, File file, String fallbackUrl, Callback callback) {
|
||||
this.url = url;
|
||||
this.file = file;
|
||||
this.fallbackUrl = fallbackUrl;
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (url == null || url.isEmpty()) {
|
||||
if (callback != null) {
|
||||
App.post(() -> callback.error("下载URL为空"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (url.startsWith("file")) return;
|
||||
if (callback == null) doInBackground();
|
||||
else App.execute(this::doInBackground);
|
||||
if (file == null) {
|
||||
if (callback != null) {
|
||||
App.post(() -> callback.error("保存文件路径为空"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (callback == null) {
|
||||
// 无回调时,直接执行(同步)
|
||||
doInBackgroundWithFallback();
|
||||
} else {
|
||||
// 有回调时,异步执行
|
||||
App.execute(this::doInBackgroundWithFallback);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 带智能回退的下载方法
|
||||
* 先尝试主URL(通常是jsDelivr CDN),失败后回退到备用URL
|
||||
*/
|
||||
private void doInBackgroundWithFallback() {
|
||||
// 先尝试主URL
|
||||
boolean mainSuccess = doInBackground(url, "主URL");
|
||||
if (mainSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 主URL失败,如果有回退URL,尝试回退URL
|
||||
if (fallbackUrl != null && !fallbackUrl.equals(url)) {
|
||||
Logger.d("Download: 主URL下载失败,回退到备用URL: " + fallbackUrl);
|
||||
doInBackground(fallbackUrl, "备用URL");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定URL下载文件(带重试机制)
|
||||
*/
|
||||
private boolean doInBackground(String downloadUrl, String source) {
|
||||
Exception lastException = null;
|
||||
|
||||
for (int attempt = 1; attempt <= MAX_RETRY_COUNT; attempt++) {
|
||||
try {
|
||||
if (callback != null) {
|
||||
App.post(() -> callback.progress(0));
|
||||
}
|
||||
|
||||
boolean success = downloadWithUrl(downloadUrl, source, attempt);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
lastException = e;
|
||||
Logger.w("Download: 下载失败 (来源: " + source + ", 尝试 " + attempt + "/" + MAX_RETRY_COUNT + "): " + e.getMessage());
|
||||
|
||||
// 如果不是最后一次尝试,等待后重试
|
||||
if (attempt < MAX_RETRY_COUNT) {
|
||||
try {
|
||||
long retryDelay = 500L * attempt; // 递增延迟
|
||||
Thread.sleep(retryDelay);
|
||||
Logger.d("Download: 等待 " + retryDelay + "ms 后重试...");
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有尝试都失败
|
||||
if (callback != null && lastException != null) {
|
||||
String errorMsg = lastException.getMessage();
|
||||
App.post(() -> callback.error(errorMsg != null ? errorMsg : "下载失败"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定URL下载文件
|
||||
*/
|
||||
private boolean downloadWithUrl(String downloadUrl, String source, int attempt) throws Exception {
|
||||
if (downloadUrl == null || downloadUrl.isEmpty()) {
|
||||
throw new Exception("下载URL为空");
|
||||
}
|
||||
if (file == null) {
|
||||
throw new Exception("保存文件路径为空");
|
||||
}
|
||||
|
||||
Response res = null;
|
||||
InputStream inputStream = null;
|
||||
try {
|
||||
res = OkHttp.newCall(downloadUrl, downloadUrl).execute();
|
||||
|
||||
// 检查HTTP响应状态码
|
||||
if (!res.isSuccessful()) {
|
||||
throw new Exception("下载失败: HTTP " + res.code() + " " + (res.message() != null ? res.message() : "未知错误"));
|
||||
}
|
||||
|
||||
// 检查响应体是否存在
|
||||
if (res.body() == null) {
|
||||
throw new Exception("下载失败: 响应体为空");
|
||||
}
|
||||
|
||||
// 获取输入流
|
||||
inputStream = res.body().byteStream();
|
||||
if (inputStream == null) {
|
||||
throw new Exception("下载失败: 无法获取输入流");
|
||||
}
|
||||
|
||||
Path.create(file);
|
||||
|
||||
// 获取文件大小,如果无法获取则使用-1表示未知大小
|
||||
String contentLengthStr = res.header(HttpHeaders.CONTENT_LENGTH);
|
||||
long expectedLength = -1;
|
||||
if (contentLengthStr != null && !contentLengthStr.isEmpty()) {
|
||||
try {
|
||||
expectedLength = Long.parseLong(contentLengthStr);
|
||||
if (expectedLength < 0) {
|
||||
expectedLength = -1;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
Logger.w("Download: 无法解析Content-Length: " + contentLengthStr);
|
||||
expectedLength = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
download(inputStream, expectedLength);
|
||||
|
||||
// 验证下载的文件(如果知道预期大小)
|
||||
if (expectedLength > 0 && !verifyDownloadedFile(file, expectedLength)) {
|
||||
throw new Exception("下载的文件可能已损坏,请重试");
|
||||
}
|
||||
|
||||
Logger.d("Download: 下载成功 (来源: " + source + ", 尝试 " + attempt + "/" + MAX_RETRY_COUNT + ")");
|
||||
if (callback != null) {
|
||||
App.post(() -> callback.success(file));
|
||||
}
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
// 如果下载失败,删除可能不完整的文件
|
||||
if (file != null && file.exists()) {
|
||||
try {
|
||||
file.delete();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
// 关闭输入流
|
||||
if (inputStream != null) {
|
||||
try {
|
||||
inputStream.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
// 关闭响应
|
||||
if (res != null) {
|
||||
try {
|
||||
res.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void cancel() {
|
||||
OkHttp.cancel(url);
|
||||
if (fallbackUrl != null) {
|
||||
OkHttp.cancel(fallbackUrl);
|
||||
}
|
||||
Path.clear(file);
|
||||
callback = null;
|
||||
}
|
||||
|
||||
private void doInBackground() {
|
||||
try (Response res = OkHttp.newCall(url, url).execute()) {
|
||||
Path.create(file);
|
||||
long expectedLength = Long.parseLong(res.header(HttpHeaders.CONTENT_LENGTH, "0"));
|
||||
download(res.body().byteStream(), expectedLength);
|
||||
|
||||
// 验证下载的文件
|
||||
if (!verifyDownloadedFile(file, expectedLength)) {
|
||||
App.post(() -> {if (callback != null) callback.error("下载的文件可能已损坏,请重试");});
|
||||
return;
|
||||
}
|
||||
|
||||
App.post(() -> {if (callback != null) callback.success(file);});
|
||||
} catch (Exception e) {
|
||||
App.post(() -> {if (callback != null) callback.error(e.getMessage());});
|
||||
}
|
||||
}
|
||||
|
||||
private void download(InputStream is, long length) throws Exception {
|
||||
if (is == null) {
|
||||
throw new Exception("输入流为空,无法下载");
|
||||
}
|
||||
|
||||
try (BufferedInputStream input = new BufferedInputStream(is); FileOutputStream os = new FileOutputStream(file)) {
|
||||
byte[] buffer = new byte[4096];
|
||||
int readBytes;
|
||||
@@ -72,39 +238,56 @@ public class Download {
|
||||
while ((readBytes = input.read(buffer)) != -1) {
|
||||
totalBytes += readBytes;
|
||||
os.write(buffer, 0, readBytes);
|
||||
int progress = (int) (totalBytes / length * 100.0);
|
||||
App.post(() -> {if (callback != null) callback.progress(progress);});
|
||||
|
||||
// 只有当知道文件大小时才计算进度
|
||||
if (length > 0 && callback != null) {
|
||||
int progress = (int) (totalBytes * 100.0 / length);
|
||||
final int finalProgress = Math.min(progress, 100); // 确保不超过100%,并设为final
|
||||
App.post(() -> callback.progress(finalProgress));
|
||||
} else if (callback != null) {
|
||||
// 不知道文件大小时,显示不确定进度
|
||||
App.post(() -> callback.progress(-1));
|
||||
}
|
||||
}
|
||||
|
||||
// 下载完成后,如果不知道文件大小,显示100%
|
||||
if (length <= 0 && callback != null) {
|
||||
App.post(() -> callback.progress(100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean verifyDownloadedFile(File file, long expectedLength) {
|
||||
try {
|
||||
// 检查文件大小
|
||||
if (file.length() != expectedLength) {
|
||||
// 如果文件不存在或为空,验证失败
|
||||
if (file == null || !file.exists() || file.length() == 0) {
|
||||
Logger.e("File verification failed: file does not exist or is empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果知道预期大小,检查文件大小是否匹配
|
||||
if (expectedLength > 0 && file.length() != expectedLength) {
|
||||
Logger.e("File size mismatch: expected " + expectedLength + ", actual " + file.length());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查APK文件头 (ZIP文件头)
|
||||
if (file.length() < 4) return false;
|
||||
if (file.length() < 4) {
|
||||
Logger.e("File too small: " + file.length() + " bytes");
|
||||
return false;
|
||||
}
|
||||
|
||||
try (FileInputStream fis = new FileInputStream(file)) {
|
||||
byte[] header = new byte[4];
|
||||
fis.read(header);
|
||||
// ZIP文件头应该是 0x504B0304 (PK..)
|
||||
if (header[0] != 0x50 || header[1] != 0x4B || header[2] != 0x03 || header[3] != 0x04) {
|
||||
Logger.e("Invalid APK file header");
|
||||
int bytesRead = fis.read(header);
|
||||
if (bytesRead < 4) {
|
||||
Logger.e("Cannot read file header");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 额外验证:检查APK文件是否完整
|
||||
// 尝试读取ZIP文件结构
|
||||
fis.getChannel().position(0);
|
||||
byte[] buffer = new byte[1024];
|
||||
int bytesRead = fis.read(buffer);
|
||||
if (bytesRead < 4) {
|
||||
Logger.e("APK file too small or corrupted");
|
||||
// ZIP文件头应该是 0x504B0304 (PK..)
|
||||
if (header[0] != 0x50 || header[1] != 0x4B || header[2] != 0x03 || header[3] != 0x04) {
|
||||
Logger.e("Invalid APK file header: " + String.format("%02X %02X %02X %02X", header[0], header[1], header[2], header[3]));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -113,6 +296,7 @@ public class Download {
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Logger.e("File verification failed: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package com.fongmi.android.tv.utils;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.fongmi.android.tv.Setting;
|
||||
|
||||
import com.fongmi.android.tv.App;
|
||||
import com.fongmi.android.tv.bean.Backup;
|
||||
import com.fongmi.android.tv.bean.History;
|
||||
import com.fongmi.android.tv.db.AppDatabase;
|
||||
import com.github.catvod.net.OkHttp;
|
||||
import com.github.catvod.utils.Logger;
|
||||
import com.github.catvod.utils.Prefers;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
/**
|
||||
* 同步码管理器(无需WebDAV账号)
|
||||
* 使用公开的HTTP存储服务,通过同步码区分用户
|
||||
*
|
||||
* 方案:使用GitHub Gist作为存储
|
||||
* - 用户创建一个公开的GitHub Gist
|
||||
* - 通过同步码作为文件名的一部分来区分不同用户
|
||||
* - 所有知道同步码的设备可以共享数据
|
||||
*/
|
||||
public class SyncCodeManager {
|
||||
|
||||
private static final String HISTORY_FILE_PREFIX = "xmbox_history_";
|
||||
private static final String SETTINGS_FILE_PREFIX = "xmbox_settings_";
|
||||
private static final String BACKUP_FILE_PREFIX = "xmbox_backup_";
|
||||
private static final String FILE_SUFFIX = ".json";
|
||||
|
||||
private static SyncCodeManager instance;
|
||||
private String syncCode;
|
||||
private String gistId; // GitHub Gist ID
|
||||
private String gistToken; // GitHub Personal Access Token(用于上传,可选)
|
||||
|
||||
public static SyncCodeManager get() {
|
||||
if (instance == null) {
|
||||
instance = new SyncCodeManager();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
private SyncCodeManager() {
|
||||
loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载配置
|
||||
*/
|
||||
private void loadConfig() {
|
||||
syncCode = Setting.getWebDAVSyncCode();
|
||||
gistId = Setting.getWebDAVGistId();
|
||||
gistToken = Setting.getWebDAVGistToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已配置
|
||||
*/
|
||||
public boolean isConfigured() {
|
||||
return !TextUtils.isEmpty(syncCode) && !TextUtils.isEmpty(gistId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成同步码
|
||||
* @return 8位随机同步码(字母+数字)
|
||||
*/
|
||||
public static String generateSyncCode() {
|
||||
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
Random random = new Random();
|
||||
StringBuilder code = new StringBuilder();
|
||||
for (int i = 0; i < 8; i++) {
|
||||
code.append(chars.charAt(random.nextInt(chars.length())));
|
||||
}
|
||||
return code.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件URL(GitHub Gist raw URL)
|
||||
*/
|
||||
private String getFileUrl(String prefix) {
|
||||
// GitHub Gist raw URL格式:
|
||||
// https://gist.githubusercontent.com/{username}/{gist_id}/raw/{filename}
|
||||
// 文件名格式:{prefix}{syncCode}.json
|
||||
// 例如:xmbox_history_ABC123XYZ.json
|
||||
|
||||
// 如果用户提供了完整的Gist raw URL
|
||||
String gistRawUrl = Setting.getWebDAVGistRawUrl();
|
||||
if (!TextUtils.isEmpty(gistRawUrl)) {
|
||||
String filename = prefix + syncCode + FILE_SUFFIX;
|
||||
return gistRawUrl + "/" + filename;
|
||||
}
|
||||
|
||||
// 否则需要从Gist ID构建(需要知道username)
|
||||
// 这里简化处理,要求用户提供完整的raw URL
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传观看记录
|
||||
*/
|
||||
public boolean uploadHistory() {
|
||||
if (!isConfigured()) {
|
||||
Logger.e("SyncCode: 未配置,无法上传观看记录");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取所有观看记录
|
||||
List<History> historyList = AppDatabase.get().getHistoryDao().findAll();
|
||||
String json = App.gson().toJson(historyList);
|
||||
|
||||
// 上传到GitHub Gist
|
||||
String fileUrl = getFileUrl(HISTORY_FILE_PREFIX);
|
||||
if (fileUrl == null) {
|
||||
Logger.e("SyncCode: 无法构建文件URL,请配置Gist Raw URL");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 使用GitHub Gist API更新文件
|
||||
boolean success = updateGistFile(fileUrl, json);
|
||||
if (success) {
|
||||
Logger.d("SyncCode: 观看记录上传成功,共 " + historyList.size() + " 条");
|
||||
}
|
||||
return success;
|
||||
} catch (Exception e) {
|
||||
Logger.e("SyncCode: 观看记录上传失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载观看记录
|
||||
*/
|
||||
public boolean downloadHistory() {
|
||||
if (!isConfigured()) {
|
||||
Logger.e("SyncCode: 未配置,无法下载观看记录");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
String fileUrl = getFileUrl(HISTORY_FILE_PREFIX);
|
||||
if (fileUrl == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 从GitHub Gist下载文件
|
||||
String json = downloadGistFile(fileUrl);
|
||||
if (TextUtils.isEmpty(json)) {
|
||||
Logger.d("SyncCode: 观看记录文件不存在,跳过下载");
|
||||
return false;
|
||||
}
|
||||
|
||||
Type listType = new TypeToken<List<History>>(){}.getType();
|
||||
List<History> remoteHistoryList = App.gson().fromJson(json, listType);
|
||||
|
||||
// 智能合并(与WebDAV相同的逻辑)
|
||||
if (!remoteHistoryList.isEmpty()) {
|
||||
List<History> localHistoryList = AppDatabase.get().getHistoryDao().findAll();
|
||||
|
||||
Map<String, History> localMap = new HashMap<>();
|
||||
for (History local : localHistoryList) {
|
||||
localMap.put(local.getKey(), local);
|
||||
}
|
||||
|
||||
List<History> toInsert = new java.util.ArrayList<>();
|
||||
List<History> toUpdate = new java.util.ArrayList<>();
|
||||
|
||||
for (History remote : remoteHistoryList) {
|
||||
History local = localMap.get(remote.getKey());
|
||||
|
||||
if (local == null) {
|
||||
toInsert.add(remote);
|
||||
} else {
|
||||
if (remote.getCreateTime() > local.getCreateTime()) {
|
||||
toUpdate.add(remote);
|
||||
} else if (remote.getCreateTime() == local.getCreateTime() && remote.getPosition() > local.getPosition()) {
|
||||
toUpdate.add(remote);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!toInsert.isEmpty()) {
|
||||
AppDatabase.get().getHistoryDao().insert(toInsert);
|
||||
Logger.d("SyncCode: 新增 " + toInsert.size() + " 条观看记录");
|
||||
}
|
||||
if (!toUpdate.isEmpty()) {
|
||||
AppDatabase.get().getHistoryDao().update(toUpdate);
|
||||
Logger.d("SyncCode: 更新 " + toUpdate.size() + " 条观看记录");
|
||||
}
|
||||
|
||||
Logger.d("SyncCode: 观看记录合并完成");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
Logger.e("SyncCode: 观看记录下载失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从GitHub Gist下载文件
|
||||
*/
|
||||
private String downloadGistFile(String fileUrl) {
|
||||
try {
|
||||
Response response = OkHttp.newCall(fileUrl).execute();
|
||||
if (response.isSuccessful()) {
|
||||
return response.body().string();
|
||||
}
|
||||
return "";
|
||||
} catch (Exception e) {
|
||||
Logger.e("SyncCode: 下载文件失败: " + e.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新GitHub Gist文件
|
||||
* 注意:GitHub Gist需要通过API更新,不能直接PUT文件
|
||||
*/
|
||||
private boolean updateGistFile(String fileUrl, String content) {
|
||||
// GitHub Gist需要通过REST API更新
|
||||
// 这里简化处理,实际需要调用GitHub Gist API
|
||||
// POST https://api.github.com/gists/{gist_id}
|
||||
|
||||
if (TextUtils.isEmpty(gistToken)) {
|
||||
Logger.w("SyncCode: 未提供GitHub Token,无法上传(Gist需要Token才能更新)");
|
||||
// 可以提示用户:同步码模式需要GitHub Token才能上传
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 构建GitHub Gist API请求
|
||||
String apiUrl = "https://api.github.com/gists/" + gistId;
|
||||
String filename = HISTORY_FILE_PREFIX + syncCode + FILE_SUFFIX;
|
||||
|
||||
// 构建请求体
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
Map<String, Object> files = new HashMap<>();
|
||||
Map<String, String> fileContent = new HashMap<>();
|
||||
fileContent.put("content", content);
|
||||
files.put(filename, fileContent);
|
||||
requestBody.put("files", files);
|
||||
|
||||
String jsonBody = App.gson().toJson(requestBody);
|
||||
|
||||
RequestBody body = RequestBody.create(
|
||||
MediaType.parse("application/json; charset=utf-8"),
|
||||
jsonBody
|
||||
);
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(apiUrl)
|
||||
.method("PATCH", body)
|
||||
.header("Authorization", "Bearer " + gistToken)
|
||||
.header("Accept", "application/vnd.github.v3+json")
|
||||
.build();
|
||||
|
||||
Response response = OkHttp.client().newCall(request).execute();
|
||||
boolean success = response.isSuccessful();
|
||||
response.close();
|
||||
|
||||
return success;
|
||||
} catch (Exception e) {
|
||||
Logger.e("SyncCode: 更新Gist失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步观看记录
|
||||
*/
|
||||
public boolean syncHistory() {
|
||||
if (!isConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
App.execute(() -> {
|
||||
uploadHistory();
|
||||
downloadHistory();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新加载配置
|
||||
*/
|
||||
public void reloadConfig() {
|
||||
loadConfig();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.fongmi.android.tv.utils;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.provider.Settings;
|
||||
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
import com.fongmi.android.tv.App;
|
||||
import com.github.catvod.utils.Logger;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* Android 更新安装器
|
||||
* 处理安装权限检查和请求,以及APK安装
|
||||
*/
|
||||
public class UpdateInstaller {
|
||||
|
||||
private static UpdateInstaller instance;
|
||||
private File pendingInstallFile; // 待安装的文件
|
||||
|
||||
public static UpdateInstaller get() {
|
||||
if (instance == null) {
|
||||
instance = new UpdateInstaller();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有安装权限
|
||||
* @return 是否有安装权限
|
||||
*/
|
||||
public boolean hasInstallPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
return App.get().getPackageManager().canRequestPackageInstalls();
|
||||
}
|
||||
return true; // Android 8.0以下不需要此权限
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求安装权限(打开设置页面)
|
||||
*/
|
||||
public void requestInstallPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
try {
|
||||
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES);
|
||||
intent.setData(Uri.parse("package:" + App.get().getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
App.get().startActivity(intent);
|
||||
Logger.d("UpdateInstaller: 已打开安装权限设置页面");
|
||||
} catch (Exception e) {
|
||||
Logger.e("UpdateInstaller: 无法打开安装权限设置页面: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装 APK 文件
|
||||
* @param apkFile APK 文件
|
||||
* @return 是否成功启动安装流程
|
||||
*/
|
||||
public boolean install(File apkFile) {
|
||||
return install(apkFile, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装 APK 文件
|
||||
* @param apkFile APK 文件
|
||||
* @param checkPermission 是否检查权限(如果为false,即使没有权限也会尝试安装)
|
||||
* @return 是否成功启动安装流程
|
||||
*/
|
||||
public boolean install(File apkFile, boolean checkPermission) {
|
||||
try {
|
||||
// Android 8.0+ 需要请求安装权限
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
if (checkPermission && !hasInstallPermission()) {
|
||||
// 没有权限,保存待安装的文件,返回 false,由调用方处理
|
||||
this.pendingInstallFile = apkFile;
|
||||
Logger.d("UpdateInstaller: 没有安装权限,已保存待安装文件: " + apkFile.getAbsolutePath());
|
||||
return false; // 返回false表示需要权限,但不表示失败
|
||||
}
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!apkFile.exists() || !apkFile.isFile()) {
|
||||
Logger.e("UpdateInstaller: APK文件不存在或不是文件: " + apkFile.getAbsolutePath());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 使用 FileProvider 获取 URI
|
||||
String authority = App.get().getPackageName() + ".provider";
|
||||
Uri apkUri = FileProvider.getUriForFile(App.get(), authority, apkFile);
|
||||
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW);
|
||||
intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
App.get().startActivity(intent);
|
||||
Logger.d("UpdateInstaller: 已启动安装程序");
|
||||
this.pendingInstallFile = null; // 清除待安装文件
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Logger.e("UpdateInstaller: 安装失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待安装的文件
|
||||
* @return 待安装的文件,如果没有则返回null
|
||||
*/
|
||||
public File getPendingInstallFile() {
|
||||
return pendingInstallFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有待安装的文件且权限已授予
|
||||
* 用于应用恢复时自动检测
|
||||
*/
|
||||
public boolean hasPendingInstall() {
|
||||
return pendingInstallFile != null && pendingInstallFile.exists() && hasInstallPermission();
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动重试安装(用于应用恢复时)
|
||||
*/
|
||||
public boolean autoRetryInstall() {
|
||||
if (hasPendingInstall()) {
|
||||
File file = pendingInstallFile;
|
||||
pendingInstallFile = null; // 清除待安装文件
|
||||
return install(file, false); // 不检查权限,因为已经检查过了
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除待安装的文件
|
||||
*/
|
||||
public void clearPendingInstall() {
|
||||
this.pendingInstallFile = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,759 @@
|
||||
package com.fongmi.android.tv.utils;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.fongmi.android.tv.App;
|
||||
import com.fongmi.android.tv.bean.Backup;
|
||||
import com.fongmi.android.tv.bean.History;
|
||||
import com.fongmi.android.tv.db.AppDatabase;
|
||||
import com.github.catvod.utils.Logger;
|
||||
import com.github.catvod.utils.Prefers;
|
||||
import com.google.gson.Gson;
|
||||
import com.thegrizzlylabs.sardineandroid.Sardine;
|
||||
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.fongmi.android.tv.Setting;
|
||||
|
||||
/**
|
||||
* WebDAV同步管理器
|
||||
* 用于同步观看记录和设置到WebDAV服务器
|
||||
*/
|
||||
public class WebDAVSyncManager {
|
||||
|
||||
private static final String HISTORY_FILE = "xmbox_history.json";
|
||||
private static final String SETTINGS_FILE = "xmbox_settings.json";
|
||||
private static final String BACKUP_FILE = "xmbox_backup.json";
|
||||
|
||||
// 同步模式:ACCOUNT(账号模式)或 CODE(同步码模式)
|
||||
public enum SyncMode {
|
||||
ACCOUNT, // 使用WebDAV账号
|
||||
CODE // 使用同步码(无需账号)
|
||||
}
|
||||
|
||||
private static WebDAVSyncManager instance;
|
||||
private Sardine sardine;
|
||||
private String baseUrl;
|
||||
private String username;
|
||||
private String password;
|
||||
private String syncCode; // 同步码
|
||||
private SyncMode syncMode = SyncMode.ACCOUNT; // 默认使用账号模式
|
||||
private volatile boolean isSyncing = false; // 同步锁,防止重复同步
|
||||
|
||||
public static WebDAVSyncManager get() {
|
||||
if (instance == null) {
|
||||
instance = new WebDAVSyncManager();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
private WebDAVSyncManager() {
|
||||
loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载WebDAV配置
|
||||
*/
|
||||
private void loadConfig() {
|
||||
// 检查同步模式
|
||||
String modeStr = Setting.getWebDAVSyncMode();
|
||||
if ("CODE".equals(modeStr)) {
|
||||
syncMode = SyncMode.CODE;
|
||||
syncCode = Setting.getWebDAVSyncCode();
|
||||
// 同步码模式:使用公开的WebDAV服务器(如jsDelivr CDN的GitHub仓库)
|
||||
// 或者使用其他公开存储服务
|
||||
baseUrl = getPublicStorageUrl();
|
||||
username = null;
|
||||
password = null;
|
||||
} else {
|
||||
syncMode = SyncMode.ACCOUNT;
|
||||
baseUrl = Setting.getWebDAVUrl();
|
||||
username = Setting.getWebDAVUsername();
|
||||
password = Setting.getWebDAVPassword();
|
||||
}
|
||||
|
||||
if (syncMode == SyncMode.ACCOUNT) {
|
||||
// 账号模式:需要账号密码
|
||||
if (!TextUtils.isEmpty(baseUrl) && !TextUtils.isEmpty(username) && !TextUtils.isEmpty(password)) {
|
||||
try {
|
||||
sardine = new OkHttpSardine();
|
||||
sardine.setCredentials(username, password);
|
||||
Logger.d("WebDAV: 账号模式配置已加载");
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 初始化失败: " + e.getMessage());
|
||||
sardine = null;
|
||||
}
|
||||
} else {
|
||||
sardine = null;
|
||||
}
|
||||
} else {
|
||||
// 同步码模式:使用公开存储,无需认证
|
||||
if (!TextUtils.isEmpty(syncCode) && !TextUtils.isEmpty(baseUrl)) {
|
||||
try {
|
||||
sardine = new OkHttpSardine();
|
||||
// 公开存储不需要认证
|
||||
Logger.d("WebDAV: 同步码模式配置已加载,同步码: " + syncCode);
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 初始化失败: " + e.getMessage());
|
||||
sardine = null;
|
||||
}
|
||||
} else {
|
||||
sardine = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公开存储URL(同步码模式使用)
|
||||
* 方案:使用GitHub Gist作为公开存储
|
||||
* 用户需要:
|
||||
* 1. 创建一个GitHub Gist(公开)
|
||||
* 2. 获取Gist的raw URL
|
||||
* 3. 输入同步码
|
||||
*
|
||||
* 文件路径格式:{gist_raw_url}/{syncCode}/xmbox_history.json
|
||||
*/
|
||||
private String getPublicStorageUrl() {
|
||||
// 获取用户配置的GitHub Gist raw URL
|
||||
// 例如:https://gist.githubusercontent.com/username/gist_id/raw/
|
||||
String gistBaseUrl = Setting.getWebDAVPublicUrl();
|
||||
|
||||
if (TextUtils.isEmpty(gistBaseUrl)) {
|
||||
// 如果没有配置,返回null(需要用户配置)
|
||||
return null;
|
||||
}
|
||||
|
||||
// 将同步码添加到路径中,作为子目录
|
||||
// 例如:https://gist.githubusercontent.com/username/gist_id/raw/ABC123XYZ/
|
||||
if (!TextUtils.isEmpty(syncCode)) {
|
||||
String url = gistBaseUrl.endsWith("/") ? gistBaseUrl : gistBaseUrl + "/";
|
||||
return url + syncCode + "/";
|
||||
}
|
||||
|
||||
return gistBaseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查WebDAV是否已配置
|
||||
*/
|
||||
public boolean isConfigured() {
|
||||
if (syncMode == SyncMode.CODE) {
|
||||
// 同步码模式:需要同步码和公开存储URL
|
||||
return sardine != null && !TextUtils.isEmpty(baseUrl) && !TextUtils.isEmpty(syncCode);
|
||||
} else {
|
||||
// 账号模式:需要账号密码和URL
|
||||
return sardine != null && !TextUtils.isEmpty(baseUrl) && !TextUtils.isEmpty(username) && !TextUtils.isEmpty(password);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成同步码
|
||||
* @return 8位随机同步码(字母+数字)
|
||||
*/
|
||||
public static String generateSyncCode() {
|
||||
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
java.util.Random random = new java.util.Random();
|
||||
StringBuilder code = new StringBuilder();
|
||||
for (int i = 0; i < 8; i++) {
|
||||
code.append(chars.charAt(random.nextInt(chars.length())));
|
||||
}
|
||||
return code.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试WebDAV连接
|
||||
* @return 测试结果,包含成功状态和错误信息
|
||||
*/
|
||||
public TestResult testConnectionWithMessage() {
|
||||
if (!isConfigured()) {
|
||||
return new TestResult(false, "WebDAV未配置,请检查URL、用户名和密码");
|
||||
}
|
||||
|
||||
try {
|
||||
// 确保baseUrl以/结尾
|
||||
String testUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
||||
Logger.d("WebDAV: 测试连接URL: " + testUrl);
|
||||
Logger.d("WebDAV: 用户名: " + (username != null ? username : "null"));
|
||||
|
||||
// 尝试列出目录
|
||||
sardine.list(testUrl);
|
||||
Logger.d("WebDAV: 连接测试成功,可以访问目录");
|
||||
return new TestResult(true, "连接成功!");
|
||||
} catch (java.io.IOException e) {
|
||||
String errorMsg = e.getMessage();
|
||||
Logger.e("WebDAV: 连接测试失败: " + errorMsg);
|
||||
Logger.e("WebDAV: 异常类型: " + e.getClass().getName());
|
||||
e.printStackTrace();
|
||||
|
||||
// 根据错误类型提供更详细的提示
|
||||
if (errorMsg != null) {
|
||||
if (errorMsg.contains("401") || errorMsg.contains("Unauthorized")) {
|
||||
return new TestResult(false, "认证失败:用户名或密码错误,请检查账号密码。\n提示:坚果云需要使用应用密码,不是登录密码");
|
||||
} else if (errorMsg.contains("403") || errorMsg.contains("Forbidden")) {
|
||||
return new TestResult(false, "访问被拒绝:账号可能没有WebDAV权限");
|
||||
} else if (errorMsg.contains("404") || errorMsg.contains("Not Found")) {
|
||||
return new TestResult(false, "URL不存在:请检查WebDAV服务器地址是否正确");
|
||||
} else if (errorMsg.contains("SSL") || errorMsg.contains("Certificate")) {
|
||||
return new TestResult(false, "SSL证书错误:请检查服务器证书是否有效");
|
||||
} else if (errorMsg.contains("timeout") || errorMsg.contains("Timeout")) {
|
||||
return new TestResult(false, "连接超时:请检查网络连接或服务器地址");
|
||||
} else if (errorMsg.contains("UnknownHost") || errorMsg.contains("unreachable")) {
|
||||
return new TestResult(false, "无法连接到服务器:请检查网络连接和服务器地址");
|
||||
}
|
||||
}
|
||||
return new TestResult(false, "连接失败:" + (errorMsg != null ? errorMsg : "未知错误"));
|
||||
} catch (Exception e) {
|
||||
String errorMsg = e.getMessage();
|
||||
Logger.e("WebDAV: 连接测试失败: " + errorMsg);
|
||||
Logger.e("WebDAV: 异常类型: " + e.getClass().getName());
|
||||
e.printStackTrace();
|
||||
return new TestResult(false, "连接失败:" + (errorMsg != null ? errorMsg : e.getClass().getSimpleName()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试WebDAV连接(兼容旧接口)
|
||||
*/
|
||||
public boolean testConnection() {
|
||||
return testConnectionWithMessage().success;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试结果类
|
||||
*/
|
||||
public static class TestResult {
|
||||
public final boolean success;
|
||||
public final String message;
|
||||
|
||||
public TestResult(boolean success, String message) {
|
||||
this.success = success;
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
private void ensureDirectory(String path) throws Exception {
|
||||
try {
|
||||
if (!sardine.exists(path)) {
|
||||
sardine.createDirectory(path);
|
||||
Logger.d("WebDAV: 创建目录: " + path);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 创建目录失败: " + e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件完整URL
|
||||
*/
|
||||
private String getFileUrl(String filename) {
|
||||
if (syncMode == SyncMode.CODE) {
|
||||
// 同步码模式:使用GitHub Gist raw URL
|
||||
// 格式:https://gist.githubusercontent.com/username/gist_id/raw/{syncCode}/{filename}
|
||||
String url = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
||||
return url + filename;
|
||||
} else {
|
||||
// 账号模式:使用WebDAV URL
|
||||
// 对于坚果云:https://dav.jianguoyun.com/dav/xmbox_history.json
|
||||
// 确保 baseUrl 以 / 结尾
|
||||
String url = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
||||
return url + filename;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传观看记录
|
||||
*/
|
||||
public boolean uploadHistory() {
|
||||
if (!isConfigured()) {
|
||||
Logger.e("WebDAV: 未配置,无法上传观看记录");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取所有观看记录
|
||||
List<History> historyList = AppDatabase.get().getHistoryDao().findAll();
|
||||
if (historyList == null) {
|
||||
historyList = new java.util.ArrayList<>();
|
||||
}
|
||||
|
||||
Logger.d("WebDAV: 准备上传观看记录,共 " + historyList.size() + " 条");
|
||||
|
||||
String json = App.gson().toJson(historyList);
|
||||
if (TextUtils.isEmpty(json)) {
|
||||
Logger.w("WebDAV: JSON数据为空");
|
||||
json = "[]"; // 确保至少有一个有效的JSON数组
|
||||
}
|
||||
|
||||
// 确保目录存在(如果baseUrl包含子目录)
|
||||
if (syncMode == SyncMode.ACCOUNT && !TextUtils.isEmpty(baseUrl)) {
|
||||
try {
|
||||
String dirUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
||||
ensureDirectory(dirUrl);
|
||||
} catch (Exception e) {
|
||||
Logger.w("WebDAV: 创建目录失败,尝试继续上传: " + e.getMessage());
|
||||
// 继续尝试上传,某些WebDAV服务可能不需要预先创建目录
|
||||
}
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
String fileUrl = getFileUrl(HISTORY_FILE);
|
||||
Logger.d("WebDAV: 上传文件URL: " + fileUrl);
|
||||
Logger.d("WebDAV: 上传数据大小: " + json.length() + " 字节");
|
||||
|
||||
byte[] data = json.getBytes("UTF-8");
|
||||
|
||||
// 对于坚果云等WebDAV服务,直接上传文件即可(会自动创建文件)
|
||||
// 如果文件已存在,会被覆盖
|
||||
sardine.put(fileUrl, data);
|
||||
|
||||
// 验证上传是否成功:检查文件是否存在
|
||||
if (sardine.exists(fileUrl)) {
|
||||
Logger.d("WebDAV: 观看记录上传成功,共 " + historyList.size() + " 条,文件已确认存在");
|
||||
return true;
|
||||
} else {
|
||||
Logger.e("WebDAV: 上传后文件不存在,可能上传失败");
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 观看记录上传失败: " + e.getMessage());
|
||||
Logger.e("WebDAV: 异常类型: " + e.getClass().getName());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载观看记录
|
||||
*/
|
||||
public boolean downloadHistory() {
|
||||
if (!isConfigured()) {
|
||||
Logger.e("WebDAV: 未配置,无法下载观看记录");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
String fileUrl = getFileUrl(HISTORY_FILE);
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!sardine.exists(fileUrl)) {
|
||||
Logger.d("WebDAV: 观看记录文件不存在,跳过下载");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 下载文件(使用循环读取,避免available()不准确的问题)
|
||||
InputStream is = sardine.get(fileUrl);
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = is.read(buffer)) != -1) {
|
||||
baos.write(buffer, 0, bytesRead);
|
||||
}
|
||||
is.close();
|
||||
byte[] data = baos.toByteArray();
|
||||
baos.close();
|
||||
|
||||
String json = new String(data, "UTF-8");
|
||||
if (TextUtils.isEmpty(json)) {
|
||||
Logger.d("WebDAV: 观看记录文件为空");
|
||||
return true; // 文件存在但为空,也算同步成功
|
||||
}
|
||||
|
||||
Type listType = new TypeToken<List<History>>(){}.getType();
|
||||
List<History> remoteHistoryList = App.gson().fromJson(json, listType);
|
||||
|
||||
// 验证数据
|
||||
if (remoteHistoryList == null) {
|
||||
Logger.e("WebDAV: JSON解析失败,返回null");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 智能合并:比较本地和远程记录,保留较新的
|
||||
List<History> localHistoryList = AppDatabase.get().getHistoryDao().findAll();
|
||||
|
||||
// 创建本地记录的映射(key -> History)
|
||||
java.util.Map<String, History> localMap = new java.util.HashMap<>();
|
||||
for (History local : localHistoryList) {
|
||||
if (local != null && local.getKey() != null) {
|
||||
localMap.put(local.getKey(), local);
|
||||
}
|
||||
}
|
||||
|
||||
// 合并远程记录
|
||||
List<History> toInsert = new java.util.ArrayList<>();
|
||||
List<History> toUpdate = new java.util.ArrayList<>();
|
||||
|
||||
for (History remote : remoteHistoryList) {
|
||||
// 验证远程记录
|
||||
if (remote == null || TextUtils.isEmpty(remote.getKey())) {
|
||||
Logger.w("WebDAV: 跳过无效的远程记录(key为空)");
|
||||
continue;
|
||||
}
|
||||
|
||||
History local = localMap.get(remote.getKey());
|
||||
|
||||
if (local == null) {
|
||||
// 本地没有,直接添加
|
||||
toInsert.add(remote);
|
||||
} else {
|
||||
// 本地有,比较createTime,保留较新的
|
||||
if (remote.getCreateTime() > local.getCreateTime()) {
|
||||
// 远程更新,更新本地
|
||||
toUpdate.add(remote);
|
||||
} else if (remote.getCreateTime() == local.getCreateTime()) {
|
||||
// 时间相同,比较position,保留进度更靠后的
|
||||
// 注意:position可能是C.TIME_UNSET(负数),需要处理
|
||||
long remotePos = remote.getPosition();
|
||||
long localPos = local.getPosition();
|
||||
// 如果都是有效值(>=0),比较大小;如果有无效值,保留有效值
|
||||
if (remotePos >= 0 && localPos >= 0) {
|
||||
if (remotePos > localPos) {
|
||||
toUpdate.add(remote);
|
||||
}
|
||||
} else if (remotePos >= 0 && localPos < 0) {
|
||||
// 远程有效,本地无效,更新
|
||||
toUpdate.add(remote);
|
||||
}
|
||||
// 否则保留本地,不更新
|
||||
}
|
||||
// 否则保留本地,不更新
|
||||
}
|
||||
}
|
||||
|
||||
// 执行插入和更新
|
||||
if (!toInsert.isEmpty()) {
|
||||
AppDatabase.get().getHistoryDao().insert(toInsert);
|
||||
Logger.d("WebDAV: 新增 " + toInsert.size() + " 条观看记录");
|
||||
}
|
||||
if (!toUpdate.isEmpty()) {
|
||||
AppDatabase.get().getHistoryDao().update(toUpdate);
|
||||
Logger.d("WebDAV: 更新 " + toUpdate.size() + " 条观看记录");
|
||||
}
|
||||
|
||||
Logger.d("WebDAV: 观看记录合并完成,远程 " + remoteHistoryList.size() + " 条,本地 " + localHistoryList.size() + " 条");
|
||||
return true; // 即使远程为空,也算同步成功
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 观看记录下载失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传设置
|
||||
*/
|
||||
public boolean uploadSettings() {
|
||||
if (!isConfigured()) {
|
||||
Logger.e("WebDAV: 未配置,无法上传设置");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取所有设置
|
||||
Map<String, ?> allPrefs = Prefers.getPrefers().getAll();
|
||||
String json = App.gson().toJson(allPrefs);
|
||||
|
||||
// 确保目录存在(如果baseUrl包含子目录)
|
||||
if (syncMode == SyncMode.ACCOUNT && !TextUtils.isEmpty(baseUrl)) {
|
||||
try {
|
||||
String dirUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
||||
ensureDirectory(dirUrl);
|
||||
} catch (Exception e) {
|
||||
Logger.w("WebDAV: 创建目录失败,尝试继续上传: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
String fileUrl = getFileUrl(SETTINGS_FILE);
|
||||
byte[] data = json.getBytes("UTF-8");
|
||||
sardine.put(fileUrl, data);
|
||||
|
||||
Logger.d("WebDAV: 设置上传成功");
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 设置上传失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载设置
|
||||
*/
|
||||
public boolean downloadSettings() {
|
||||
if (!isConfigured()) {
|
||||
Logger.e("WebDAV: 未配置,无法下载设置");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
String fileUrl = getFileUrl(SETTINGS_FILE);
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!sardine.exists(fileUrl)) {
|
||||
Logger.d("WebDAV: 设置文件不存在,跳过下载");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
InputStream is = sardine.get(fileUrl);
|
||||
byte[] buffer = new byte[is.available()];
|
||||
is.read(buffer);
|
||||
is.close();
|
||||
|
||||
String json = new String(buffer, "UTF-8");
|
||||
Gson gson = App.gson();
|
||||
Map<String, Object> settings = gson.fromJson(json, Map.class);
|
||||
|
||||
// 应用设置(合并,不覆盖已存在的)
|
||||
if (settings != null && !settings.isEmpty()) {
|
||||
for (Map.Entry<String, Object> entry : settings.entrySet()) {
|
||||
// 只同步非敏感设置,跳过某些本地设置
|
||||
String key = entry.getKey();
|
||||
if (!shouldSkipSetting(key)) {
|
||||
Prefers.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
Logger.d("WebDAV: 设置下载成功");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 设置下载失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否应该跳过某个设置项
|
||||
*/
|
||||
private boolean shouldSkipSetting(String key) {
|
||||
// 跳过WebDAV相关设置,避免循环同步
|
||||
if (key.startsWith("webdav_")) {
|
||||
return true;
|
||||
}
|
||||
// 跳过设备特定设置
|
||||
if (key.equals("device_uuid") || key.equals("device_name")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传完整备份(包含所有数据)
|
||||
*/
|
||||
public boolean uploadBackup() {
|
||||
if (!isConfigured()) {
|
||||
Logger.e("WebDAV: 未配置,无法上传备份");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
Backup backup = Backup.create();
|
||||
String json = backup.toString();
|
||||
|
||||
// 确保目录存在(如果baseUrl包含子目录)
|
||||
if (syncMode == SyncMode.ACCOUNT && !TextUtils.isEmpty(baseUrl)) {
|
||||
try {
|
||||
String dirUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
||||
ensureDirectory(dirUrl);
|
||||
} catch (Exception e) {
|
||||
Logger.w("WebDAV: 创建目录失败,尝试继续上传: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
String fileUrl = getFileUrl(BACKUP_FILE);
|
||||
byte[] data = json.getBytes("UTF-8");
|
||||
sardine.put(fileUrl, data);
|
||||
|
||||
Logger.d("WebDAV: 完整备份上传成功");
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 完整备份上传失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载完整备份
|
||||
*/
|
||||
public boolean downloadBackup() {
|
||||
if (!isConfigured()) {
|
||||
Logger.e("WebDAV: 未配置,无法下载备份");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
String fileUrl = getFileUrl(BACKUP_FILE);
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!sardine.exists(fileUrl)) {
|
||||
Logger.d("WebDAV: 备份文件不存在,跳过下载");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
InputStream is = sardine.get(fileUrl);
|
||||
byte[] buffer = new byte[is.available()];
|
||||
is.read(buffer);
|
||||
is.close();
|
||||
|
||||
String json = new String(buffer, "UTF-8");
|
||||
Backup backup = Backup.objectFrom(json);
|
||||
|
||||
// 恢复备份
|
||||
if (!backup.getConfig().isEmpty()) {
|
||||
backup.restore();
|
||||
Logger.d("WebDAV: 完整备份下载并恢复成功");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
Logger.e("WebDAV: 完整备份下载失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步观看记录(上传+下载合并)
|
||||
* @param async 是否异步执行,true=异步,false=同步(阻塞)
|
||||
*/
|
||||
public boolean syncHistory(boolean async) {
|
||||
if (!isConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 防止重复同步
|
||||
if (isSyncing) {
|
||||
Logger.w("WebDAV: 同步正在进行中,跳过本次请求");
|
||||
return false;
|
||||
}
|
||||
|
||||
Runnable syncTask = () -> {
|
||||
try {
|
||||
isSyncing = true;
|
||||
// 先上传本地记录
|
||||
uploadHistory();
|
||||
// 再下载远程记录并合并
|
||||
downloadHistory();
|
||||
} finally {
|
||||
isSyncing = false;
|
||||
}
|
||||
};
|
||||
|
||||
if (async) {
|
||||
App.execute(syncTask);
|
||||
} else {
|
||||
syncTask.run();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步观看记录(异步执行,默认)
|
||||
*/
|
||||
public boolean syncHistory() {
|
||||
return syncHistory(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步设置(上传+下载合并)
|
||||
* @param async 是否异步执行
|
||||
*/
|
||||
public boolean syncSettings(boolean async) {
|
||||
if (!isConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Runnable syncTask = () -> {
|
||||
// 先上传本地设置
|
||||
uploadSettings();
|
||||
// 再下载远程设置并合并
|
||||
downloadSettings();
|
||||
};
|
||||
|
||||
if (async) {
|
||||
App.execute(syncTask);
|
||||
} else {
|
||||
syncTask.run();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步设置(异步执行,默认)
|
||||
*/
|
||||
public boolean syncSettings() {
|
||||
return syncSettings(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整同步(观看记录+设置)
|
||||
* @param async 是否异步执行
|
||||
*/
|
||||
public boolean syncAll(boolean async) {
|
||||
if (!isConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 防止重复同步
|
||||
if (isSyncing) {
|
||||
Logger.w("WebDAV: 同步正在进行中,跳过本次请求");
|
||||
return false;
|
||||
}
|
||||
|
||||
Runnable syncTask = () -> {
|
||||
try {
|
||||
isSyncing = true;
|
||||
// 先上传本地记录
|
||||
uploadHistory();
|
||||
// 再下载远程记录并合并
|
||||
downloadHistory();
|
||||
// 同步设置
|
||||
syncSettings(false); // 设置同步使用同步方式,避免嵌套异步
|
||||
} finally {
|
||||
isSyncing = false;
|
||||
}
|
||||
};
|
||||
|
||||
if (async) {
|
||||
App.execute(syncTask);
|
||||
} else {
|
||||
syncTask.run();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整同步(异步执行,默认)
|
||||
*/
|
||||
public boolean syncAll() {
|
||||
return syncAll(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新加载配置(配置更改后调用)
|
||||
*/
|
||||
public void reloadConfig() {
|
||||
loadConfig();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
<string name="setting_app">应用设置</string>
|
||||
<string name="setting_network">网络设置</string>
|
||||
<string name="setting_data">数据管理</string>
|
||||
<string name="app_version">v3.0.9</string>
|
||||
<string name="app_version">v3.1.1</string>
|
||||
<string name="about_github">在GitHub上查看</string>
|
||||
|
||||
<!-- Backup & Restore -->
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
<string name="setting_app">應用設置</string>
|
||||
<string name="setting_network">網絡設置</string>
|
||||
<string name="setting_data">數據管理</string>
|
||||
<string name="app_version">v3.0.9</string>
|
||||
<string name="app_version">v3.1.1</string>
|
||||
<string name="about_github">在GitHub上查看</string>
|
||||
|
||||
<!-- Backup & Restore -->
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
<string name="setting_choose">Choose</string>
|
||||
<string name="setting_off">Off</string>
|
||||
<string name="setting_on">On</string>
|
||||
<string name="app_version">v3.0.9</string>
|
||||
<string name="app_version">v3.1.1</string>
|
||||
<string name="about_github">View on GitHub</string>
|
||||
|
||||
<!-- Backup & Restore -->
|
||||
|
||||
Reference in New Issue
Block a user