From 37bd57ffc5dea16b2ced020a05b73512f24a61e9 Mon Sep 17 00:00:00 2001
From: 2dust <31833384+2dust@users.noreply.github.com>
Date: Sat, 3 Jan 2026 15:22:45 +0800
Subject: [PATCH] Add WebDAV backup and restore functionality
Introduces a new BackupActivity for configuration backup and restore, supporting both local storage and WebDAV servers. Adds WebDavManager and WebDavConfig for handling WebDAV operations, updates the navigation drawer and menu, and refactors AboutActivity to remove backup/restore logic. Includes new layouts and string resources for backup options and WebDAV settings.
---
V2rayNG/app/build.gradle.kts | 1 +
V2rayNG/app/src/main/AndroidManifest.xml | 3 +
.../java/com/v2ray/ang/dto/WebDavConfig.kt | 9 +
.../java/com/v2ray/ang/handler/MmkvManager.kt | 21 +
.../com/v2ray/ang/handler/WebDavManager.kt | 187 +++++++++
.../java/com/v2ray/ang/ui/AboutActivity.kt | 178 ---------
.../java/com/v2ray/ang/ui/BackupActivity.kt | 361 ++++++++++++++++++
.../java/com/v2ray/ang/ui/MainActivity.kt | 7 +-
.../src/main/res/layout/activity_about.xml | 90 +----
.../src/main/res/layout/activity_backup.xml | 150 ++++++++
.../app/src/main/res/layout/dialog_webdav.xml | 44 +++
V2rayNG/app/src/main/res/menu/menu_drawer.xml | 4 +
.../app/src/main/res/values-ar/strings.xml | 21 +-
.../app/src/main/res/values-bn/strings.xml | 21 +-
.../src/main/res/values-bqi-rIR/strings.xml | 21 +-
.../app/src/main/res/values-fa/strings.xml | 21 +-
.../app/src/main/res/values-ru/strings.xml | 21 +-
.../app/src/main/res/values-vi/strings.xml | 21 +-
.../src/main/res/values-zh-rCN/strings.xml | 21 +-
.../src/main/res/values-zh-rTW/strings.xml | 21 +-
V2rayNG/app/src/main/res/values/strings.xml | 20 +-
V2rayNG/gradle/libs.versions.toml | 2 +
22 files changed, 939 insertions(+), 306 deletions(-)
create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/dto/WebDavConfig.kt
create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/handler/WebDavManager.kt
create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/ui/BackupActivity.kt
create mode 100644 V2rayNG/app/src/main/res/layout/activity_backup.xml
create mode 100644 V2rayNG/app/src/main/res/layout/dialog_webdav.xml
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" }