diff --git a/V2rayNG/app/build.gradle.kts b/V2rayNG/app/build.gradle.kts
index bd14300d..8612a5dd 100644
--- a/V2rayNG/app/build.gradle.kts
+++ b/V2rayNG/app/build.gradle.kts
@@ -158,6 +158,7 @@ dependencies {
// Data and Storage Libraries
implementation(libs.mmkv.static)
implementation(libs.gson)
+ implementation(libs.okhttp)
// Reactive and Utility Libraries
implementation(libs.kotlinx.coroutines.android)
diff --git a/V2rayNG/app/src/main/AndroidManifest.xml b/V2rayNG/app/src/main/AndroidManifest.xml
index f25602a9..859b3faf 100644
--- a/V2rayNG/app/src/main/AndroidManifest.xml
+++ b/V2rayNG/app/src/main/AndroidManifest.xml
@@ -146,6 +146,9 @@
+
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/WebDavConfig.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/WebDavConfig.kt
new file mode 100644
index 00000000..f8f06dbd
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/WebDavConfig.kt
@@ -0,0 +1,9 @@
+package com.v2ray.ang.dto
+
+data class WebDavConfig(
+ val baseUrl: String,
+ val username: String? = null,
+ val password: String? = null,
+ val remoteBasePath: String = "/",
+ val timeoutSeconds: Long = 30
+)
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt
index ef3347ab..f06c31ac 100644
--- a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt
@@ -4,6 +4,7 @@ import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
import com.v2ray.ang.dto.AssetUrlItem
+import com.v2ray.ang.dto.WebDavConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.ServerAffiliationInfo
@@ -27,6 +28,7 @@ object MmkvManager {
private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
private const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
private const val KEY_SUB_IDS = "SUB_IDS"
+ private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
//private val profileStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_CONFIG, MMKV.MULTI_PROCESS_MODE) }
private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
@@ -468,6 +470,7 @@ object MmkvManager {
//endregion
+ //region settings
/**
* Encodes the settings.
*
@@ -588,4 +591,22 @@ object MmkvManager {
//endregion
+ //region WebDAV
+
+ /**
+ * Encodes the WebDAV config as JSON into settings storage.
+ */
+ fun encodeWebDavConfig(config: WebDavConfig): Boolean {
+ return settingsStorage.encode(KEY_WEBDAV_CONFIG, JsonUtil.toJson(config))
+ }
+
+ /**
+ * Decodes the WebDAV config from settings storage.
+ */
+ fun decodeWebDavConfig(): WebDavConfig? {
+ val json = settingsStorage.decodeString(KEY_WEBDAV_CONFIG) ?: return null
+ return JsonUtil.fromJson(json, WebDavConfig::class.java)
+ }
+
+ //endregion
}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/WebDavManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/WebDavManager.kt
new file mode 100644
index 00000000..b71d42ee
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/WebDavManager.kt
@@ -0,0 +1,187 @@
+package com.v2ray.ang.handler
+
+import android.util.Log
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.dto.WebDavConfig
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.Credentials
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.asRequestBody
+import java.io.File
+import java.io.FileOutputStream
+import java.net.URL
+import java.util.concurrent.TimeUnit
+
+object WebDavManager {
+ private var cfg: WebDavConfig? = null
+ private var client: OkHttpClient? = null
+
+ /**
+ * Initialize the WebDAV manager with a configuration and build an OkHttp client.
+ *
+ * @param config WebDavConfig containing baseUrl, credentials, remoteBasePath and timeoutSeconds.
+ */
+ fun init(config: WebDavConfig) {
+ cfg = config
+ client = OkHttpClient.Builder()
+ .connectTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
+ .readTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
+ .writeTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
+ .callTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
+ .build()
+ }
+
+ /**
+ * Upload a local file to a remote relative path under the configured remoteBasePath.
+ * The provided `remoteRelativePath` should be relative (e.g. "backup_ng.zip").
+ * The method will attempt to create parent directories via MKCOL before PUT.
+ *
+ * @param localFile File to upload.
+ * @param remoteRelativePath Remote path relative to configured remoteBasePath.
+ * @return true if upload succeeded (HTTP 2xx), false otherwise.
+ */
+ suspend fun uploadFile(localFile: File, remoteRelativePath: String): Boolean = withContext(Dispatchers.IO) {
+ try {
+ val cl = client ?: return@withContext false
+ val remote = buildRemoteUrl(remoteRelativePath)
+
+ // Ensure parent directories exist
+ val dirPath = remote.substringBeforeLast('/')
+ if (dirPath != remote) {
+ ensureRemoteDirs(dirPath)
+ }
+
+ // Determine content type based on file extension
+ val mediaType = when (localFile.extension.lowercase()) {
+ "zip" -> "application/zip"
+ "json" -> "application/json"
+ "txt" -> "text/plain"
+ else -> "application/octet-stream"
+ }.toMediaTypeOrNull()
+
+ val body = localFile.asRequestBody(mediaType)
+ val req = applyAuth(Request.Builder().url(remote).put(body)).build()
+ cl.newCall(req).execute().use { resp ->
+ val success = resp.isSuccessful
+ if (success) {
+ Log.i(AppConfig.TAG, "WebDAV upload success: $remoteRelativePath")
+ } else {
+ Log.e(AppConfig.TAG, "WebDAV upload failed: $remoteRelativePath (HTTP ${resp.code})")
+ }
+ return@withContext success
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "WebDAV upload exception: $remoteRelativePath", e)
+ return@withContext false
+ }
+ }
+
+ /**
+ * Download a remote file (relative to configured remoteBasePath) into a local file.
+ *
+ * @param remoteRelativePath Remote path relative to configured remoteBasePath.
+ * @param destFile Local destination file to write to.
+ * @return true if download and write succeeded, false otherwise.
+ */
+ suspend fun downloadFile(remoteRelativePath: String, destFile: File): Boolean = withContext(Dispatchers.IO) {
+ try {
+ val cl = client ?: return@withContext false
+ val remote = buildRemoteUrl(remoteRelativePath)
+ val req = applyAuth(Request.Builder().url(remote).get()).build()
+ cl.newCall(req).execute().use { resp ->
+ if (!resp.isSuccessful) {
+ Log.e(AppConfig.TAG, "WebDAV download failed: $remoteRelativePath (HTTP ${resp.code})")
+ return@withContext false
+ }
+
+ val body = resp.body
+ if (body == null) {
+ Log.e(AppConfig.TAG, "WebDAV download failed: $remoteRelativePath (empty response body)")
+ return@withContext false
+ }
+
+ body.byteStream().use { input ->
+ destFile.parentFile?.mkdirs()
+ FileOutputStream(destFile).use { fos ->
+ input.copyTo(fos)
+ }
+ }
+
+ Log.i(AppConfig.TAG, "WebDAV download success: $remoteRelativePath")
+ return@withContext true
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "WebDAV download exception: $remoteRelativePath", e)
+ return@withContext false
+ }
+ }
+
+ /**
+ * Build a full remote URL by combining the configured base URL, the configured
+ * remote base path and a relative path provided by the caller.
+ *
+ * Example: baseUrl="https://example.com/remote.php/dav", remoteBasePath="backups",
+ * remoteRelativePath="backup_ng.zip" => "https://example.com/remote.php/dav/backups/backup_ng.zip"
+ *
+ * @param remoteRelativePath A path relative to the configured remoteBasePath (no leading slash required).
+ * @return Full URL string used for HTTP operations.
+ */
+ private fun buildRemoteUrl(remoteRelativePath: String): String {
+ val base = cfg?.baseUrl?.trimEnd('/') ?: ""
+ val basePath = cfg?.remoteBasePath?.trim('/') ?: ""
+ val rel = remoteRelativePath.trimStart('/')
+ return if (basePath.isEmpty()) "$base/$rel" else "$base/$basePath/$rel"
+ }
+
+ /**
+ * Apply HTTP Basic authentication headers to the given request builder when
+ * username is configured in `cfg`.
+ *
+ * @param builder OkHttp Request.Builder to modify.
+ * @return The same builder instance with Authorization header applied if credentials exist.
+ */
+ private fun applyAuth(builder: Request.Builder): Request.Builder {
+ val username = cfg?.username
+ val password = cfg?.password
+ if (!username.isNullOrEmpty()) {
+ builder.header("Authorization", Credentials.basic(username, password ?: ""))
+ }
+ return builder
+ }
+
+ /**
+ * Ensure that each directory segment in the given directory URL exists on the
+ * WebDAV server. This issues MKCOL requests for each segment in a best-effort
+ * manner and ignores errors for segments that already exist.
+ *
+ * @param dirUrl Absolute URL to the directory that should exist (e.g. https://.../backups)
+ */
+ private fun ensureRemoteDirs(dirUrl: String) {
+ try {
+ val cl = client ?: return
+ val url = URL(dirUrl)
+ val segments = url.path.split("/").filter { it.isNotEmpty() }
+ var accum = ""
+ for (seg in segments) {
+ accum += "/$seg"
+ val mkUrl = URL(url.protocol, url.host, if (url.port == -1) -1 else url.port, accum).toString()
+ try {
+ val req = applyAuth(Request.Builder().url(mkUrl).method("MKCOL", null)).build()
+ cl.newCall(req).execute().use { resp ->
+ // 201 Created or 405 Method Not Allowed (already exists) are acceptable
+ if (resp.code != 201 && resp.code != 405 && resp.code != 409) {
+ Log.w(AppConfig.TAG, "WebDAV MKCOL $mkUrl returned ${resp.code}")
+ }
+ }
+ } catch (ignored: Exception) {
+ // best-effort, continue
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "WebDAV ensureRemoteDirs error", e)
+ }
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt
index 32180711..e3400d69 100644
--- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt
@@ -1,126 +1,22 @@
package com.v2ray.ang.ui
-import android.Manifest
-import android.content.Intent
-import android.os.Build
import android.os.Bundle
-import android.util.Log
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.core.content.ContextCompat
-import androidx.core.content.FileProvider
-import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityAboutBinding
-import com.v2ray.ang.extension.toast
-import com.v2ray.ang.extension.toastError
-import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.SpeedtestManager
import com.v2ray.ang.util.Utils
-import com.v2ray.ang.util.ZipUtil
-import java.io.File
-import java.text.SimpleDateFormat
-import java.util.Locale
class AboutActivity : BaseActivity() {
-
private val binding by lazy { ActivityAboutBinding.inflate(layoutInflater) }
- private val requestPermissionLauncher =
- registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
- if (isGranted) {
- try {
- showFileChooser()
- } catch (e: Exception) {
- Log.e(AppConfig.TAG, "Failed to show file chooser", e)
- }
- } else {
- toast(R.string.toast_permission_denied)
- }
- }
-
- private val createBackupFile =
- registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
- if (uri != null) {
- try {
- val ret = backupConfigurationToCache()
- if (ret.first) {
- // Copy the cached zip file to user-selected location
- contentResolver.openOutputStream(uri)?.use { output ->
- File(ret.second).inputStream().use { input ->
- input.copyTo(output)
- }
- }
- // Clean up cache file
- File(ret.second).delete()
- toastSuccess(R.string.toast_success)
- } else {
- toastError(R.string.toast_failure)
- }
- } catch (e: Exception) {
- Log.e(AppConfig.TAG, "Failed to backup configuration", e)
- toastError(R.string.toast_failure)
- }
- }
- }
-
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
title = getString(R.string.title_about)
- binding.layoutBackup.setOnClickListener {
- val dateFormatted = SimpleDateFormat(
- "yyyy-MM-dd-HH-mm-ss",
- Locale.getDefault()
- ).format(System.currentTimeMillis())
- val defaultFileName = "${getString(R.string.app_name)}_${dateFormatted}.zip"
-
- // Let user choose where to save the backup file
- createBackupFile.launch(defaultFileName)
- }
-
- binding.layoutShare.setOnClickListener {
- val ret = backupConfigurationToCache()
- if (ret.first) {
- startActivity(
- Intent.createChooser(
- Intent(Intent.ACTION_SEND).setType("application/zip")
- .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- .putExtra(
- Intent.EXTRA_STREAM,
- FileProvider.getUriForFile(
- this, BuildConfig.APPLICATION_ID + ".cache", File(ret.second)
- )
- ), getString(R.string.title_configuration_share)
- )
- )
- } else {
- toastError(R.string.toast_failure)
- }
- }
-
- binding.layoutRestore.setOnClickListener {
- val permission =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- Manifest.permission.READ_MEDIA_IMAGES
- } else {
- Manifest.permission.READ_EXTERNAL_STORAGE
- }
-
- if (ContextCompat.checkSelfPermission(this, permission) == android.content.pm.PackageManager.PERMISSION_GRANTED) {
- try {
- showFileChooser()
- } catch (e: Exception) {
- Log.e(AppConfig.TAG, "Failed to show file chooser", e)
- }
- } else {
- requestPermissionLauncher.launch(permission)
- }
- }
-
binding.layoutSoureCcode.setOnClickListener {
Utils.openUri(this, AppConfig.APP_URL)
}
@@ -154,78 +50,4 @@ class AboutActivity : BaseActivity() {
binding.tvAppId.text = it
}
}
-
- /**
- * Backup configuration to cache directory
- * Returns Pair
- */
- private fun backupConfigurationToCache(): Pair {
- val dateFormatted = SimpleDateFormat(
- "yyyy-MM-dd-HH-mm-ss",
- Locale.getDefault()
- ).format(System.currentTimeMillis())
- val folderName = "${getString(R.string.app_name)}_${dateFormatted}"
- val backupDir = this.cacheDir.absolutePath + "/$folderName"
- val outputZipFilePath = "${this.cacheDir.absolutePath}/$folderName.zip"
-
- val count = MMKV.backupAllToDirectory(backupDir)
- if (count <= 0) {
- return Pair(false, "")
- }
-
- if (ZipUtil.zipFromFolder(backupDir, outputZipFilePath)) {
- return Pair(true, outputZipFilePath)
- } else {
- return Pair(false, "")
- }
- }
-
- private fun restoreConfiguration(zipFile: File): Boolean {
- val backupDir = this.cacheDir.absolutePath + "/${System.currentTimeMillis()}"
-
- if (!ZipUtil.unzipToFolder(zipFile, backupDir)) {
- return false
- }
-
- val count = MMKV.restoreAllFromDirectory(backupDir)
- return count > 0
- }
-
- private fun showFileChooser() {
- val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
- type = "*/*"
- addCategory(Intent.CATEGORY_OPENABLE)
- }
-
- try {
- chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
- } catch (ex: android.content.ActivityNotFoundException) {
- Log.e(AppConfig.TAG, "File chooser activity not found", ex)
- toast(R.string.toast_require_file_manager)
- }
- }
-
- private val chooseFile =
- registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
- val uri = result.data?.data
- if (result.resultCode == RESULT_OK && uri != null) {
- try {
- val targetFile =
- File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip")
- contentResolver.openInputStream(uri).use { input ->
- targetFile.outputStream().use { fileOut ->
- input?.copyTo(fileOut)
- }
- }
- if (restoreConfiguration(targetFile)) {
- toastSuccess(R.string.toast_success)
- } else {
- toastError(R.string.toast_failure)
- }
- } catch (e: Exception) {
- Log.e(AppConfig.TAG, "Error during file restore", e)
- toastError(R.string.toast_failure)
- }
- }
- }
}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BackupActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BackupActivity.kt
new file mode 100644
index 00000000..5221fbb5
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BackupActivity.kt
@@ -0,0 +1,361 @@
+package com.v2ray.ang.ui
+
+import android.Manifest
+import android.app.AlertDialog
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.BuildConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.extension.toastError
+import com.v2ray.ang.extension.toastSuccess
+import com.v2ray.ang.handler.WebDavManager
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.dto.WebDavConfig
+import androidx.lifecycle.lifecycleScope
+import com.v2ray.ang.databinding.ActivityBackupBinding
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import com.v2ray.ang.databinding.DialogWebdavBinding
+import com.v2ray.ang.util.ZipUtil
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+class BackupActivity : BaseActivity() {
+ private val binding by lazy { ActivityBackupBinding.inflate(layoutInflater) }
+
+ private val config_backup_options: Array by lazy {
+ resources.getStringArray(R.array.config_backup_options)
+ }
+
+ companion object {
+ private const val BACKUP_FILE_NAME = "backup_ng.zip"
+ }
+
+ private val requestPermissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
+ if (isGranted) {
+ try {
+ showFileChooser()
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to show file chooser", e)
+ }
+ } else {
+ toast(R.string.toast_permission_denied)
+ }
+ }
+
+ private val createBackupFile =
+ registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
+ if (uri != null) {
+ try {
+ val ret = backupConfigurationToCache()
+ if (ret.first) {
+ // Copy the cached zip file to user-selected location
+ contentResolver.openOutputStream(uri)?.use { output ->
+ File(ret.second).inputStream().use { input ->
+ input.copyTo(output)
+ }
+ }
+ // Clean up cache file
+ File(ret.second).delete()
+ toastSuccess(R.string.toast_success)
+ } else {
+ toastError(R.string.toast_failure)
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to backup configuration", e)
+ toastError(R.string.toast_failure)
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+
+ title = getString(R.string.title_configuration_backup_restore)
+
+ binding.layoutBackup.setOnClickListener {
+ AlertDialog.Builder(this)
+ .setTitle(R.string.title_configuration_backup)
+ .setItems(config_backup_options) { dialog, which ->
+ when (which) {
+ 0 -> backupViaLocal()
+ 1 -> backupViaWebDav()
+ }
+ }
+ .show()
+ }
+
+ binding.layoutShare.setOnClickListener {
+ val ret = backupConfigurationToCache()
+ if (ret.first) {
+ startActivity(
+ Intent.createChooser(
+ Intent(Intent.ACTION_SEND).setType("application/zip")
+ .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ .putExtra(
+ Intent.EXTRA_STREAM,
+ FileProvider.getUriForFile(
+ this, BuildConfig.APPLICATION_ID + ".cache", File(ret.second)
+ )
+ ), getString(R.string.title_configuration_share)
+ )
+ )
+ } else {
+ toastError(R.string.toast_failure)
+ }
+ }
+
+ binding.layoutRestore.setOnClickListener {
+ AlertDialog.Builder(this)
+ .setTitle(R.string.title_configuration_restore)
+ .setItems(config_backup_options) { dialog, which ->
+ when (which) {
+ 0 -> restoreViaLocal()
+ 1 -> restoreViaWebDav()
+ }
+ }
+ .show()
+ }
+
+ binding.layoutWebdavConfigSetting.setOnClickListener {
+ showWebDavSettingsDialog()
+ }
+ }
+
+ /**
+ * Backup configuration to cache directory
+ * Returns Pair
+ */
+ private fun backupConfigurationToCache(): Pair {
+ val dateFormatted = SimpleDateFormat(
+ "yyyy-MM-dd-HH-mm-ss",
+ Locale.getDefault()
+ ).format(System.currentTimeMillis())
+ val folderName = "${getString(R.string.app_name)}_${dateFormatted}"
+ val backupDir = this.cacheDir.absolutePath + "/$folderName"
+ val outputZipFilePath = "${this.cacheDir.absolutePath}/$folderName.zip"
+
+ val count = MMKV.backupAllToDirectory(backupDir)
+ if (count <= 0) {
+ return Pair(false, "")
+ }
+
+ if (ZipUtil.zipFromFolder(backupDir, outputZipFilePath)) {
+ return Pair(true, outputZipFilePath)
+ } else {
+ return Pair(false, "")
+ }
+ }
+
+ private fun restoreConfiguration(zipFile: File): Boolean {
+ val backupDir = this.cacheDir.absolutePath + "/${System.currentTimeMillis()}"
+
+ if (!ZipUtil.unzipToFolder(zipFile, backupDir)) {
+ return false
+ }
+
+ val count = MMKV.restoreAllFromDirectory(backupDir)
+ return count > 0
+ }
+
+ private fun showFileChooser() {
+ val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
+ type = "*/*"
+ addCategory(Intent.CATEGORY_OPENABLE)
+ }
+
+ try {
+ chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
+ } catch (ex: ActivityNotFoundException) {
+ Log.e(AppConfig.TAG, "File chooser activity not found", ex)
+ toast(R.string.toast_require_file_manager)
+ }
+ }
+
+ private val chooseFile =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ val uri = result.data?.data
+ if (result.resultCode == RESULT_OK && uri != null) {
+ try {
+ val targetFile =
+ File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip")
+ contentResolver.openInputStream(uri).use { input ->
+ targetFile.outputStream().use { fileOut ->
+ input?.copyTo(fileOut)
+ }
+ }
+ if (restoreConfiguration(targetFile)) {
+ toastSuccess(R.string.toast_success)
+ } else {
+ toastError(R.string.toast_failure)
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Error during file restore", e)
+ toastError(R.string.toast_failure)
+ }
+ }
+ }
+
+ private fun backupViaLocal() {
+ val dateFormatted = SimpleDateFormat(
+ "yyyy-MM-dd-HH-mm-ss",
+ Locale.getDefault()
+ ).format(System.currentTimeMillis())
+ val defaultFileName = "${getString(R.string.app_name)}_${dateFormatted}.zip"
+ createBackupFile.launch(defaultFileName)
+ }
+
+ private fun restoreViaLocal() {
+ val permission =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ Manifest.permission.READ_MEDIA_IMAGES
+ } else {
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ }
+ if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
+ try {
+ showFileChooser()
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to show file chooser", e)
+ }
+ } else {
+ requestPermissionLauncher.launch(permission)
+ }
+ }
+
+ private fun backupViaWebDav() {
+ val saved = MmkvManager.decodeWebDavConfig()
+ if (saved == null || saved.baseUrl.isEmpty()) {
+ toastError(R.string.title_webdav_config_setting_unknown)
+ return
+ }
+
+ binding.pbWaiting.show()
+
+ lifecycleScope.launch(Dispatchers.IO) {
+ var tempFile: File? = null
+ try {
+ val ret = backupConfigurationToCache()
+ if (!ret.first) {
+ withContext(Dispatchers.Main) {
+ toastError(R.string.toast_failure)
+ }
+ return@launch
+ }
+
+ tempFile = File(ret.second)
+ WebDavManager.init(saved)
+
+ val ok = try {
+ WebDavManager.uploadFile(tempFile, BACKUP_FILE_NAME)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "WebDAV upload error", e)
+ false
+ }
+
+ withContext(Dispatchers.Main) {
+ if (ok) toastSuccess(R.string.toast_success) else toastError(R.string.toast_failure)
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "WebDAV backup error", e)
+ withContext(Dispatchers.Main) {
+ toastError(R.string.toast_failure)
+ }
+ } finally {
+ try {
+ tempFile?.delete()
+ } catch (_: Exception) {
+ }
+ withContext(Dispatchers.Main) {
+ binding.pbWaiting.hide()
+ }
+ }
+ }
+ }
+
+ private fun restoreViaWebDav() {
+ val saved = MmkvManager.decodeWebDavConfig()
+ if (saved == null || saved.baseUrl.isEmpty()) {
+ toastError(R.string.title_webdav_config_setting_unknown)
+ return
+ }
+
+ binding.pbWaiting.show()
+
+ lifecycleScope.launch(Dispatchers.IO) {
+ var target: File? = null
+ try {
+ target = File(cacheDir, "download_${System.currentTimeMillis()}.zip")
+ WebDavManager.init(saved)
+ val ok = WebDavManager.downloadFile(BACKUP_FILE_NAME, target)
+ if (!ok) {
+ withContext(Dispatchers.Main) {
+ toastError(R.string.toast_failure)
+ }
+ return@launch
+ }
+
+ val restored = restoreConfiguration(target)
+ withContext(Dispatchers.Main) {
+ if (restored) {
+ setResult(RESULT_OK, Intent().putExtra("config_restored", true))
+ toastSuccess(R.string.toast_success)
+ } else {
+ toastError(R.string.toast_failure)
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "WebDAV download error", e)
+ withContext(Dispatchers.Main) { toastError(R.string.toast_failure) }
+ } finally {
+ try {
+ target?.delete()
+ } catch (_: Exception) {
+ }
+ withContext(Dispatchers.Main) {
+ binding.pbWaiting.hide()
+ }
+ }
+ }
+ }
+
+ private fun showWebDavSettingsDialog() {
+ val dialogBinding = DialogWebdavBinding.inflate(layoutInflater)
+
+ MmkvManager.decodeWebDavConfig()?.let { cfg ->
+ dialogBinding.etWebdavUrl.setText(cfg.baseUrl)
+ dialogBinding.etWebdavUser.setText(cfg.username ?: "")
+ dialogBinding.etWebdavPass.setText(cfg.password ?: "")
+ dialogBinding.etWebdavRemotePath.setText(cfg.remoteBasePath ?: "/")
+ }
+
+ AlertDialog.Builder(this)
+ .setTitle(R.string.title_webdav_config_setting)
+ .setView(dialogBinding.root)
+ .setPositiveButton(R.string.menu_item_save_config) { _, _ ->
+ val url = dialogBinding.etWebdavUrl.text.toString().trim()
+ val user = dialogBinding.etWebdavUser.text.toString().trim().ifEmpty { null }
+ val pass = dialogBinding.etWebdavPass.text.toString()
+ val remotePath = dialogBinding.etWebdavRemotePath.text.toString().trim().ifEmpty { "/" }
+ val cfg = WebDavConfig(baseUrl = url, username = user, password = pass, remoteBasePath = remotePath)
+ MmkvManager.encodeWebDavConfig(cfg)
+ toastSuccess(R.string.toast_success)
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt
index 51d6c96f..c0e69452 100644
--- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt
@@ -56,7 +56,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
startV2Ray()
}
}
- private val requestSubSettingActivity = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ private val requestActivityLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
initGroupTab()
}
private val tabGroupListener = object : TabLayout.OnTabSelectedListener {
@@ -672,9 +672,9 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
override fun onNavigationItemSelected(item: MenuItem): Boolean {
// Handle navigation view item clicks here.
when (item.itemId) {
- R.id.sub_setting -> requestSubSettingActivity.launch(Intent(this, SubSettingActivity::class.java))
+ R.id.sub_setting -> requestActivityLauncher.launch(Intent(this, SubSettingActivity::class.java))
R.id.per_app_proxy_settings -> startActivity(Intent(this, PerAppProxyActivity::class.java))
- R.id.routing_setting -> requestSubSettingActivity.launch(Intent(this, RoutingSettingActivity::class.java))
+ R.id.routing_setting -> requestActivityLauncher.launch(Intent(this, RoutingSettingActivity::class.java))
R.id.user_asset_setting -> startActivity(Intent(this, UserAssetActivity::class.java))
R.id.settings -> startActivity(
Intent(this, SettingsActivity::class.java)
@@ -684,6 +684,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
R.id.promotion -> Utils.openUri(this, "${Utils.decode(AppConfig.APP_PROMOTION_URL)}?t=${System.currentTimeMillis()}")
R.id.logcat -> startActivity(Intent(this, LogcatActivity::class.java))
R.id.check_for_update -> startActivity(Intent(this, CheckUpdateActivity::class.java))
+ R.id.backup_restore -> requestActivityLauncher.launch(Intent(this, BackupActivity::class.java))
R.id.about -> startActivity(Intent(this, AboutActivity::class.java))
}
diff --git a/V2rayNG/app/src/main/res/layout/activity_about.xml b/V2rayNG/app/src/main/res/layout/activity_about.xml
index 2eddfe0e..a1a7c840 100644
--- a/V2rayNG/app/src/main/res/layout/activity_about.xml
+++ b/V2rayNG/app/src/main/res/layout/activity_about.xml
@@ -9,99 +9,11 @@
android:layout_height="wrap_content"
android:orientation="vertical">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:orientation="vertical">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/V2rayNG/app/src/main/res/layout/dialog_webdav.xml b/V2rayNG/app/src/main/res/layout/dialog_webdav.xml
new file mode 100644
index 00000000..f244702b
--- /dev/null
+++ b/V2rayNG/app/src/main/res/layout/dialog_webdav.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/res/menu/menu_drawer.xml b/V2rayNG/app/src/main/res/menu/menu_drawer.xml
index 4e204e62..379bfddb 100644
--- a/V2rayNG/app/src/main/res/menu/menu_drawer.xml
+++ b/V2rayNG/app/src/main/res/menu/menu_drawer.xml
@@ -39,6 +39,10 @@
android:id="@+id/check_for_update"
android:icon="@drawable/ic_check_update_24dp"
android:title="@string/update_check_for_update" />
+
- الكود المصدري
Open Source licenses
قناة Telegram
- نسخ التكوين احتياطيًا
- استعادة التكوين
- مشاركة التكوين
ترويج
@@ -339,6 +336,18 @@
From subscription group
Remarks regular filter
+
+ Backup & Restore
+ نسخ التكوين احتياطيًا
+ استعادة التكوين
+ مشاركة التكوين
+ WebDAV Settings
+ Please configure WebDAV first.
+ WebDAV server URL
+ Username
+ Password
+ Remote directory (optional)
+
- رمز استجابة سريعة (QRcode)
- تصدير إلى الحافظة
@@ -384,6 +393,10 @@
- Least Ping
- Least Load
- Pre-resolving domain…
+
+
+ - Local
+ - WebDAV
+
diff --git a/V2rayNG/app/src/main/res/values-bn/strings.xml b/V2rayNG/app/src/main/res/values-bn/strings.xml
index 1e9a094e..ae5d7947 100644
--- a/V2rayNG/app/src/main/res/values-bn/strings.xml
+++ b/V2rayNG/app/src/main/res/values-bn/strings.xml
@@ -238,9 +238,6 @@
সোর্স কোড
Open Source licenses
টেলিগ্রাম চ্যানেল
- কনফিগারেশন ব্যাকআপ
- কনফিগারেশন পুনরুদ্ধার
- কনফিগারেশন শেয়ার করুন
প্রচার
@@ -338,6 +335,18 @@
From subscription group
Remarks regular filter
+
+ Backup & Restore
+ কনফিগারেশন ব্যাকআপ
+ কনফিগারেশন পুনরুদ্ধার
+ কনফিগারেশন শেয়ার করুন
+ WebDAV Settings
+ Please configure WebDAV first.
+ WebDAV server URL
+ Username
+ Password
+ Remote directory (optional)
+
- QR কোড
- ক্লিপবোর্ডে রপ্তানি করুন
@@ -389,6 +398,10 @@
- Least Ping
- Least Load
- Pre-resolving domain…
+
+
+ - Local
+ - WebDAV
+
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml b/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml
index 9d3aa25c..7beadefe 100644
--- a/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml
+++ b/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml
@@ -238,9 +238,6 @@
کود بونچک
موجوزا کود بونچک
تورگه تلگرام
- لادراری گرؽڌن ز کانفیگ
- وورگندن کانفیگ
- یک رسۊوی کانفیگ
تبلیقات
@@ -347,6 +344,18 @@
From subscription group
Remarks regular filter
+
+ Backup & Restore
+ لادراری گرؽڌن ز کانفیگ
+ وورگندن کانفیگ
+ یک رسۊوی کانفیگ
+ WebDAV Settings
+ Please configure WebDAV first.
+ WebDAV server URL
+ Username
+ Password
+ Remote directory (optional)
+
- QRcode
- و در کشیڌن من کلیپ بورد
@@ -399,6 +408,10 @@
- کم ترین پینگ
- کم ترین بار(لود)
- پؽش هل دامنه…
+
+
+ - Local
+ - WebDAV
+
diff --git a/V2rayNG/app/src/main/res/values-fa/strings.xml b/V2rayNG/app/src/main/res/values-fa/strings.xml
index 0ca1aa4e..46890040 100644
--- a/V2rayNG/app/src/main/res/values-fa/strings.xml
+++ b/V2rayNG/app/src/main/res/values-fa/strings.xml
@@ -237,9 +237,6 @@
کد منبع
مجوز های منبع باز
کانال تلگرام
- پشتیبان گیری از پیکربندی
- بازیابی پیکربندی
- اشتراک گذاری پیکربندی
تبلیغات
به روزرسانی خودکار اشتراک ها
@@ -344,6 +341,18 @@
From subscription group
Remarks regular filter
+
+ Backup & Restore
+ پشتیبان گیری از پیکربندی
+ بازیابی پیکربندی
+ اشتراک گذاری پیکربندی
+ WebDAV Settings
+ Please configure WebDAV first.
+ WebDAV server URL
+ Username
+ Password
+ Remote directory (optional)
+
- QRcode
- خروجی گرفتن در کلیپ بورد
@@ -398,6 +407,10 @@
- کمترین پینگ
- کمترین بار(لود)
- Pre-resolving domain…
+
+
+ - Local
+ - WebDAV
+
diff --git a/V2rayNG/app/src/main/res/values-ru/strings.xml b/V2rayNG/app/src/main/res/values-ru/strings.xml
index db84ae24..7c8e6ef7 100644
--- a/V2rayNG/app/src/main/res/values-ru/strings.xml
+++ b/V2rayNG/app/src/main/res/values-ru/strings.xml
@@ -237,9 +237,6 @@
Исходный код
Лицензии открытого исходного кода
Telegram-канал
- Резервирование конфигурации
- Восстановление конфигурации
- Поделиться конфигурацией
Содействие
@@ -346,6 +343,18 @@
From subscription group
Remarks regular filter
+
+ Backup & Restore
+ Резервирование конфигурации
+ Восстановление конфигурации
+ Поделиться конфигурацией
+ WebDAV Settings
+ Please configure WebDAV first.
+ WebDAV server URL
+ Username
+ Password
+ Remote directory (optional)
+
- QR-код
- Экспорт в буфер обмена
@@ -398,6 +407,10 @@
- Наименьшая задержка
- Наименьшая нагрузка
- Предварительное определение домена…
+
+
+ - Local
+ - WebDAV
+
diff --git a/V2rayNG/app/src/main/res/values-vi/strings.xml b/V2rayNG/app/src/main/res/values-vi/strings.xml
index 62883f18..dc5c6427 100644
--- a/V2rayNG/app/src/main/res/values-vi/strings.xml
+++ b/V2rayNG/app/src/main/res/values-vi/strings.xml
@@ -239,9 +239,6 @@
Mã nguồn
Open Source licenses
Kênh Telegram
- Sao lưu cấu hình
- Khôi phục cấu hình
- Chia sẻ cấu hình
Quảng bá server
Tự động cập nhật các gói đăng ký
@@ -340,6 +337,18 @@
From subscription group
Remarks regular filter
+
+ Backup & Restore
+ Sao lưu cấu hình
+ Khôi phục cấu hình
+ Chia sẻ cấu hình
+ WebDAV Settings
+ Please configure WebDAV first.
+ WebDAV server URL
+ Username
+ Password
+ Remote directory (optional)
+
- Xuất ra mã QR (Chụp màn hình để lưu)
- Sao chép vào Clipboard
@@ -386,6 +395,10 @@
- Least Ping
- Least Load
- Pre-resolving domain…
+
+
+ - Local
+ - WebDAV
+
diff --git a/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml b/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml
index f7d359bb..f5b5f4b5 100644
--- a/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml
+++ b/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml
@@ -235,9 +235,6 @@
源代码
Open Source licenses
Telegram 频道
- 备份配置
- 还原配置
- 分享配置
推广
@@ -338,6 +335,18 @@
来自订阅分组
别名正则过滤
+
+ 备份 & 还原
+ 备份配置
+ 还原配置
+ 分享配置
+ WebDAV 设置
+ 请先设置 WebDAV
+ WebDAV 服务器地址WebDAV server address
+ 用户名
+ 密码
+ 远程文件夹名(可选)
+
- 二维码
- 导出至剪贴板
@@ -390,6 +399,10 @@
- 最低延迟
- 最稳定
- 预解析域名中…
+
+
+ - 本地
+ - WebDAV
+
diff --git a/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml b/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml
index e8c8f442..7b876d12 100644
--- a/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml
+++ b/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml
@@ -236,9 +236,6 @@
原始碼
Open Source licenses
Telegram 頻道
- 備份設定
- 還原設定
- 分享設定
推廣
@@ -338,6 +335,18 @@
來自訂閱分組
別名正規過濾
+
+ Backup & Restore
+ 備份設定
+ 還原設定
+ 分享設定
+ WebDAV 設定
+ 請先設定 WebDAV
+ WebDAV 伺服器位址
+ 使用者名稱
+ 密碼
+ 遠端目錄(可選)
+
- 二維碼
- 匯出至剪貼簿
@@ -390,6 +399,10 @@
- 最低延遲
- 最少卡頓
- 預解析域名…
+
+
+ - 本地
+ - WebDAV
+
diff --git a/V2rayNG/app/src/main/res/values/strings.xml b/V2rayNG/app/src/main/res/values/strings.xml
index 4acf13a5..14e3d582 100644
--- a/V2rayNG/app/src/main/res/values/strings.xml
+++ b/V2rayNG/app/src/main/res/values/strings.xml
@@ -241,9 +241,6 @@
Source code
Open Source licenses
Telegram channel
- Backup config
- Restore config
- Share config
Promotion
@@ -350,6 +347,18 @@
From subscription group
Remarks regular filter
+
+ Backup & Restore
+ Backup config
+ Restore config
+ Share config
+ WebDAV Settings
+ Please configure WebDAV first.
+ WebDAV server URL
+ Username
+ Password
+ Remote directory (optional)
+
- QRcode
- Export to clipboard
@@ -404,6 +413,9 @@
- Least Load
- Pre-resolving domain…
+
+ - Local
+ - WebDAV
+
diff --git a/V2rayNG/gradle/libs.versions.toml b/V2rayNG/gradle/libs.versions.toml
index 0b966e1e..8840181f 100644
--- a/V2rayNG/gradle/libs.versions.toml
+++ b/V2rayNG/gradle/libs.versions.toml
@@ -13,6 +13,7 @@ activity = "1.12.2"
constraintlayout = "2.2.1"
mmkvStatic = "1.3.16"
gson = "2.13.2"
+okhttp = "5.3.2"
quickieFoss = "1.14.0"
kotlinxCoroutinesAndroid = "1.10.2"
kotlinxCoroutinesCore = "1.10.2"
@@ -41,6 +42,7 @@ androidx-activity = { group = "androidx.activity", name = "activity", version.re
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
mmkv-static = { module = "com.tencent:mmkv-static", version.ref = "mmkvStatic" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
+okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
quickie-foss = { module = "com.github.T8RIN.QuickieExtended:quickie-foss", version.ref = "quickieFoss" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }