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" }