require external storage

This commit is contained in:
X1nto 2021-04-13 14:18:53 +04:00
parent 1c3ab835a6
commit e73b56ed4d
12 changed files with 188 additions and 89 deletions

View File

@ -8,12 +8,18 @@
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<!-- is required for some Android 5.x devices -->
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage"/>
<queries> <queries>
<package android:name="com.vanced.android.youtube" /> <package android:name="com.vanced.android.youtube" />
<package android:name="com.google.android.youtube" /> <package android:name="com.google.android.youtube" />
@ -32,7 +38,9 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
tools:ignore="UnusedAttribute"> tools:ignore="UnusedAttribute"
android:requestLegacyExternalStorage="true">
<!-- Only because MANAGE_EXTERNAL_STORAGE is not available in android Q -->
<activity <activity
android:name=".ui.core.SplashScreenActivity" android:name=".ui.core.SplashScreenActivity"
@ -51,7 +59,7 @@
<activity <activity
android:name=".ui.WelcomeActivity" android:name=".ui.WelcomeActivity"
android:theme="@style/DarkTheme"/> android:theme="@style/DarkTheme" />
<activity <activity
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
@ -62,12 +70,14 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data <data
android:scheme="https"
android:host="vancedapp.com" android:host="vancedapp.com"
android:pathPrefix="/downloads"/> android:pathPrefix="/downloads"
android:scheme="https" />
</intent-filter> </intent-filter>

View File

@ -24,6 +24,6 @@ object MicrogDownloader {
fun startMicrogInstall(context: Context) { fun startMicrogInstall(context: Context) {
installing.postValue(true) installing.postValue(true)
postReset() postReset()
install("${context.getExternalFilesDir(folderName)}/$fileName", context) install("$folderName/$fileName".managerFilepath, context)
} }
} }

View File

@ -68,6 +68,6 @@ object MusicDownloader {
if (variant == "root") if (variant == "root")
installMusicRoot(context) installMusicRoot(context)
else else
install("${context.getExternalFilesDir("music/nonroot")}/nonroot.apk", context) install("music/nonroot/nonroot.apk".managerFilepath, context)
} }
} }

View File

