diff --git a/app/build.gradle b/app/build.gradle index e8544b94bd..eb3058ffbe 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -138,6 +138,8 @@ dependencies { implementation 'org.fourthline.cling:cling-support:2.1.1' implementation 'org.greenrobot:eventbus:3.3.1' implementation 'org.nanohttpd:nanohttpd:2.3.1' + implementation 'commons-net:commons-net:3.9.0' + implementation 'com.google.code.gson:gson:2.8.9' implementation('org.simpleframework:simple-xml:2.7.1') { exclude group: 'stax', module: 'stax-api' exclude group: 'xpp3', module: 'xpp3' } implementation(ext: 'aar', name: 'dlna-core', group: 'fongmi', version: 'release') implementation(ext: 'aar', name: 'dlna-dmc', group: 'fongmi', version: 'release') diff --git a/app/schemas/com.fongmi.android.tv.db.AppDatabase/32.json b/app/schemas/com.fongmi.android.tv.db.AppDatabase/32.json new file mode 100644 index 0000000000..494b89f359 --- /dev/null +++ b/app/schemas/com.fongmi.android.tv.db.AppDatabase/32.json @@ -0,0 +1,526 @@ +{ + "formatVersion": 1, + "database": { + "version": 31, + "identityHash": "69877503693666f70da7e229ab1b7136", + "entities": [ + { + "tableName": "Keep", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `siteName` TEXT, `vodName` TEXT, `vodPic` TEXT, `createTime` INTEGER NOT NULL, `type` INTEGER NOT NULL, `cid` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "siteName", + "columnName": "siteName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "vodName", + "columnName": "vodName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "vodPic", + "columnName": "vodPic", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createTime", + "columnName": "createTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cid", + "columnName": "cid", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Site", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `searchable` INTEGER, `changeable` INTEGER, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "searchable", + "columnName": "searchable", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "changeable", + "columnName": "changeable", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Live", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `boot` INTEGER NOT NULL, `pass` INTEGER NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "boot", + "columnName": "boot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pass", + "columnName": "pass", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Track", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `group` INTEGER NOT NULL, `track` INTEGER NOT NULL, `player` INTEGER NOT NULL, `key` TEXT, `name` TEXT, `selected` INTEGER NOT NULL, `adaptive` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "player", + "columnName": "player", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "selected", + "columnName": "selected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "adaptive", + "columnName": "adaptive", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Track_key_player_type", + "unique": true, + "columnNames": [ + "key", + "player", + "type" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Track_key_player_type` ON `${TABLE_NAME}` (`key`, `player`, `type`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "Config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `time` INTEGER NOT NULL, `url` TEXT, `json` TEXT, `name` TEXT, `logo` TEXT, `home` TEXT, `parse` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logo", + "columnName": "logo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "home", + "columnName": "home", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parse", + "columnName": "parse", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Config_url_type", + "unique": true, + "columnNames": [ + "url", + "type" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Config_url_type` ON `${TABLE_NAME}` (`url`, `type`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "Device", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `uuid` TEXT, `name` TEXT, `ip` TEXT, `type` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ip", + "columnName": "ip", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Device_uuid_name", + "unique": true, + "columnNames": [ + "uuid", + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Device_uuid_name` ON `${TABLE_NAME}` (`uuid`, `name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "History", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `vodPic` TEXT, `vodName` TEXT, `vodFlag` TEXT, `vodRemarks` TEXT, `episodeUrl` TEXT, `revSort` INTEGER NOT NULL, `revPlay` INTEGER NOT NULL, `createTime` INTEGER NOT NULL, `opening` INTEGER NOT NULL, `ending` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `speed` REAL NOT NULL, `player` INTEGER NOT NULL, `scale` INTEGER NOT NULL, `cid` INTEGER NOT NULL,`lastUpdated` INTEGER, `deleted` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vodPic", + "columnName": "vodPic", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "vodName", + "columnName": "vodName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "vodFlag", + "columnName": "vodFlag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "vodRemarks", + "columnName": "vodRemarks", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "episodeUrl", + "columnName": "episodeUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "revSort", + "columnName": "revSort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revPlay", + "columnName": "revPlay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createTime", + "columnName": "createTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opening", + "columnName": "opening", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ending", + "columnName": "ending", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speed", + "columnName": "speed", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "player", + "columnName": "player", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scale", + "columnName": "scale", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cid", + "columnName": "cid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `vodPic` TEXT, `vodName` TEXT, `url` TEXT, `header` TEXT, `createTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vodPic", + "columnName": "vodPic", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "vodName", + "columnName": "vodName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createTime", + "columnName": "createTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(43, '69877503693666f70da7e229ab1b7136')" + ] + } +} \ No newline at end of file diff --git a/app/src/leanback/AndroidManifest.xml b/app/src/leanback/AndroidManifest.xml index 9c98b0e5f4..f134052402 100644 --- a/app/src/leanback/AndroidManifest.xml +++ b/app/src/leanback/AndroidManifest.xml @@ -150,6 +150,11 @@ android:configChanges="screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|orientation" android:screenOrientation="sensorLandscape" /> + + + + + { + @Override + protected Void doInBackground(Void... voids) { + try { + //HistorySyncManager syncManager = new HistorySyncManager("ftp://192.168.1.1:21/USB2T/(Documents)/(TVBox)/TV4/TV.json", "user", "pass456"); + HistorySyncManager syncManager = new HistorySyncManager(Setting.getFtpUri(), Setting.getFtpUsername(), Setting.getFtpPassword()); + syncManager.syncAll(); + } catch (Exception e) { + Log.e("Sync", "Error during sync operation", e); + } + return null; + } + + @Override + protected void onPostExecute(Void result) { + Log.d("Sync", "Sync operation completed"); + } + } + private void setWallDefault(View view) { WallConfig.refresh(Setting.getWall() == 4 ? 1 : Setting.getWall() + 1); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPlayerActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPlayerActivity.java index 595e44aa2f..f90290c440 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPlayerActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPlayerActivity.java @@ -12,6 +12,7 @@ import com.fongmi.android.tv.databinding.ActivitySettingPlayerBinding; import com.fongmi.android.tv.impl.BufferCallback; import com.fongmi.android.tv.impl.SubtitleCallback; +import com.fongmi.android.tv.impl.SyncCallback; import com.fongmi.android.tv.impl.UaCallback; import com.fongmi.android.tv.player.Players; import com.fongmi.android.tv.ui.base.BaseActivity; @@ -20,7 +21,7 @@ import com.fongmi.android.tv.ui.dialog.UaDialog; import com.fongmi.android.tv.utils.ResUtil; -public class SettingPlayerActivity extends BaseActivity implements UaCallback, BufferCallback, SubtitleCallback { +public class SettingPlayerActivity extends BaseActivity implements UaCallback, BufferCallback, SubtitleCallback, SyncCallback { private ActivitySettingPlayerBinding mBinding; private String[] caption; @@ -178,4 +179,6 @@ public void setSubtitle(int size) { mBinding.subtitleText.setText(String.valueOf(size)); } + @Override + public void setSync() { } } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SyncDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SyncDialog.java new file mode 100644 index 0000000000..0e8948d802 --- /dev/null +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SyncDialog.java @@ -0,0 +1,83 @@ +package com.fongmi.android.tv.ui.dialog; + +import android.content.DialogInterface; +import android.view.LayoutInflater; +import android.view.View; +import android.view.WindowManager; + +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentActivity; + +import com.fongmi.android.tv.Setting; +import com.fongmi.android.tv.bean.Config; +import com.fongmi.android.tv.databinding.DialogSyncBinding; +import com.fongmi.android.tv.event.ServerEvent; +import com.fongmi.android.tv.impl.SyncCallback; +import com.fongmi.android.tv.utils.ResUtil; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; + +public class SyncDialog{ // implements DialogInterface.OnDismissListener { + + private final DialogSyncBinding binding; + private final FragmentActivity activity; + private final AlertDialog dialog; + + public static SyncDialog create(FragmentActivity activity) { + return new SyncDialog(activity); + } + + public SyncDialog(FragmentActivity activity) { + this.activity = activity; + this.binding = DialogSyncBinding.inflate(LayoutInflater.from(activity)); + this.dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).create(); + } + + public void show() { + initDialog(); + initView(); + initEvent(); + } + + private void initDialog() { + WindowManager.LayoutParams params = dialog.getWindow().getAttributes(); + params.width = (int) (ResUtil.getScreenWidth() * 0.90f); + dialog.getWindow().setAttributes(params); + dialog.getWindow().setDimAmount(0); + dialog.show(); + } + + private void initView() { + binding.ftpServer.setText(Setting.getFtpUri()); + binding.ftpUsername.setText(Setting.getFtpUsername()); + binding.ftpPassword.setText(Setting.getFtpPassword()); + } + + private void initEvent() { + EventBus.getDefault().register(this); + binding.positive.setOnClickListener(this::onPositive); + binding.negative.setOnClickListener(this::onNegative); + } + + + private void onPositive(View view) { + Setting.putFtpPassword(binding.ftpPassword.getText().toString().trim()); + Setting.putFtpUsername(binding.ftpUsername.getText().toString().trim()); + Setting.putFtpUri(binding.ftpServer.getText().toString().trim()); + dialog.dismiss(); + } + + private void onNegative(View view) { + dialog.dismiss(); + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onServerEvent(ServerEvent event) { + if (event.getType() != ServerEvent.Type.SETTING) return; + } + + +} diff --git a/app/src/leanback/res/layout/activity_setting.xml b/app/src/leanback/res/layout/activity_setting.xml index e3322f736c..a3423ec839 100644 --- a/app/src/leanback/res/layout/activity_setting.xml +++ b/app/src/leanback/res/layout/activity_setting.xml @@ -510,5 +510,57 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/leanback/res/layout/dialog_sync.xml b/app/src/leanback/res/layout/dialog_sync.xml new file mode 100644 index 0000000000..ca79e97e41 --- /dev/null +++ b/app/src/leanback/res/layout/dialog_sync.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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 51e7bdba69..311e1822b9 100644 --- a/app/src/main/java/com/fongmi/android/tv/App.java +++ b/app/src/main/java/com/fongmi/android/tv/App.java @@ -7,6 +7,7 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -14,6 +15,7 @@ import com.fongmi.android.tv.api.config.LiveConfig; import com.fongmi.android.tv.ui.activity.CrashActivity; +import com.fongmi.android.tv.bean.HistorySyncManager; import com.fongmi.android.tv.utils.LanguageUtil; import com.fongmi.android.tv.utils.Notify; import com.github.catvod.Init; @@ -102,6 +104,25 @@ protected void attachBaseContext(Context base) { Init.set(base); } + private static class SyncTask extends android.os.AsyncTask { + @Override + protected Void doInBackground(Void... voids) { + try { + //HistorySyncManager syncManager = new HistorySyncManager("ftp://user:pass123@192.168.1.1:21/USB2T/(Documents)/(TVBox)/TV4/TV.json", "", "pass456"); + HistorySyncManager syncManager = new HistorySyncManager(Setting.getFtpUri(), Setting.getFtpUsername(), Setting.getFtpPassword()); + syncManager.syncAll(); + } catch (Exception e) { + Log.e("Sync", "Error during sync operation", e); + } + return null; + } + + @Override + protected void onPostExecute(Void result) { + Log.d("Sync", "Sync operation completed"); + } + } + @Override public void onCreate() { super.onCreate(); @@ -110,6 +131,9 @@ public void onCreate() { Logger.addLogAdapter(getLogAdapter()); OkHttp.get().setProxy(Setting.getProxy()); OkHttp.get().setDoh(Doh.objectFrom(Setting.getDoh())); + + new SyncTask().execute(); + CaocConfig.Builder.create().backgroundMode(CaocConfig.BACKGROUND_MODE_SILENT).errorActivity(CrashActivity.class).apply(); registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() { @Override @@ -146,7 +170,6 @@ public void onActivityDestroyed(@NonNull Activity activity) { public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) { } }); - } @Override 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 17105a4425..83b45f354e 100644 --- a/app/src/main/java/com/fongmi/android/tv/Setting.java +++ b/app/src/main/java/com/fongmi/android/tv/Setting.java @@ -542,4 +542,14 @@ public static void putThunderCacheDir(String dir) { Prefers.put("thunder_cache_dir", dir); } + public static void putFtpUri(String uri) { Prefers.put("ftpUri", uri); } + public static String getFtpUri() { return Prefers.getString("ftpUri"); } + + public static void putFtpUsername(String username) { Prefers.put("ftpUsername", username); } + public static String getFtpUsername() { return Prefers.getString("ftpUsername"); } + + public static void putFtpPassword(String password) {Prefers.put("ftpPassword", password); } + public static String getFtpPassword() { return Prefers.getString("ftpPassword"); } + + } diff --git a/app/src/main/java/com/fongmi/android/tv/bean/FtpManager.java b/app/src/main/java/com/fongmi/android/tv/bean/FtpManager.java new file mode 100644 index 0000000000..0cb8d03d32 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/bean/FtpManager.java @@ -0,0 +1,143 @@ +package com.fongmi.android.tv.bean; + +import org.apache.commons.net.ftp.FTP; +import org.apache.commons.net.ftp.FTPClient; +import org.apache.commons.net.ftp.FTPSClient; +import java.net.URI; +import java.net.URISyntaxException; +import java.io.*; +import java.nio.charset.StandardCharsets; + + + +public class FtpManager { + private String server; + private String path; + private int port; + private String username; + private String password; + private boolean useFTPS; + public boolean isServerReachable = false; + + public FtpManager(String server, String path, int port, String username, String password, boolean useFTPS) { + this.server = server; + this.path = path; + this.port = port; + this.username = username; + this.password = password; + this.useFTPS = useFTPS; + this.isServerReachable = !this.server.trim().isEmpty() || this.server != null || !this.path.trim().equalsIgnoreCase(""); + } + + public FtpManager(String ftpUrl, String username, String password) { + try { + this.username = username; + this.password = password; + parseUrl(ftpUrl); + this.isServerReachable = !this.server.trim().isEmpty() || this.server != null || !this.path.trim().equalsIgnoreCase(""); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } + + private void parseUrl(String ftpUrl) throws URISyntaxException { + URI uri = new URI(ftpUrl); + this.server = uri.getHost(); + this.path= uri.getPath(); + this.useFTPS = uri.getScheme().equalsIgnoreCase("ftps"); + this.port = (uri.getPort() == -1) ? useFTPS ? 990 : 21 : uri.getPort(); + + if (this.username.trim().isEmpty() || this.password.trim().isEmpty() || this.username.trim().equalsIgnoreCase("") || this.password.trim().equalsIgnoreCase("")) + { + if (uri.getUserInfo() != null) { + String[] userInfo = uri.getUserInfo().split(":"); + this.username = userInfo[0]; + this.password = (userInfo.length > 1) ? userInfo[1] : ""; + } else { + this.username = "anonymous"; + this.password = ""; + } + } + } + + private FTPClient connectToFTP() throws IOException { + if (!isServerReachable) { + throw new IOException("Server is not reachable."); + } + + FTPClient ftpClient = useFTPS ? new FTPSClient() : new FTPClient(); + ftpClient.connect(server, port); + + if (username != null && !username.isEmpty()) { + ftpClient.login(username, password); + } else { + ftpClient.login("anonymous", ""); + } + + ftpClient.setFileType(FTP.BINARY_FILE_TYPE); + ftpClient.enterLocalPassiveMode(); + return ftpClient; + } + + public String downloadJsonFileAsString(String remoteFilePath) throws IOException { + remoteFilePath = remoteFilePath==null? this.path : remoteFilePath; + + FTPClient ftpClient = connectToFTP(); + try { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + boolean success = ftpClient.retrieveFile(remoteFilePath, outputStream); + + if (!success) { + return null; + //throw new IOException("Failed to download the file: " + remoteFilePath); + } + return outputStream.toString(StandardCharsets.UTF_8.name()); + } finally { + if (ftpClient != null && ftpClient.isConnected()) { + try { + ftpClient.disconnect(); + } catch (IOException e) { + // Log the exception + } + } + } + } + + public void uploadJsonString(String jsonString, String remoteFilePath) throws IOException { + remoteFilePath = remoteFilePath==null? this.path : remoteFilePath; + + FTPClient ftpClient = connectToFTP(); + try { + createRemoteDirectories(ftpClient, remoteFilePath); + + // Convert the JSON string to an InputStream + InputStream inputStream = new ByteArrayInputStream(jsonString.getBytes(StandardCharsets.UTF_8)); + + boolean done = ftpClient.storeFile(remoteFilePath, inputStream); + inputStream.close(); + if (!done) { + throw new IOException("Failed to upload the file."); + } + } finally { + ftpClient.disconnect(); + } + } + + private void createRemoteDirectories(FTPClient ftpClient, String remoteFilePath) throws IOException { + String[] pathElements = remoteFilePath.split("/"); + String currentPath = ""; + + for (int i = 0; i < pathElements.length - 1; i++) { + if (!pathElements[i].isEmpty()) { + currentPath += "/" + pathElements[i]; + boolean dirExists = ftpClient.changeWorkingDirectory(currentPath); + if (!dirExists) { + boolean created = ftpClient.makeDirectory(currentPath); + if (!created) { + throw new IOException("Unable to create remote directory: " + currentPath); + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fongmi/android/tv/bean/History.java b/app/src/main/java/com/fongmi/android/tv/bean/History.java index 7f57f812c6..02203a82e7 100644 --- a/app/src/main/java/com/fongmi/android/tv/bean/History.java +++ b/app/src/main/java/com/fongmi/android/tv/bean/History.java @@ -16,8 +16,15 @@ import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TimeZone; @Entity public class History { @@ -58,6 +65,10 @@ public class History { private int scale; @SerializedName("cid") private int cid; + @SerializedName("lastUpdated") + private long lastUpdated = getCurrentUTCTime(); + @SerializedName("deleted") + private boolean deleted = false; public static History objectFrom(String str) { return App.gson().fromJson(str, History.class); @@ -75,6 +86,10 @@ public History() { this.player = -1; } + public static List getAll() { + return AppDatabase.get().getHistoryDao().getAll(); + } + @NonNull public String getKey() { return key; @@ -212,6 +227,22 @@ public void setCid(int cid) { this.cid = cid; } + public long getLastUpdated() { + return lastUpdated; + } + + public void setLastUpdated(long lastUpdated) { + this.lastUpdated = lastUpdated; + } + + public boolean isDeleted() { + return deleted; + } + + public void setDeleted(boolean deleted) { + this.deleted = deleted; + } + public String getSiteName() { return VodConfig.get().getSite(getSiteKey()).getName(); } @@ -300,9 +331,16 @@ public History save() { } public History delete() { - AppDatabase.get().getHistoryDao().delete(VodConfig.getCid(), getKey()); - AppDatabase.get().getTrackDao().delete(getKey()); + //soft delete + setDeleted(true); + setLastUpdated(System.currentTimeMillis()); + AppDatabase.get().getHistoryDao().insertOrUpdate(this); return this; + + //hard delete +// AppDatabase.get().getHistoryDao().delete(VodConfig.getCid(), getKey()); +// AppDatabase.get().getTrackDao().delete(getKey()); +// return this; } public List find() { @@ -353,6 +391,73 @@ public static void sync(List targets) { }); } + private static long getCurrentUTCTime() { + Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + return calendar.getTimeInMillis(); + } + + public static List syncLists(List list1, List list2) { + Map mergedMap = new HashMap<>(); + + // Process items from both lists + for (List list : Arrays.asList(list1, list2)) { + for (History item : list) { + String key = item.getKey(); + //if (!item.isDeleted() && item.lastUpdate.days - Datetime.days > xxx) { //TODO or noTodo: lastupdated is more xx days then remove from the mergedMap for hard delete + if (mergedMap.containsKey(key)) { + History existingItem = mergedMap.get(key); + if (item.isDeleted() || Objects.requireNonNull(existingItem).isDeleted()) { + item.setDeleted(true); + assert existingItem != null; + existingItem.setDeleted(true); + } + if (item.getLastUpdated() > existingItem.getLastUpdated()) { + updateAllColumns(existingItem, item); + } else if (item.getPosition() > existingItem.getPosition()) { + updateAllColumns(existingItem, item); + } + } else { + mergedMap.put(key, item); + } +// } else { +// // If item is marked as deleted, remove it from the merged map +// mergedMap.remove(key); +// } + } + } + + List result = new ArrayList<>(mergedMap.values()); + //insertOrUpdate(result); + return result; + } + + public static void insertOrUpdate(List items) { + AppDatabase.get().getHistoryDao().insertOrUpdate(items); + } + + private static void updateAllColumns(History existingItem, History newItem) { + existingItem.setVodPic(newItem.getVodPic()); + existingItem.setVodName(newItem.getVodName()); + existingItem.setVodFlag(newItem.getVodFlag()); + existingItem.setVodRemarks(newItem.getVodRemarks()); + existingItem.setEpisodeUrl(newItem.getEpisodeUrl()); + existingItem.setRevSort(newItem.isRevSort()); + existingItem.setRevPlay(newItem.isRevPlay()); + existingItem.setCreateTime(newItem.getCreateTime()); + existingItem.setOpening(newItem.getOpening()); + existingItem.setEnding(newItem.getEnding()); + existingItem.setPosition(newItem.getPosition()); + existingItem.setDuration(newItem.getDuration()); + existingItem.setSpeed(newItem.getSpeed()); + existingItem.setPlayer(newItem.getPlayer()); + existingItem.setScale(newItem.getScale()); + existingItem.setCid(newItem.getCid()); + existingItem.setLastUpdated(newItem.getLastUpdated()); + } + + + + @NonNull @Override public String toString() { diff --git a/app/src/main/java/com/fongmi/android/tv/bean/HistorySyncManager.java b/app/src/main/java/com/fongmi/android/tv/bean/HistorySyncManager.java new file mode 100644 index 0000000000..42e195fb4a --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/bean/HistorySyncManager.java @@ -0,0 +1,216 @@ +package com.fongmi.android.tv.bean; + +import android.util.Log; + +import com.fongmi.android.tv.App; +import com.fongmi.android.tv.api.config.VodConfig; +import com.fongmi.android.tv.bean.History; +import com.fongmi.android.tv.db.AppDatabase; + + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import com.fongmi.android.tv.db.dao.HistoryDao; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.reflect.TypeToken; + +import java.lang.reflect.Type; +import java.util.TimeZone; +import java.util.Calendar; + +public class HistorySyncManager { + + private static final String TAG = "HistorySyncManager"; + private static final String GIST_TOKEN = "xxxajfhfiejfbf"; + private static final String GIST_URL = "https://api.github.com/gists/YOUR_GIST_ID"; + private FtpManager ftpManager; + + public HistorySyncManager(FtpManager ftpManager) { + this.ftpManager = ftpManager; + } + +// public HistorySyncManager() { +// ftpManager = new FtpManager("192.168.1.1", 21, "hoanayang", "ilovebob123", false); +// } + + public HistorySyncManager(String uri, String username, String password) { + ftpManager = new FtpManager(uri, username, password); + } + + public void syncAll() { + String jsonData; + try { + if (!ftpManager.isServerReachable) + { + return; + } + + Gson gson = new Gson(); + jsonData = ftpManager.downloadJsonFileAsString(null);///USB2T/(Documents)/(TVBox)/TV2/TV.json"); + JsonObject jsonObject = jsonData==null? new JsonObject(): gson.fromJson(jsonData, JsonObject.class); + List ftpItems = jsonData==null? new ArrayList<>(): parseHistoryList(jsonData); + List sqliteItems =AppDatabase.get().getHistoryDao().getAll(); + List newMergedItems = History.syncLists(sqliteItems, ftpItems); + AppDatabase.get().runInTransaction(new Runnable() { + @Override + public void run() { + AppDatabase.get().getHistoryDao().insertOrUpdate(newMergedItems); + //TODO or NotToDo: delete hard, if lastupdated is more than xx days old, delete it from the database + //AppDatabase.get().getHistoryDao().deleteHard(); + } + }); + + jsonObject.add("History", gson.toJsonTree(newMergedItems)); + String updatedJsonString = gson.toJson(jsonObject); + //ftpManager.uploadJsonString(updatedJsonString, "/USB2T/(Documents)/(TVBox)/TV2/TV.json"); + ftpManager.uploadJsonString(updatedJsonString, null); + + } catch (IOException e) { + e.printStackTrace(); + } + } + + public List parseHistoryList(String jsonString) { + Gson gson = new GsonBuilder() + .registerTypeAdapter(History.class, new JsonDeserializer() { + @Override + public History deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + JsonObject jsonObject = json.getAsJsonObject(); + History history = new History(); + + history.setKey(jsonObject.get("key").getAsString()); + history.setVodPic(jsonObject.has("vodPic") ? jsonObject.get("vodPic").getAsString() : null); + history.setVodName(jsonObject.has("vodName") ? jsonObject.get("vodName").getAsString() : null); + history.setVodFlag(jsonObject.has("vodFlag") ? jsonObject.get("vodFlag").getAsString() : null); + history.setVodRemarks(jsonObject.has("vodRemarks") ? jsonObject.get("vodRemarks").getAsString() : null); + history.setEpisodeUrl(jsonObject.has("episodeUrl") ? jsonObject.get("episodeUrl").getAsString() : null); + history.setRevSort(jsonObject.has("revSort") && jsonObject.get("revSort").getAsBoolean()); + history.setRevPlay(jsonObject.has("revPlay") && jsonObject.get("revPlay").getAsBoolean()); + history.setCreateTime(jsonObject.has("createTime") ? jsonObject.get("createTime").getAsLong() : 0L); + history.setOpening(jsonObject.has("opening") ? jsonObject.get("opening").getAsLong() : 0L); + history.setEnding(jsonObject.has("ending") ? jsonObject.get("ending").getAsLong() : 0L); + history.setPosition(jsonObject.has("position") ? jsonObject.get("position").getAsLong() : 0L); + history.setDuration(jsonObject.has("duration") ? jsonObject.get("duration").getAsLong() : 0L); + history.setSpeed(jsonObject.has("speed") ? jsonObject.get("speed").getAsFloat() : 1.0f); + history.setPlayer(jsonObject.has("player") ? jsonObject.get("player").getAsInt() : 0); + history.setScale(jsonObject.has("scale") ? jsonObject.get("scale").getAsInt() : 0); + history.setCid(jsonObject.has("cid") ? jsonObject.get("cid").getAsInt() : -1); + + // Set default values for missing fields + history.setLastUpdated(jsonObject.has("lastUpdated") ? jsonObject.get("lastUpdated").getAsLong() : getCurrentUTCTime()); + history.setDeleted(jsonObject.has("deleted") && jsonObject.get("deleted").getAsBoolean()); + + return history; + } + }) + .create(); + + JsonObject jsonObject = gson.fromJson(jsonString, JsonObject.class); + String historyArrayString = jsonObject.getAsJsonArray("History").toString(); + Type listType = new TypeToken>(){}.getType(); + return gson.fromJson(historyArrayString, listType); + } + + private static long getCurrentUTCTime() { + Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + return calendar.getTimeInMillis(); + } + + private void updateSQLite(List items) { + AppDatabase.get().getHistoryDao().insertOrUpdate(items); + } + + private List getItemsFromGist() { + List items = new ArrayList<>(); + try { + URL url = new URL(GIST_URL); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Authorization", "token " + GIST_TOKEN); + conn.setRequestProperty("Accept", "application/vnd.github.v3+json"); + + if (conn.getResponseCode() != 200) { + throw new RuntimeException("Failed : HTTP error code : " + conn.getResponseCode()); + } + + BufferedReader br = new BufferedReader(new InputStreamReader((conn.getInputStream()))); + StringBuilder response = new StringBuilder(); + String output; + while ((output = br.readLine()) != null) { + response.append(output); + } + + JSONObject jsonObject = new JSONObject(response.toString()); + JSONObject files = jsonObject.getJSONObject("files"); + JSONObject tvJson = files.getJSONObject("tv.json"); + String content = tvJson.getString("content"); + + items = History.arrayFrom(content); + + conn.disconnect(); + } catch (Exception e) { + Log.e(TAG, "Error fetching data from Gist", e); + } + return items; + } + + + + private void updateGist(List items) { + try { + JSONObject contentJson = new JSONObject(); + JSONArray historyArray = new JSONArray(App.gson().toJson(items)); + contentJson.put("History", historyArray); + + JSONObject gistContent = new JSONObject(); + gistContent.put("content", contentJson.toString()); + + JSONObject files = new JSONObject(); + files.put("tv.json", gistContent); + + JSONObject requestBody = new JSONObject(); + requestBody.put("files", files); + + URL url = new URL(GIST_URL); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("PATCH"); + conn.setRequestProperty("Authorization", "token " + GIST_TOKEN); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setDoOutput(true); + + try (OutputStream os = conn.getOutputStream()) { + byte[] input = requestBody.toString().getBytes(StandardCharsets.UTF_8); + os.write(input, 0, input.length); + } + + if (conn.getResponseCode() != 200) { + throw new RuntimeException("Failed : HTTP error code : " + conn.getResponseCode()); + } + + conn.disconnect(); + } catch (Exception e) { + Log.e(TAG, "Error updating Gist", e); + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/fongmi/android/tv/db/AppDatabase.java b/app/src/main/java/com/fongmi/android/tv/db/AppDatabase.java index 2f1c548550..a3b22f8e08 100644 --- a/app/src/main/java/com/fongmi/android/tv/db/AppDatabase.java +++ b/app/src/main/java/com/fongmi/android/tv/db/AppDatabase.java @@ -39,7 +39,7 @@ @Database(entities = {Keep.class, Site.class, Live.class, Track.class, Config.class, Device.class, History.class, Download.class}, version = AppDatabase.VERSION) public abstract class AppDatabase extends RoomDatabase { - public static final int VERSION = 31; + public static final int VERSION = 32; public static final String NAME = "tv"; public static final String SYMBOL = "@@@"; public static final String BACKUP_SUFFIX = "tv.backup"; @@ -116,6 +116,7 @@ private static AppDatabase create(Context context) { .addMigrations(MIGRATION_28_29) .addMigrations(MIGRATION_29_30) .addMigrations(MIGRATION_30_31) + .addMigrations(MIGRATION_31_32) .allowMainThreadQueries().fallbackToDestructiveMigration().build(); } @@ -287,4 +288,12 @@ public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("CREATE TABLE Download (`id` TEXT NOT NULL, vodPic TEXT, vodName TEXT, url TEXT, header TEXT, createTime INTEGER NOT NULL, PRIMARY KEY (`id`))"); } }; + + static final Migration MIGRATION_31_32 = new Migration(31, 32) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE History ADD COLUMN lastUpdated INTEGER DEFAULT (strftime('%s', 'now'))"); + database.execSQL("ALTER TABLE History ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0"); + } + }; } diff --git a/app/src/main/java/com/fongmi/android/tv/db/dao/HistoryDao.java b/app/src/main/java/com/fongmi/android/tv/db/dao/HistoryDao.java index e4be94103d..c21292833f 100644 --- a/app/src/main/java/com/fongmi/android/tv/db/dao/HistoryDao.java +++ b/app/src/main/java/com/fongmi/android/tv/db/dao/HistoryDao.java @@ -2,6 +2,7 @@ import androidx.room.Dao; import androidx.room.Query; +import androidx.room.Transaction; import com.fongmi.android.tv.bean.History; @@ -10,7 +11,7 @@ @Dao public abstract class HistoryDao extends BaseDao { - @Query("SELECT * FROM History WHERE cid = :cid ORDER BY createTime DESC") + @Query("SELECT * FROM History WHERE deleted = 0 AND cid = :cid ORDER BY createTime DESC") public abstract List find(int cid); @Query("SELECT * FROM History WHERE cid = :cid AND `key` = :key") @@ -19,12 +20,35 @@ public abstract class HistoryDao extends BaseDao { @Query("SELECT * FROM History WHERE cid = :cid AND vodName = :vodName") public abstract List findByName(int cid, String vodName); - @Query("DELETE FROM History WHERE cid = :cid AND `key` = :key") + //Soft Deleted to sync with other devices to set as delete + @Query("UPDATE History SET deleted=1 WHERE cid = :cid AND `key` = :key") public abstract void delete(int cid, String key); + + //hard Deleted from database, TODO or NotTodo: delete rows which has deleted flag on for more than xx days + @Query("DELETE FROM History WHERE cid = :cid AND `key` = :key") + public abstract void deleteHard(int cid, String key); + + @Query("DELETE FROM History WHERE cid = :cid") public abstract void delete(int cid); @Query("DELETE FROM History") public abstract void delete(); + + @Query("SELECT * FROM History ORDER BY createTime DESC") + public abstract List getAll(); + + @Transaction + public void insertOrUpdateAll(List histories) { + for (History history : histories) { + History existing = find(history.getCid(), history.getKey()); + if (existing != null) { + update(history); + } else { + insert(history); + } + } + } + } diff --git a/app/src/main/java/com/fongmi/android/tv/impl/SyncCallback.java b/app/src/main/java/com/fongmi/android/tv/impl/SyncCallback.java new file mode 100644 index 0000000000..583f7cb49f --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/impl/SyncCallback.java @@ -0,0 +1,6 @@ +package com.fongmi.android.tv.impl; + +public interface SyncCallback { + + void setSync(); +} diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index ae77272b30..6753e3de3a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -165,6 +165,26 @@ 正在检测更新… 更新 + + 同步设定 + 同步 + 1. FTP/FTPS + ftps://ServerName/FolerName/TV.json + FTP Username + FTP Password + 5. Samba主机路径 + smb://ServerName/FolerName/TV.json + Samba Username + Samba Password + 2. Gist + Samba Password + 3. Google Drive + Google Drive Token + 4. Webdav + 4. Webdav Username + 4. Webdav Password + + X5 WebView 解压X5 WebView并启用? diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 8a1a4fcb92..93c764f9ae 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -166,6 +166,26 @@ 正在檢查更新… 更新 + + 同步設定 + 同步 + 1. FTP/FTPS + ftps://ServerName/FolerName/TV.json + FTP Username + FTP Password + 5. Samba主機路徑 + smb://ServerName/FolerName/TV.json + Samba Username + Samba Password + 2. Gist + Samba Password + 3. Google Drive + Google Drive Token + 4. Webdav + 4. Webdav Username + 4. Webdav Password + + X5 WebView 解壓X5 WebView並啟用? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index da7b4e76a2..853f665fea 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -166,6 +166,26 @@ Checking for updates… Update + + Sync Setting + Sync + 1. FTP/FTPS + ftps://ServerName/FolerName/TV.json + FTP Username + FTP Password + 5. Samba Location + smb://ServerName/FolerName/TV.json + Samba Username + Samba Password + 2. Gist + Samba Password + 3. Google Drive + Google Drive Token + 4. Webdav + 4. Webdav Username + 4. Webdav Password + + X5 WebView Unzip X5 WebView and Enable? diff --git a/app/src/mobile/res/layout/fragment_setting.xml b/app/src/mobile/res/layout/fragment_setting.xml index 5cea59f79f..70176a7857 100644 --- a/app/src/mobile/res/layout/fragment_setting.xml +++ b/app/src/mobile/res/layout/fragment_setting.xml @@ -456,6 +456,24 @@ tools:text="about" /> + + + + + + \ No newline at end of file