add check access camera

This commit is contained in:
dranik
2026-05-08 16:57:35 +03:00
parent a53db6eafe
commit bb56008c3d
15 changed files with 372 additions and 10 deletions
+4
View File
@@ -24,5 +24,9 @@
<string name="notificationSettingsDialogMessage">To show notifications, you must enable notifications in the system settings</string>
<string name="openNotificationSettings">Open notification settings</string>
<string name="cameraPermissionDialogTitle">Camera access</string>
<string name="cameraPermissionDialogMessage">To scan a QR code for device pairing, Amnezia VPN needs access to the camera.</string>
<string name="cameraPermissionContinue">Continue</string>
<string name="tvNoFileBrowser">Please install a file management utility to browse files</string>
</resources>
@@ -73,6 +73,7 @@ private const val CHECK_VPN_PERMISSION_ACTION_CODE = 1
private const val CREATE_FILE_ACTION_CODE = 2
private const val OPEN_FILE_ACTION_CODE = 3
private const val CHECK_NOTIFICATION_PERMISSION_ACTION_CODE = 4
private const val CHECK_CAMERA_PERMISSION_ACTION_CODE = 5
private const val PREFS_NOTIFICATION_PERMISSION_ASKED = "NOTIFICATION_PERMISSION_ASKED"
private const val OPEN_FILE_AFTER_RESUME_DELAY_MS = 400L
@@ -880,6 +881,66 @@ class AmneziaActivity : QtActivity() {
@SuppressLint("UnsupportedChromeOsCameraSystemFeature")
fun isCameraPresent(): Boolean = applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA)
@Suppress("unused")
fun isCameraPermissionGranted(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
@Suppress("unused")
fun requestCameraPermissionForQrPairing() {
if (isCameraPermissionGranted()) {
mainScope.launch {
qtInitialized.await()
QtAndroidController.onCameraPermissionResult(true)
}
return
}
runOnUiThread {
AlertDialog.Builder(this)
.setTitle(R.string.cameraPermissionDialogTitle)
.setMessage(R.string.cameraPermissionDialogMessage)
.setNegativeButton(R.string.cancel) { _, _ ->
mainScope.launch {
qtInitialized.await()
QtAndroidController.onCameraPermissionResult(false)
}
}
.setPositiveButton(R.string.cameraPermissionContinue) { _, _ ->
requestPermission(
Manifest.permission.CAMERA,
CHECK_CAMERA_PERMISSION_ACTION_CODE,
PermissionRequestHandler(
onSuccess = {
mainScope.launch {
qtInitialized.await()
QtAndroidController.onCameraPermissionResult(true)
}
},
onFail = {
mainScope.launch {
qtInitialized.await()
QtAndroidController.onCameraPermissionResult(false)
}
},
onAny = {}
)
)
}
.show()
}
}
@Suppress("unused")
fun openApplicationDetailsSettings() {
try {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
startActivity(this)
}
} catch (e: ActivityNotFoundException) {
Log.e(TAG, "openApplicationDetailsSettings: $e")
}
}
@Suppress("unused")
fun isOnTv(): Boolean = applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
@@ -1179,6 +1240,7 @@ class AmneziaActivity : QtActivity() {
CREATE_FILE_ACTION_CODE -> "CREATE_FILE"
OPEN_FILE_ACTION_CODE -> "OPEN_FILE"
CHECK_NOTIFICATION_PERMISSION_ACTION_CODE -> "CHECK_NOTIFICATION_PERMISSION"
CHECK_CAMERA_PERMISSION_ACTION_CODE -> "CHECK_CAMERA_PERMISSION"
else -> actionCode.toString()
}
}
@@ -34,4 +34,6 @@ object QtAndroidController {
external fun onActivityPaused()
external fun onActivityResumed()
external fun onCameraPermissionResult(granted: Boolean)
}
+1
View File
@@ -44,6 +44,7 @@ set(SOURCES ${SOURCES}
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosnotificationhandler.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosglue.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QRCodeReaderBase.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosPairingCameraAccess.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/StoreKitController.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/AmneziaSceneDelegateHooks.mm
+1
View File
@@ -49,6 +49,7 @@ set(SOURCES ${SOURCES}
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosglue.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QRCodeReaderBase.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosPairingCameraAccess_stub.cpp
)
set(ICON_FILE ${CMAKE_CURRENT_SOURCE_DIR}/images/app.icns)
+2
View File
@@ -65,6 +65,7 @@ set(HEADERS ${HEADERS}
${CLIENT_ROOT_DIR}/core/utils/utilities.h
${CLIENT_ROOT_DIR}/core/utils/managementServer.h
${CLIENT_ROOT_DIR}/core/utils/constants.h
${CLIENT_ROOT_DIR}/platforms/ios/iosPairingCameraAccess.h
)
# Mozilla headres
@@ -155,6 +156,7 @@ set(SOURCES ${SOURCES}
if(NOT IOS AND NOT MACOS_NE)
set(SOURCES ${SOURCES}
${CLIENT_ROOT_DIR}/platforms/ios/QRCodeReaderBase.cpp
${CLIENT_ROOT_DIR}/platforms/ios/iosPairingCameraAccess_stub.cpp
)
endif()
@@ -104,7 +104,8 @@ bool AndroidController::initialize()
{"onImeInsetsChanged", "(I)V", reinterpret_cast<void *>(onImeInsetsChanged)},
{"onSystemBarsInsetsChanged", "(II)V", reinterpret_cast<void *>(onSystemBarsInsetsChanged)},
{"onActivityPaused", "()V", reinterpret_cast<void *>(onActivityPaused)},
{"onActivityResumed", "()V", reinterpret_cast<void *>(onActivityResumed)}
{"onActivityResumed", "()V", reinterpret_cast<void *>(onActivityResumed)},
{"onCameraPermissionResult", "(Z)V", reinterpret_cast<void *>(onCameraPermissionResult)}
};
QJniEnvironment env;
@@ -202,6 +203,21 @@ bool AndroidController::isCameraPresent()
return callActivityMethod<jboolean>("isCameraPresent", "()Z");
}
bool AndroidController::isCameraPermissionGranted()
{
return callActivityMethod<jboolean>("isCameraPermissionGranted", "()Z");
}
void AndroidController::requestCameraPermissionForQrPairing()
{
callActivityMethod("requestCameraPermissionForQrPairing", "()V");
}
void AndroidController::openApplicationDetailsSettings()
{
callActivityMethod("openApplicationDetailsSettings", "()V");
}
bool AndroidController::isOnTv()
{
return callActivityMethod<jboolean>("isOnTv", "()Z");
@@ -583,4 +599,13 @@ void AndroidController::onActivityResumed(JNIEnv *env, jobject thiz)
emit AndroidController::instance()->activityResumed();
}
// static
void AndroidController::onCameraPermissionResult(JNIEnv *env, jobject thiz, jboolean granted)
{
Q_UNUSED(env);
Q_UNUSED(thiz);
emit AndroidController::instance()->cameraPermissionResult(static_cast<bool>(granted));
}
@@ -38,6 +38,9 @@ public:
void closeFd();
QString getFileName(const QString &uri);
bool isCameraPresent();
bool isCameraPermissionGranted();
void requestCameraPermissionForQrPairing();
void openApplicationDetailsSettings();
bool isOnTv();
bool isEdgeToEdgeEnabled();
int getStatusBarHeight();
@@ -77,6 +80,7 @@ signals:
void systemBarsInsetsChanged(int navBarHeightDp, int statusBarHeightDp);
void activityPaused();
void activityResumed();
void cameraPermissionResult(bool granted);
private:
bool isWaitingStatus = true;
@@ -109,6 +113,7 @@ private:
static void onSystemBarsInsetsChanged(JNIEnv *env, jobject thiz, jint navBarHeightDp, jint statusBarHeightDp);
static void onActivityPaused(JNIEnv *env, jobject thiz);
static void onActivityResumed(JNIEnv *env, jobject thiz);
static void onCameraPermissionResult(JNIEnv *env, jobject thiz, jboolean granted);
template <typename Ret, typename ...Args>
static auto callActivityMethod(const char *methodName, const char *signature, Args &&...args);
@@ -0,0 +1,10 @@
#ifndef IOS_PAIRING_CAMERA_ACCESS_H
#define IOS_PAIRING_CAMERA_ACCESS_H
#include <functional>
bool amneziaIosPairingCameraAccessGranted();
void amneziaIosRequestPairingCameraAccess(const std::function<void(bool)> &onDone);
void amneziaIosOpenApplicationSettings();
#endif
@@ -0,0 +1,37 @@
#include "platforms/ios/iosPairingCameraAccess.h"
#import <AVFoundation/AVFoundation.h>
#import <UIKit/UIKit.h>
bool amneziaIosPairingCameraAccessGranted()
{
const AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
return status == AVAuthorizationStatusAuthorized;
}
void amneziaIosRequestPairingCameraAccess(const std::function<void(bool)> &onDone)
{
const AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
if (status == AVAuthorizationStatusAuthorized) {
onDone(true);
return;
}
if (status == AVAuthorizationStatusDenied || status == AVAuthorizationStatusRestricted) {
onDone(false);
return;
}
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo
completionHandler:^(BOOL granted) {
dispatch_async(dispatch_get_main_queue(), ^{
onDone(static_cast<bool>(granted));
});
}];
}
void amneziaIosOpenApplicationSettings()
{
NSURL *url = [NSURL URLWithString:UIApplicationOpenSettingsURLString];
if (url != nil) {
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
}
}
@@ -0,0 +1,13 @@
#include "platforms/ios/iosPairingCameraAccess.h"
bool amneziaIosPairingCameraAccessGranted()
{
return true;
}
void amneziaIosRequestPairingCameraAccess(const std::function<void(bool)> &onDone)
{
onDone(true);
}
void amneziaIosOpenApplicationSettings() {}
@@ -9,6 +9,8 @@
#include <QTimer>
#include <QUuid>
#include "platforms/ios/iosPairingCameraAccess.h"
#if defined(Q_OS_ANDROID)
#include "platforms/android/android_controller.h"
#endif
@@ -109,6 +111,8 @@ PairingUiController::PairingUiController(PairingController *pairingController, S
{
#if defined(Q_OS_ANDROID)
g_pairingUiForAndroidQr = this;
connect(AndroidController::instance(), &AndroidController::cameraPermissionResult, this,
[this](bool granted) { emit pairingCameraAccessFinished(granted); });
#endif
}
@@ -157,6 +161,40 @@ void PairingUiController::openPairingQrScanner()
#endif
}
bool PairingUiController::isPairingCameraAccessGranted() const
{
#if defined(Q_OS_ANDROID)
return AndroidController::instance()->isCameraPermissionGranted();
#elif defined(Q_OS_IOS)
return amneziaIosPairingCameraAccessGranted();
#else
return true;
#endif
}
void PairingUiController::requestPairingCameraAccess()
{
#if defined(Q_OS_ANDROID)
AndroidController::instance()->requestCameraPermissionForQrPairing();
#elif defined(Q_OS_IOS)
amneziaIosRequestPairingCameraAccess([this](bool granted) {
QMetaObject::invokeMethod(
this, [this, granted]() { emit pairingCameraAccessFinished(granted); }, Qt::QueuedConnection);
});
#else
emit pairingCameraAccessFinished(true);
#endif
}
void PairingUiController::openPairingCameraAppSettings()
{
#if defined(Q_OS_ANDROID)
AndroidController::instance()->openApplicationDetailsSettings();
#elif defined(Q_OS_IOS)
amneziaIosOpenApplicationSettings();
#endif
}
bool PairingUiController::applyScannedTextAsPairingUuid(const QString &raw)
{
const QString t = raw.trimmed();
@@ -74,6 +74,13 @@ public slots:
/** Android: system camera activity. iOS: toggle camera from QML. */
void openPairingQrScanner();
/** Mobile: whether the app may use the camera for QR pairing (OS permission). Desktop: true. */
Q_INVOKABLE bool isPairingCameraAccessGranted() const;
/** Mobile: show rationale / system camera permission UI; emits pairingCameraAccessFinished. Desktop: emits granted. */
Q_INVOKABLE void requestPairingCameraAccess();
/** Open system settings for this app (camera can be enabled there). No-op on desktop. */
Q_INVOKABLE void openPairingCameraAppSettings();
/** If \a raw contains a session UUID (not vpn://), emits pairingUuidFromScan and returns true. */
bool applyScannedTextAsPairingUuid(const QString &raw);
@@ -97,6 +104,8 @@ signals:
void pairingUuidFromScan(const QString &uuid);
void tvPairingUiPhaseChanged();
/** After requestPairingCameraAccess(): true if OS granted camera access. */
void pairingCameraAccessFinished(bool granted);
private:
void setTvBusy(bool busy);
@@ -19,19 +19,103 @@ import "../Components"
PageType {
id: root
/** True after "Add Device via QR" until permission result or navigation. */
property bool pendingOpenQrPageAfterCamera: false
/** True after denial: user may enable camera in system settings; resume opens QR page when granted. */
property bool waitingSettingsReturnForQrPage: false
function proceedOpenQrPairingPage() {
PageController.goToPage(PageEnum.PageSettingsApiQrPairingSend)
pendingOpenQrPageAfterCamera = false
waitingSettingsReturnForQrPage = false
}
function showCameraDeniedDrawer() {
showQuestionDrawer(
qsTr("Camera access is required"),
qsTr("Allow camera access to scan the pairing QR code. You can enable it in the system settings for Amnezia VPN."),
qsTr("Open settings"),
qsTr("Cancel"),
function() {
PairingUiController.openPairingCameraAppSettings()
},
function() {
waitingSettingsReturnForQrPage = false
})
}
function tryResumeQrPageAfterCameraSettings() {
if (!waitingSettingsReturnForQrPage || !root.visible) {
return
}
if (PairingUiController.isPairingCameraAccessGranted()) {
proceedOpenQrPairingPage()
}
}
function openAddDeviceViaQr() {
const maxC = ApiAccountInfoModel.data("maxDeviceCount")
const activeC = ApiAccountInfoModel.data("activeDeviceCount")
if (maxC > 0 && activeC >= maxC) {
PageController.goToPage(PageEnum.PageSettingsApiDeviceLimit)
} else {
return
}
if (Qt.platform.os !== "android" && Qt.platform.os !== "ios") {
PageController.goToPage(PageEnum.PageSettingsApiQrPairingSend)
return
}
if (PairingUiController.isPairingCameraAccessGranted()) {
proceedOpenQrPairingPage()
return
}
pendingOpenQrPageAfterCamera = true
PairingUiController.requestPairingCameraAccess()
}
onVisibleChanged: {
if (!visible) {
pendingOpenQrPageAfterCamera = false
waitingSettingsReturnForQrPage = false
}
}
Connections {
target: Qt.application
function onStateChanged() {
if (Qt.application.state !== Qt.ApplicationActive) {
return
}
root.tryResumeQrPageAfterCameraSettings()
}
}
Connections {
target: SettingsController
enabled: Qt.platform.os === "android"
function onActivityResumed() {
root.tryResumeQrPageAfterCameraSettings()
}
}
Connections {
target: PairingUiController
function onPairingCameraAccessFinished(granted) {
if (!root.pendingOpenQrPageAfterCamera) {
return
}
root.pendingOpenQrPageAfterCamera = false
if (granted) {
root.proceedOpenQrPairingPage()
} else {
root.waitingSettingsReturnForQrPage = true
root.showCameraDeniedDrawer()
}
}
function onPhonePairingSucceeded() {
const serverIndex = ServersUiController.getProcessedServerIndex()
if (serverIndex < 0) {
@@ -6,6 +6,7 @@ import QRCodeReader 1.0
import PageEnum 1.0
import Style 1.0
import "./"
import "../Controls2"
import "../Controls2/TextTypes"
import "../Config"
@@ -23,6 +24,10 @@ PageType {
property int lastInvalidPairingQrToastClockMs: 0
/** iOS may deliver many QR frames; guard duplicate step transitions. */
property bool addDeviceConfirmNavigationScheduled: false
/** Mobile: waiting for camera permission before starting scan UI / Android scanner. */
property bool awaitingCameraPermissionForScan: false
/** After denial on scan screen: user may enable camera in settings. */
property bool waitingSettingsReturnForScan: false
Timer {
id: pairingCameraKickTimer
@@ -31,6 +36,38 @@ PageType {
onTriggered: root.restartPairingIosCamera()
}
function startPairingScanAfterPermission() {
if (Qt.platform.os === "android") {
PairingUiController.openPairingQrScanner()
} else if (Qt.platform.os === "ios") {
root.pairingCameraOpen = true
}
}
function showScanCameraDeniedDrawer() {
showQuestionDrawer(
qsTr("Camera access is required"),
qsTr("Allow camera access to scan the pairing QR code. You can enable it in the system settings for Amnezia VPN."),
qsTr("Open settings"),
qsTr("Cancel"),
function() {
PairingUiController.openPairingCameraAppSettings()
},
function() {
root.waitingSettingsReturnForScan = false
})
}
function tryResumeScanAfterCameraSettings() {
if (!root.waitingSettingsReturnForScan || !root.visible || root.pairingWizardStep !== 0) {
return
}
if (PairingUiController.isPairingCameraAccessGranted()) {
root.waitingSettingsReturnForScan = false
root.startPairingScanAfterPermission()
}
}
function restartPairingIosCamera() {
if (Qt.platform.os !== "ios" || !root.pairingCameraOpen) {
return
@@ -63,6 +100,7 @@ PageType {
pairingQrReader.stopReading()
root.pairingCameraOpen = false
root.pairingWizardStep = 0
root.waitingSettingsReturnForScan = false
if (!root.keepPhonePairingInBackgroundOnClose && !PairingUiController.phonePairingBusy) {
PairingUiController.cancelAllPairingActivity()
}
@@ -70,6 +108,27 @@ PageType {
}
}
Connections {
target: Qt.application
function onStateChanged() {
if (Qt.application.state !== Qt.ApplicationActive) {
return
}
root.tryResumeScanAfterCameraSettings()
}
}
Connections {
target: SettingsController
enabled: Qt.platform.os === "android"
function onActivityResumed() {
root.tryResumeScanAfterCameraSettings()
}
}
Connections {
target: root
function onPairingCameraOpenChanged() {
@@ -161,6 +220,11 @@ PageType {
}
enabled: !PairingUiController.phonePairingBusy
clickedFunc: function() {
if (!PairingUiController.isPairingCameraAccessGranted()) {
root.awaitingCameraPermissionForScan = true
PairingUiController.requestPairingCameraAccess()
return
}
if (Qt.platform.os === "android") {
PairingUiController.openPairingQrScanner()
} else {
@@ -238,14 +302,6 @@ PageType {
wrapMode: Text.Wrap
}
ParagraphTextType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
text: qsTr("Devices available with Amnezia Premium: %1").arg(ApiAccountInfoModel.data("availableDeviceSlots"))
wrapMode: Text.Wrap
}
BasicButtonType {
Layout.fillWidth: true
Layout.leftMargin: 16
@@ -296,6 +352,19 @@ PageType {
Connections {
target: PairingUiController
function onPairingCameraAccessFinished(granted) {
if (!root.awaitingCameraPermissionForScan) {
return
}
root.awaitingCameraPermissionForScan = false
if (granted) {
root.startPairingScanAfterPermission()
} else {
root.waitingSettingsReturnForScan = true
root.showScanCameraDeniedDrawer()
}
}
function onPairingUuidFromScan(uuid) {
if (root.addDeviceConfirmNavigationScheduled) {
return