@ -40,6 +40,10 @@ class MainActivity : AppCompatActivity() {
lateinit var binding: ActivityMainBinding lateinit var binding: ActivityMainBinding
private val navHost by lazy { findNavController(R.id.nav_host) } private val navHost by lazy { findNavController(R.id.nav_host) }
companion object {
const val REQUEST_CODE = 69
}
private val loadingObserver = object : LoadingStateListener { private val loadingObserver = object : LoadingStateListener {
val tag = "VMLocalisation" val tag = "VMLocalisation"
override fun onDataChanged() { override fun onDataChanged() {
@ -98,6 +102,9 @@ class MainActivity : AppCompatActivity() {
setFinalTheme() setFinalTheme()
super.onResume() super.onResume()
Crowdin.registerDataLoadingObserver(loadingObserver) Crowdin.registerDataLoadingObserver(loadingObserver)
if (!canAccessStorage(this)) {
DialogContainer.storageDialog(this)
}
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {

View File

@ -1,13 +1,16 @@
package com.vanced.manager.ui.dialogs package com.vanced.manager.ui.dialogs
import android.content.Context import android.content.Context
import android.os.Build
import androidx.core.content.edit import androidx.core.content.edit
import androidx.fragment.app.FragmentActivity
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.vanced.manager.R import com.vanced.manager.R
import com.vanced.manager.utils.showWithAccent import com.vanced.manager.utils.showWithAccent
import com.vanced.manager.utils.isMiuiOptimizationsEnabled import com.vanced.manager.utils.isMiuiOptimizationsEnabled
import com.vanced.manager.utils.openUrl import com.vanced.manager.utils.openUrl
import com.vanced.manager.utils.requestStoragePerms
object DialogContainer { object DialogContainer {
@ -30,6 +33,23 @@ object DialogContainer {
prefs.edit { putBoolean("firstLaunch", false) } prefs.edit { putBoolean("firstLaunch", false) }
} }
fun storageDialog(activity: FragmentActivity) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M)
return
MaterialAlertDialogBuilder(activity).apply {
setTitle(R.string.storage_access_required)
setMessage(R.string.storage_access_required_summary)
setPositiveButton(R.string.auth_dialog_ok) { dialog, _ ->
dialog.cancel()
requestStoragePerms(activity)
}
setCancelable(false)
create()
showWithAccent()
}
}
fun miuiDialog(context: Context) { fun miuiDialog(context: Context) {
MaterialAlertDialogBuilder(context).apply { MaterialAlertDialogBuilder(context).apply {
setTitle(context.getString(R.string.miui_one_title)) setTitle(context.getString(R.string.miui_one_title))

View File

@ -8,7 +8,10 @@ import android.widget.Toast
import com.vanced.manager.R import com.vanced.manager.R
import com.vanced.manager.core.ui.base.BindingFragment import com.vanced.manager.core.ui.base.BindingFragment
import com.vanced.manager.databinding.FragmentLogBinding import com.vanced.manager.databinding.FragmentLogBinding
import com.vanced.manager.utils.AppUtils
import com.vanced.manager.utils.AppUtils.logs import com.vanced.manager.utils.AppUtils.logs
import com.vanced.manager.utils.managerFilepath
import com.vanced.manager.utils.performStorageAction
import java.io.File import java.io.File
import java.io.FileWriter import java.io.FileWriter
import java.io.IOException import java.io.IOException
@ -30,23 +33,30 @@ class LogFragment : BindingFragment<FragmentLogBinding>() {
val logs = TextUtils.concat(*logs.toTypedArray()) val logs = TextUtils.concat(*logs.toTypedArray())
logText.text = logs logText.text = logs
logSave.setOnClickListener { logSave.setOnClickListener {
val calendar = Calendar.getInstance()
val year = calendar.get(Calendar.YEAR)
val month = calendar.get(Calendar.MONTH)
val day = calendar.get(Calendar.DAY_OF_MONTH)
val hour = calendar.get(Calendar.HOUR_OF_DAY)
val minute = calendar.get(Calendar.MINUTE)
val second = calendar.get(Calendar.SECOND)
try { try {
val calendar = Calendar.getInstance() performStorageAction(requireActivity()) {
val year = calendar.get(Calendar.YEAR) val logPath = File("logs".managerFilepath).apply {
val month = calendar.get(Calendar.MONTH) if (!exists()) {
val day = calendar.get(Calendar.DAY_OF_MONTH) mkdirs()
val hour = calendar.get(Calendar.HOUR_OF_DAY) }
val minute = calendar.get(Calendar.MINUTE) }.path
val second = calendar.get(Calendar.SECOND) FileWriter(File(logPath, "$year$month${day}_$hour$minute$second.log")).apply {
val log = File(requireActivity().getExternalFilesDir("logs")?.path + "/$year$month${day}_$hour$minute$second.log") append(logs)
FileWriter(log).apply { flush()
append(logs) close()
flush() }
close()
} }
Toast.makeText(requireActivity(), R.string.logs_saved, Toast.LENGTH_SHORT).show() Toast.makeText(requireActivity(), R.string.logs_saved, Toast.LENGTH_SHORT).show()
} catch (e: IOException) { } catch (e: IOException) {
Toast.makeText(requireActivity(), R.string.logs_not_saved, Toast.LENGTH_SHORT).show() Toast.makeText(requireActivity(), R.string.logs_not_saved, Toast.LENGTH_SHORT).show()
AppUtils.log("VMIO", "Could not save logs: $e")
} }
} }
} }

View File

@ -51,7 +51,7 @@ class HomeViewModel(private val activity: FragmentActivity): ViewModel() {
val microgModel = MutableLiveData<DataModel>() val microgModel = MutableLiveData<DataModel>()
val musicModel = MutableLiveData<DataModel>() val musicModel = MutableLiveData<DataModel>()
val musicRootModel = MutableLiveData<RootDataModel>() val musicRootModel = MutableLiveData<RootDataModel>()
val managerModel = MutableLiveData<DataModel>() private val managerModel = MutableLiveData<DataModel>()
fun fetchData() { fun fetchData() {
viewModelScope.launch { viewModelScope.launch {
@ -110,7 +110,7 @@ class HomeViewModel(private val activity: FragmentActivity): ViewModel() {
activity.getString(R.string.vanced) -> { activity.getString(R.string.vanced) -> {
when (variant) { when (variant) {
"nonroot" -> { "nonroot" -> {
if (vancedInstallFilesExist(activity)) { if (vancedInstallFilesExist()) {
InstallationFilesDetectedDialog.newInstance(app).show(activity) InstallationFilesDetectedDialog.newInstance(app).show(activity)
} else { } else {
VancedPreferencesDialog().show(activity) VancedPreferencesDialog().show(activity)
@ -124,7 +124,7 @@ class HomeViewModel(private val activity: FragmentActivity): ViewModel() {
activity.getString(R.string.music) -> { activity.getString(R.string.music) -> {
when (variant) { when (variant) {
"nonroot" -> { "nonroot" -> {
if (musicApkExists(activity)) { if (musicApkExists()) {
InstallationFilesDetectedDialog.newInstance(app).show(activity) InstallationFilesDetectedDialog.newInstance(app).show(activity)
} else { } else {
MusicPreferencesDialog().show(activity) MusicPreferencesDialog().show(activity)
@ -136,7 +136,7 @@ class HomeViewModel(private val activity: FragmentActivity): ViewModel() {
} }
} }
activity.getString(R.string.microg) -> { activity.getString(R.string.microg) -> {
if (apkExist(activity, "microg.apk")) { if (apkExist("microg.apk")) {
InstallationFilesDetectedDialog.newInstance(app).show(activity) InstallationFilesDetectedDialog.newInstance(app).show(activity)
} else { } else {
AppDownloadDialog.newInstance(app).show(activity) AppDownloadDialog.newInstance(app).show(activity)

View File

@ -49,13 +49,13 @@ object DownloadHelper : CoroutineScope by CoroutineScope(Dispatchers.IO) {
override fun onResponse(call: Call<ResponseBody>, response: Response<ResponseBody>) { override fun onResponse(call: Call<ResponseBody>, response: Response<ResponseBody>) {
if (response.isSuccessful) { if (response.isSuccessful) {
launch { launch {
if (response.body()?.let { writeFile(it, context.getExternalFilesDir(fileFolder)?.path + "/" + fileName) } == true) { if (response.body()?.let { writeFile(it, fileFolder, fileName) } == true) {
onDownloadComplete() onDownloadComplete()
} else { } else {
onError("Could not save file") onError(response.errorBody().toString())
downloadProgress.postValue(0)
log("VMDownloader", "Failed to save file: $url\n${response.errorBody()}") log("VMDownloader", "Failed to save file: $url\n${response.errorBody()}")
} }
downloadProgress.postValue(0)
} }
} else { } else {
val errorBody = response.errorBody().toString() val errorBody = response.errorBody().toString()
@ -68,76 +68,78 @@ object DownloadHelper : CoroutineScope by CoroutineScope(Dispatchers.IO) {
override fun onFailure(call: Call<ResponseBody>, t: Throwable) { override fun onFailure(call: Call<ResponseBody>, t: Throwable) {
if (call.isCanceled) { if (call.isCanceled) {
log("VMDownloader", "Download canceled") log("VMDownloader", "Download canceled")
downloadProgress.postValue(0)
} else { } else {
onError(t.stackTraceToString()) onError(t.stackTraceToString())
downloadProgress.postValue(0)
log("VMDownloader", "Failed to download file: $url") log("VMDownloader", "Failed to download file: $url")
} }
downloadProgress.postValue(0)
} }
}) })
} }
fun writeFile(body: ResponseBody, filePath: String): Boolean { fun writeFile(body: ResponseBody, folderName: String, fileName: String): Boolean =
return try { try {
val file = File(filePath)
val totalBytes = body.contentLength() val totalBytes = body.contentLength()
var inputStream: InputStream? = null val fileReader = ByteArray(8096)
var outputStream: OutputStream? = null var downloadedBytes: Long = 0
try { val dir = File(managerPath, folderName).apply {
val fileReader = ByteArray(4096) if (!exists()) {
var downloadedBytes: Long = 0 mkdirs()
inputStream = body.byteStream()
outputStream = FileOutputStream(file)
var read: Int
while (inputStream.read(fileReader).also { read = it } != -1) {
outputStream.write(fileReader, 0, read)
downloadedBytes += read.toLong()
downloadProgress.postValue((downloadedBytes * 100 / totalBytes).toInt())
} }
outputStream.flush()
true
} catch (e: IOException) {
false
} finally {
inputStream?.close()
outputStream?.close()
} }
val inputStream: InputStream = body.byteStream()
val outputStream: OutputStream = FileOutputStream("${dir.path}/$fileName")
var read: Int
while (inputStream.read(fileReader).also { read = it } != -1) {
outputStream.write(fileReader, 0, read)
downloadedBytes += read.toLong()
downloadProgress.postValue((downloadedBytes * 100 / totalBytes).toInt())
}
outputStream.flush()
inputStream.close()
outputStream.close()
true
} catch (e: IOException) { } catch (e: IOException) {
log("VMIO", "Failed to save file: $e")
false false
} }
}
fun downloadManager(context: Context) { fun downloadManager(context: Context) {
val url = "https://github.com/YTVanced/VancedManager/releases/latest/download/manager.apk" val url = "https://github.com/YTVanced/VancedManager/releases/latest/download/manager.apk"
download(url,"https://github.com/YTVanced/VancedManager/", "manager", "manager.apk", context, onDownloadComplete = { download(
val apk = File("${context.getExternalFilesDir("manager")?.path}/manager.apk") url,
val uri = "https://github.com/YTVanced/VancedManager/",
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) "manager",
FileProvider.getUriForFile(context, "${context.packageName}.provider", apk) "manager.apk",
else context,
Uri.fromFile(apk) onDownloadComplete = {
val apk = File("manager/manager.apk".managerFilepath)
val uri =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
FileProvider.getUriForFile(context, "${context.packageName}.provider", apk)
else
Uri.fromFile(apk)
val intent = Intent(Intent.ACTION_VIEW) val intent = Intent(Intent.ACTION_VIEW)
intent.setDataAndType(uri, "application/vnd.android.package-archive") intent.setDataAndType(uri, "application/vnd.android.package-archive")
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
try { try {
context.startActivity(intent) context.startActivity(intent)
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
log("VMDownloader", e.stackTraceToString()) log("VMDownloader", e.stackTraceToString())
} finally { } finally {
sendCloseDialog(context) sendCloseDialog(context)
} }
}, onError = { }) {
downloadingFile.postValue( downloadingFile.postValue(
context.getString( context.getString(
R.string.error_downloading, R.string.error_downloading,
"manager.apk" "manager.apk"
) )
) )
}) }
} }
} }

View File

@ -19,6 +19,8 @@ import java.util.*
val RadioGroup.checkedButtonTag: String? get() = findViewById<MaterialRadioButton>(checkedRadioButtonId)?.tag?.toString() val RadioGroup.checkedButtonTag: String? get() = findViewById<MaterialRadioButton>(checkedRadioButtonId)?.tag?.toString()
val String.managerFilepath get() = "$managerPath/$this"
fun DialogFragment.show(activity: FragmentActivity) { fun DialogFragment.show(activity: FragmentActivity) {
try { try {
show(activity.supportFragmentManager, "") show(activity.supportFragmentManager, "")

View File

@ -100,16 +100,16 @@ object PackageHelper {
} }
} }
fun apkExist(context: Context, apk: String): Boolean { fun apkExist(apk: String): Boolean {
val apkPath = File(context.getExternalFilesDir(apk.substring(0, apk.length - 4))?.path, apk) val apkPath = File("${apk.removeSuffix(".apk")}/$apk".managerFilepath)
if (apkPath.exists()) if (apkPath.exists())
return true return true
return false return false
} }
fun musicApkExists(context: Context): Boolean { fun musicApkExists(): Boolean {
val apkPath = File(context.getExternalFilesDir("music/nonroot")?.path, "nonroot.apk") val apkPath = File("music/nonroot/nonroot.apk".managerFilepath)
if (apkPath.exists()) { if (apkPath.exists()) {
return true return true
} }
@ -117,8 +117,8 @@ object PackageHelper {
return false return false
} }
fun vancedInstallFilesExist(context: Context): Boolean { fun vancedInstallFilesExist(): Boolean {
val apksPath = File(context.getExternalFilesDir("vanced/nonroot")?.path.toString()) val apksPath = File("vanced/nonroot".managerFilepath)
val splitFiles = mutableListOf<String>() val splitFiles = mutableListOf<String>()
if (apksPath.exists()) { if (apksPath.exists()) {
val files = apksPath.listFiles() val files = apksPath.listFiles()
@ -212,8 +212,8 @@ object PackageHelper {
private fun installRootApp(context: Context, app: String, appVerCode: Int?, pkg: String, modApkBool: (fileName: String) -> Boolean) = CoroutineScope(Dispatchers.IO).launch { private fun installRootApp(context: Context, app: String, appVerCode: Int?, pkg: String, modApkBool: (fileName: String) -> Boolean) = CoroutineScope(Dispatchers.IO).launch {
Shell.getShell { Shell.getShell {
val apkFilesPath = context.getExternalFilesDir("$app/root")?.path val apkFilesPath = "$app/root".managerFilepath
val files = File(apkFilesPath.toString()).listFiles()?.toList() val files = File(apkFilesPath).listFiles()?.toList()
if (files != null) { if (files != null) {
val modApk: File? = files.lastOrNull { modApkBool(it.name) } val modApk: File? = files.lastOrNull { modApkBool(it.name) }
if (modApk != null) { if (modApk != null) {
@ -268,7 +268,7 @@ object PackageHelper {
appName: String appName: String
) { ) {
val packageInstaller = context.packageManager.packageInstaller val packageInstaller = context.packageManager.packageInstaller
val folder = File(context.getExternalFilesDir("$appName/nonroot")?.path.toString()) val folder = File("$appName/nonroot".managerFilepath)
var session: PackageInstaller.Session? = null var session: PackageInstaller.Session? = null
val sessionId: Int val sessionId: Int
val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
@ -299,17 +299,10 @@ object PackageHelper {
} }
} }
private fun installSplitApkFilesRoot(apkFiles: List<File>?, context: Context): Boolean { private fun installSplitApkFilesRoot(apkFiles: List<File>?, context: Context) : Boolean {
val filenames = arrayOf("black.apk", "dark.apk", "blue.apk", "pink.apk", "hash.json") val filenames = arrayOf("black.apk", "dark.apk", "blue.apk", "pink.apk", "hash.json")
log(INSTALLER_TAG, "installing split apk files: ${apkFiles?.map { it.name }}") log(INSTALLER_TAG, "installing split apk files: ${apkFiles?.map { it.name }}")
val sessionId = Shell.su("pm install-create -r").exec().out.joinToString(" ").filter { it.isDigit() }.toIntOrNull() val sessionId = Shell.su("pm install-create").exec().out.joinToString(" ").filter { it.isDigit() }.toInt()
if (sessionId == null) {
sendFailure("Session ID is null", context)
sendCloseDialog(context)
return false
}
apkFiles?.filter { !filenames.contains(it.name) }?.forEach { apkFile -> apkFiles?.filter { !filenames.contains(it.name) }?.forEach { apkFile ->
val apkName = apkFile.name val apkName = apkFile.name
log(INSTALLER_TAG, "installing APK: $apkName") log(INSTALLER_TAG, "installing APK: $apkName")

View File

@ -0,0 +1,53 @@
package com.vanced.manager.utils
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Environment
import android.provider.Settings
import androidx.fragment.app.FragmentActivity
import com.vanced.manager.ui.MainActivity
import com.vanced.manager.ui.dialogs.DialogContainer.storageDialog
@Suppress("DEPRECATION")
val managerPath get() = "${Environment.getExternalStorageDirectory().path}/Vanced Manager"
inline fun performStorageAction(activity: FragmentActivity, action: () -> Unit) {
if (canAccessStorage(activity)) {
action()
} else {
storageDialog(activity)
}
}
fun canAccessStorage(activity: FragmentActivity): Boolean = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Environment.isExternalStorageManager()
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> {
listOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
).all {
activity.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
}
}
else -> true
}
fun requestStoragePerms(activity: FragmentActivity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
activity.startActivity(
Intent(
Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION
)
)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
activity.requestPermissions(
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
),
MainActivity.REQUEST_CODE
)
}
}

View File

@ -94,6 +94,8 @@
<string name="microg_bug_summary_music">Due to a bug in the original microG, installing Music v4.11+ first requires you to install v4.07.51, open it, then login and only then can you install v4.11 and higher. Do you want to proceed with the installation of v4.07.51?</string> <string name="microg_bug_summary_music">Due to a bug in the original microG, installing Music v4.11+ first requires you to install v4.07.51, open it, then login and only then can you install v4.11 and higher. Do you want to proceed with the installation of v4.07.51?</string>
<string name="please_be_patient">Please do NOT exit the app during this process!</string> <string name="please_be_patient">Please do NOT exit the app during this process!</string>
<string name="welcome">Welcome</string> <string name="welcome">Welcome</string>
<string name="storage_access_required">Storage access required</string>
<string name="storage_access_required_summary">In order for Vanced Manager to work, you must grant the storage permission</string>
<!-- Install Page --> <!-- Install Page -->
<string name="choose_preferred_language">Choose your preferred language(s) for Vanced</string> <string name="choose_preferred_language">Choose your preferred language(s) for Vanced</string>