Skip to content

Commit

Permalink
Add backup and restore configuration functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
2dust committed Apr 15, 2024
1 parent c33b646 commit a26bf3e
Show file tree
Hide file tree
Showing 16 changed files with 363 additions and 17 deletions.
1 change: 1 addition & 0 deletions V2rayNG/app/src/main/kotlin/com/v2ray/ang/AppConfig.kt
Expand Up @@ -7,6 +7,7 @@ package com.v2ray.ang
object AppConfig {
const val ANG_PACKAGE = BuildConfig.APPLICATION_ID
const val DIR_ASSETS = "assets"
const val DIR_BACKUPS = "backups"

// legacy
const val ANG_CONFIG = "ang_config"
Expand Down
109 changes: 108 additions & 1 deletion V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/AboutActivity.kt
@@ -1,15 +1,27 @@
package com.v2ray.ang.ui

import android.Manifest
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import com.tbruyelle.rxpermissions.RxPermissions
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.util.SpeedtestUtil
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 lateinit var binding: ActivityAboutBinding
private val extDir by lazy { File(Utils.backupPath(this)) }

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Expand All @@ -19,6 +31,31 @@ class AboutActivity : BaseActivity() {

title = getString(R.string.title_about)

binding.tvBackupSummary.text = this.getString(R.string.summary_configuration_backup, extDir)
binding.layoutBackup.setOnClickListener {
backupMMKV()
}

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
}
RxPermissions(this)
.request(permission)
.subscribe {
if (it) {
try {
showFileChooser()
} catch (e: Exception) {
e.printStackTrace()
}
} else
toast(R.string.toast_permission_denied)
}
}

binding.layoutSoureCcode.setOnClickListener {
Utils.openUri(this, AppConfig.v2rayNGUrl)
}
Expand All @@ -38,9 +75,79 @@ class AboutActivity : BaseActivity() {
"v${BuildConfig.VERSION_NAME} (${SpeedtestUtil.getLibVersion()})".also {
binding.tvVersion.text = it
}
}

fun backupMMKV() {
val dateFormated = SimpleDateFormat(
"yyyy-MM-dd-HH-mm-ss",
Locale.getDefault()
).format(System.currentTimeMillis())
val folderName = "${getString(R.string.app_name)}_${dateFormated}"
val backupDir = this.cacheDir.absolutePath + "/$folderName"
val outputZipFilePath = extDir.absolutePath + "/$folderName.zip"

val count = MMKV.backupAllToDirectory(backupDir)
if (count <= 0) {
toast(R.string.toast_failure)
}

if (ZipUtil.zipFromFolder(backupDir, outputZipFilePath)) {
toast(R.string.toast_success)
} else {
toast(R.string.toast_failure)
}
}

fun restoreMMKV(zipFile: File) {
val backupDir = this.cacheDir.absolutePath + "/${System.currentTimeMillis()}"

if (!ZipUtil.unzipToFolder(zipFile, backupDir)) {
toast(R.string.toast_failure)
}

val count = MMKV.restoreAllFromDirectory(backupDir)
if (count > 0) {
toast(R.string.toast_success)
} else {
toast(R.string.toast_failure)
}
}

private fun showFileChooser() {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.type = "*/*"
intent.addCategory(Intent.CATEGORY_OPENABLE)

try {
chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
} catch (ex: android.content.ActivityNotFoundException) {
toast(R.string.toast_require_file_manager)
}
}

private val chooseFile =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val uri = it.data?.data
if (it.resultCode == RESULT_OK && uri != null) {
try {
try {
val targetFile =
File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip")
contentResolver.openInputStream(uri).use { input ->
targetFile.outputStream().use { fileOut ->
input?.copyTo(fileOut)
}
}

}
restoreMMKV(targetFile)
} catch (e: Exception) {
e.printStackTrace()
}

} catch (e: Exception) {
e.printStackTrace()
toast(e.message.toString())
}
}
}
}
18 changes: 13 additions & 5 deletions V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/Utils.kt
@@ -1,11 +1,8 @@
package com.v2ray.ang.util

import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.text.Editable
import android.util.Base64
import java.util.*
import android.content.ClipData
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration.UI_MODE_NIGHT_MASK
Expand All @@ -14,6 +11,8 @@ import android.net.Uri
import android.os.Build
import android.os.LocaleList
import android.provider.Settings
import android.text.Editable
import android.util.Base64
import android.util.Log
import android.util.Patterns
import android.webkit.URLUtil
Expand All @@ -23,9 +22,10 @@ import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.R
import com.v2ray.ang.extension.toast
import java.net.*
import com.v2ray.ang.service.V2RayServiceManager
import java.io.IOException
import java.net.*
import java.util.*

object Utils {

Expand Down Expand Up @@ -318,6 +318,14 @@ object Utils {
return extDir.absolutePath
}

fun backupPath(context: Context?): String {
if (context == null)
return ""
val extDir = context.getExternalFilesDir(AppConfig.DIR_BACKUPS)
?: return context.getDir(AppConfig.DIR_BACKUPS, 0).absolutePath
return extDir.absolutePath
}

fun getDeviceIdForXUDPBaseKey(): String {
val androidId = Settings.Secure.ANDROID_ID.toByteArray(charset("UTF-8"))
return Base64.encodeToString(androidId.copyOf(32), Base64.NO_PADDING.or(Base64.URL_SAFE))
Expand Down
102 changes: 102 additions & 0 deletions V2rayNG/app/src/main/kotlin/com/v2ray/ang/util/ZipUtil.kt
@@ -0,0 +1,102 @@
package com.v2ray.ang.util

import java.io.BufferedOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream

object ZipUtil {
private const val BUFFER_SIZE = 4096

@Throws(IOException::class)
fun zipFromFolder(folderPath: String, outputZipFilePath: String): Boolean {
val buffer = ByteArray(BUFFER_SIZE)

try {
if (folderPath.isEmpty() || outputZipFilePath.isEmpty()) {
return false
}

val filesToCompress = ArrayList<String>()
val directory = File(folderPath)
if (directory.isDirectory) {
directory.listFiles()?.forEach {
if (it.isFile) {
filesToCompress.add(it.absolutePath)
}
}
}
if (filesToCompress.isEmpty()) {
return false
}

val zos = ZipOutputStream(FileOutputStream(outputZipFilePath))

filesToCompress.forEach { file ->
val ze = ZipEntry(File(file).name)
zos.putNextEntry(ze)
val inputStream = FileInputStream(file)
while (true) {
val len = inputStream.read(buffer)
if (len <= 0) break
zos.write(buffer, 0, len)
}

inputStream.close()
}

zos.closeEntry()
zos.close()
} catch (e: Exception) {
e.printStackTrace()
return false
}
return true
}

@Throws(IOException::class)
fun unzipToFolder(zipFile: File, destDirectory: String): Boolean {
File(destDirectory).run {
if (!exists()) {
mkdirs()
}
}
try {
ZipFile(zipFile).use { zip ->
zip.entries().asSequence().forEach { entry ->
zip.getInputStream(entry).use { input ->
val filePath = destDirectory + File.separator + entry.name
if (!entry.isDirectory) {
// if the entry is a file, extracts it
extractFile(input, filePath)
} else {
// if the entry is a directory, make the directory
val dir = File(filePath)
dir.mkdir()
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
return false
}
return true
}

@Throws(IOException::class)
private fun extractFile(inputStream: InputStream, destFilePath: String) {
val bos = BufferedOutputStream(FileOutputStream(destFilePath))
val bytesIn = ByteArray(BUFFER_SIZE)
var read: Int
while (inputStream.read(bytesIn).also { read = it } != -1) {
bos.write(bytesIn, 0, read)
}
bos.close()
}
}
9 changes: 9 additions & 0 deletions V2rayNG/app/src/main/res/drawable-night/ic_backup_24dp.xml
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M864,192L704,192L704,96c0,-17.7 -14.3,-32 -32,-32L352,64c-9,0 -17.2,3.7 -22.9,9.7L137.7,265.1c-6,5.8 -9.7,14 -9.7,22.9v512c0,17.7 14.3,32 32,32h160v96c0,17.7 14.3,32 32,32h512c17.7,0 32,-14.3 32,-32L896,224c0,-17.7 -14.3,-32 -32,-32zM320,173.2L320,256h-82.8l82.8,-82.8zM192,768L192,320h160c17.7,0 32,-14.3 32,-32L384,128h256v64h-96c-9,0 -17.2,3.7 -22.9,9.7L329.7,393.1c-6,5.8 -9.7,14 -9.7,22.9v352L192,768zM512,301.2L512,384h-82.8l82.8,-82.8zM832,896L384,896L384,448h160c17.7,0 32,-14.3 32,-32L576,256h256v640z" />
</vector>
9 changes: 9 additions & 0 deletions V2rayNG/app/src/main/res/drawable-night/ic_restore_24dp.xml
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M938.7,512a384,384 0,0 1,-384 384,379.3 379.3,0 0,1 -220.2,-69.5 21.8,21.8 0,0 1,-9 -15.8,21.3 21.3,0 0,1 6,-16.6l30.7,-31.1a21.3,21.3 0,0 1,26.9 -2.6A294.8,294.8 0,0 0,554.7 810.7a298.7,298.7 0,1 0,-298.7 -298.7h100.7a20.9,20.9 0,0 1,15.4 6.4l8.5,8.5a21.3,21.3 0,0 1,0 30.3L230,708.3a21.8,21.8 0,0 1,-30.3 0l-150.6,-151a21.3,21.3 0,0 1,0 -30.3l8.5,-8.5a20.9,20.9 0,0 1,15.4 -6.4H170.7a384,384 0,0 1,768 0z" />
</vector>
9 changes: 9 additions & 0 deletions V2rayNG/app/src/main/res/drawable/ic_backup_24dp.xml
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:fillColor="#FF000000"
android:pathData="M864,192L704,192L704,96c0,-17.7 -14.3,-32 -32,-32L352,64c-9,0 -17.2,3.7 -22.9,9.7L137.7,265.1c-6,5.8 -9.7,14 -9.7,22.9v512c0,17.7 14.3,32 32,32h160v96c0,17.7 14.3,32 32,32h512c17.7,0 32,-14.3 32,-32L896,224c0,-17.7 -14.3,-32 -32,-32zM320,173.2L320,256h-82.8l82.8,-82.8zM192,768L192,320h160c17.7,0 32,-14.3 32,-32L384,128h256v64h-96c-9,0 -17.2,3.7 -22.9,9.7L329.7,393.1c-6,5.8 -9.7,14 -9.7,22.9v352L192,768zM512,301.2L512,384h-82.8l82.8,-82.8zM832,896L384,896L384,448h160c17.7,0 32,-14.3 32,-32L576,256h256v640z" />
</vector>
9 changes: 9 additions & 0 deletions V2rayNG/app/src/main/res/drawable/ic_restore_24dp.xml
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:fillColor="#FF000000"
android:pathData="M938.7,512a384,384 0,0 1,-384 384,379.3 379.3,0 0,1 -220.2,-69.5 21.8,21.8 0,0 1,-9 -15.8,21.3 21.3,0 0,1 6,-16.6l30.7,-31.1a21.3,21.3 0,0 1,26.9 -2.6A294.8,294.8 0,0 0,554.7 810.7a298.7,298.7 0,1 0,-298.7 -298.7h100.7a20.9,20.9 0,0 1,15.4 6.4l8.5,8.5a21.3,21.3 0,0 1,0 30.3L230,708.3a21.8,21.8 0,0 1,-30.3 0l-150.6,-151a21.3,21.3 0,0 1,0 -30.3l8.5,-8.5a20.9,20.9 0,0 1,15.4 -6.4H170.7a384,384 0,0 1,768 0z" />
</vector>

0 comments on commit a26bf3e

Please sign in to comment.