Refactor permission handling with PermissionRequester

This commit is contained in:
2dust committed 2026-01-28 16:18:19 +08:00
1 parent 2c47abc219
commit 8600fdba35
9 files changed
+163 -199

No files matched your search

@@ -0,0 +1,44 @@
package com.v2ray.ang.dto
import android.Manifest
import android.os.Build
import androidx.annotation.RequiresApi
/**
* Permission types used in the app, handling API level differences.
*/
enum class PermissionType {
/** Camera permission (used for scanning QR codes) */
CAMERA {
override fun getPermission(): String = Manifest.permission.CAMERA
},
/** Read storage / media permission (adapts to Android version) */
READ_STORAGE {
override fun getPermission(): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
}
},
/** Notification permission (Android 13+) */
POST_NOTIFICATIONS {
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
override fun getPermission(): String = Manifest.permission.POST_NOTIFICATIONS
};
/** Return the actual Android permission string */
abstract fun getPermission(): String
/** Return a human-readable label for the permission */
fun getLabel(): String {
return when (this) {
CAMERA -> "Camera"
READ_STORAGE -> "Storage"
POST_NOTIFICATIONS -> "Notification"
}
}
}
@@ -1,15 +1,11 @@
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 androidx.lifecycle.lifecycleScope
import com.tencent.mmkv.MMKV
@@ -26,6 +22,7 @@ import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.WebDavManager
import com.v2ray.ang.dto.PermissionType
import com.v2ray.ang.util.ZipUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -41,19 +38,6 @@ class BackupActivity : BaseActivity() {
resources.getStringArray(R.array.config_backup_options)
}
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) {
@@ -219,20 +203,8 @@ class BackupActivity : BaseActivity() {
}
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)
checkAndRequestPermission(PermissionType.READ_STORAGE) {
showFileChooser()
}
}
@@ -19,7 +19,9 @@ import com.v2ray.ang.R
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.CustomDividerItemDecoration
import com.v2ray.ang.util.MyContextWrapper
import com.v2ray.ang.dto.PermissionType
import com.v2ray.ang.util.Utils
import com.v2ray.ang.util.permissionRequester
/**
@@ -36,7 +38,7 @@ import com.v2ray.ang.util.Utils
abstract class BaseActivity : AppCompatActivity() {
// Progress indicator that sits at the bottom of the toolbar
private var progressBar: LinearProgressIndicator? = null
protected val permissionRequester = permissionRequester()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
@@ -213,4 +215,18 @@ abstract class BaseActivity : AppCompatActivity() {
return progressBar?.visibility == View.VISIBLE
}
/**
* Check if permission is granted and request it if not.
* Convenience method that delegates to permissionRequester.
*
* @param permissionType The type of permission to check and request
* @param onGranted Callback to execute when permission is granted
*/
protected fun checkAndRequestPermission(
permissionType: PermissionType,
onGranted: () -> Unit
) {
permissionRequester.request(permissionType, onGranted)
}
}
@@ -1,12 +1,9 @@
package com.v2ray.ang.ui
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.ColorStateList
import android.net.Uri
import android.net.VpnService
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
@@ -35,6 +32,7 @@ import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.dto.PermissionType
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.MainViewModel
import kotlinx.coroutines.Dispatchers
@@ -65,40 +63,6 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
}
}
// register activity result for requesting permission
private val requestPermissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
when (pendingAction) {
Action.IMPORT_QR_CODE_CONFIG ->
scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java))
Action.READ_CONTENT_FROM_URI ->
chooseFileForCustomConfig.launch(Intent.createChooser(Intent(Intent.ACTION_GET_CONTENT).apply {
type = "*/*"
addCategory(Intent.CATEGORY_OPENABLE)
}, getString(R.string.title_file_chooser)))
Action.POST_NOTIFICATIONS -> {}
else -> {}
}
} else {
toast(R.string.toast_permission_denied)
}
pendingAction = Action.NONE
}
private var pendingAction: Action = Action.NONE
enum class Action {
NONE,
IMPORT_QR_CODE_CONFIG,
READ_CONTENT_FROM_URI,
POST_NOTIFICATIONS
}
private val chooseFileForCustomConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val uri = it.data?.data
if (it.resultCode == RESULT_OK && uri != null) {
@@ -148,11 +112,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
setupViewModel()
mainViewModel.reloadServerList()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
pendingAction = Action.POST_NOTIFICATIONS
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
checkAndRequestPermission(PermissionType.POST_NOTIFICATIONS) {
}
}
@@ -416,12 +376,8 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
* import config from qrcode
*/
private fun importQRcode(): Boolean {
val permission = Manifest.permission.CAMERA
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
checkAndRequestPermission(PermissionType.CAMERA) {
scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java))
} else {
pendingAction = Action.IMPORT_QR_CODE_CONFIG
requestPermissionLauncher.launch(permission)
}
return true
}
@@ -475,7 +431,9 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
*/
private fun importConfigLocal(): Boolean {
try {
showFileChooser()
checkAndRequestPermission(PermissionType.READ_STORAGE) {
showFileChooser()
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to import config from local file", e)
return false
@@ -596,40 +554,19 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
intent.type = "*/*"
intent.addCategory(Intent.CATEGORY_OPENABLE)
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) {
pendingAction = Action.READ_CONTENT_FROM_URI
chooseFileForCustomConfig.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
} else {
requestPermissionLauncher.launch(permission)
}
chooseFileForCustomConfig.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
}
/**
* read content from uri
*/
private fun readContentFromUri(uri: Uri) {
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 {
contentResolver.openInputStream(uri).use { input ->
importBatchConfig(input?.bufferedReader()?.readText())
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to read content from URI", e)
try {
contentResolver.openInputStream(uri).use { input ->
importBatchConfig(input?.bufferedReader()?.readText())
}
} else {
requestPermissionLauncher.launch(permission)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to read content from URI", e)
}
}
@@ -1,6 +1,5 @@
package com.v2ray.ang.ui
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
@@ -16,13 +15,13 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityRoutingSettingBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.dto.PermissionType
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
import kotlinx.coroutines.Dispatchers
@@ -43,16 +42,6 @@ class RoutingSettingActivity : BaseActivity() {
resources.getStringArray(R.array.preset_rulesets)
}
private val requestCameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
scanQRcodeForRulesets.launch(Intent(this, ScannerActivity::class.java))
} else {
toast(R.string.toast_permission_denied)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
@@ -88,7 +77,7 @@ class RoutingSettingActivity : BaseActivity() {
R.id.add_rule -> startActivity(Intent(this, RoutingEditActivity::class.java)).let { true }
R.id.import_predefined_rulesets -> importPredefined().let { true }
R.id.import_rulesets_from_clipboard -> importFromClipboard().let { true }
R.id.import_rulesets_from_qrcode -> requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA).let { true }
R.id.import_rulesets_from_qrcode -> importQRcode()
R.id.export_rulesets_to_clipboard -> export2Clipboard().let { true }
else -> super.onOptionsItemSelected(item)
}
@@ -160,6 +149,13 @@ class RoutingSettingActivity : BaseActivity() {
.show()
}
private fun importQRcode(): Boolean {
checkAndRequestPermission(PermissionType.CAMERA) {
scanQRcodeForRulesets.launch(Intent(this, ScannerActivity::class.java))
}
return true
}
private fun export2Clipboard() {
val rulesetList = MmkvManager.decodeRoutingRulesets()
if (rulesetList.isNullOrEmpty()) {
@@ -1,28 +1,16 @@
package com.v2ray.ang.ui
import android.Manifest
import android.content.Intent
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
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.AngConfigManager
import com.v2ray.ang.dto.PermissionType
class ScScannerActivity : BaseActivity() {
private val requestCameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
scanQRCode.launch(Intent(this, ScannerActivity::class.java))
} else {
toast(R.string.toast_permission_denied)
finish()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_none)
@@ -30,7 +18,9 @@ class ScScannerActivity : BaseActivity() {
}
private fun importQRcode(): Boolean {
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
checkAndRequestPermission(PermissionType.CAMERA) {
scanQRCode.launch(Intent(this, ScannerActivity::class.java))
}
return true
}
@@ -1,21 +1,18 @@
package com.v2ray.ang.ui
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityNoneBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.dto.PermissionType
import com.v2ray.ang.util.QRCodeDecoder
import io.github.g00fy2.quickie.QRResult
import io.github.g00fy2.quickie.ScanCustomCode
@@ -47,17 +44,6 @@ class ScannerActivity : BaseActivity() {
}
}
private val requestPermissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
showFileChooser()
} else {
toast(R.string.toast_permission_denied)
}
}
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -106,16 +92,8 @@ class ScannerActivity : BaseActivity() {
}
R.id.select_photo -> {
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) {
checkAndRequestPermission(PermissionType.READ_STORAGE) {
showFileChooser()
} else {
requestPermissionLauncher.launch(permission)
}
true
}
@@ -1,11 +1,9 @@
package com.v2ray.ang.ui
import android.Manifest
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.OpenableColumns
import android.util.Log
@@ -24,6 +22,7 @@ import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.dto.PermissionType
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.UserAssetViewModel
import kotlinx.coroutines.Dispatchers
@@ -40,39 +39,6 @@ class UserAssetActivity : BaseActivity() {
val extDir by lazy { File(Utils.userAssetPath(this)) }
private val requestStoragePermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
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)
}
} else {
toast(R.string.toast_permission_denied)
}
}
private val requestCameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
scanQRCodeForAssetURL.launch(Intent(this, ScannerActivity::class.java))
} else {
toast(R.string.toast_permission_denied)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_user_asset_setting))
@@ -125,14 +91,29 @@ class UserAssetActivity : BaseActivity() {
}
private fun showFileChooser() {
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
checkAndRequestPermission(PermissionType.READ_STORAGE) {
showFileChooser2()
}
requestStoragePermissionLauncher.launch(permission)
}
private fun showFileChooser2() {
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()) { result ->
val uri = result.data?.data
if (result.resultCode == RESULT_OK && uri != null) {
@@ -182,7 +163,9 @@ class UserAssetActivity : BaseActivity() {
}
private fun importAssetFromQRcode(): Boolean {
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
checkAndRequestPermission(PermissionType.CAMERA) {
scanQRCodeForAssetURL.launch(Intent(this, ScannerActivity::class.java))
}
return true
}
@@ -0,0 +1,48 @@
package com.v2ray.ang.util
import android.content.pm.PackageManager
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.v2ray.ang.R
import com.v2ray.ang.dto.PermissionType
import com.v2ray.ang.extension.toast
/**
* Helper for requesting permissions.
*/
class PermissionRequester(private val activity: AppCompatActivity) {
private var permissionCallback: ((Boolean) -> Unit)? = null
private val permissionLauncher: ActivityResultLauncher<String> =
activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
permissionCallback?.invoke(isGranted)
permissionCallback = null
}
/**
* Check the permission and request it if not granted.
*
* @param permissionType the type of permission
* @param onGranted called when permission is granted (called immediately if already granted)
*/
fun request(permissionType: PermissionType, onGranted: () -> Unit) {
val permission = permissionType.getPermission()
if (ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED) {
onGranted()
} else {
permissionCallback = { isGranted ->
if (isGranted) {
onGranted()
} else {
val message = "${activity.getString(R.string.toast_permission_denied)} ${permissionType.getLabel()}"
activity.toast(message)
}
}
permissionLauncher.launch(permission)
}
}
}
fun AppCompatActivity.permissionRequester() = PermissionRequester(this)