How to reference external iOS system state updates in SwiftUI? - ios

There are many possible variants of this question, but take as an example the CNAuthorizationStatus returned by CNContactStore.authorizationStatus(for: .contacts), which can be notDetermined, restricted, denied, or authorized. My goal is to always show the current authorization status in my app's UI.
To expose this to SwiftUI, I might make an ObservableObject called ModelData with a contacts property:
final class ModelData: ObservableObject {
#Published var contacts = Contacts.shared
}
Where contacts contains my contact-specific model code, including Authorization:
class Contacts {
fileprivate let store = CNContactStore()
static let shared = Contacts()
enum Authorization {
case notDetermined
case restricted
case denied
case authorized
}
var authorization: Authorization {
switch CNContactStore.authorizationStatus(for: .contacts) {
case .notDetermined:
return .notDetermined
case .restricted:
return .restricted
case .denied:
return .denied
case .authorized:
return .authorized
#unknown default:
return .notDetermined
}
}
}
And I might add a method that a button could call to request access:
func requestAccess(handler: #escaping (Bool, Error?) -> Void) {
store.requestAccess(for: .contacts) { (granted, error) in
// TODO: tell SwiftUI views to re-check authorization
DispatchQueue.main.async {
handler(granted, error)
}
}
}
And for the sake of simplicity, say my view is just:
Text(String(describing: modelData.contacts.authorization))
So my questions are:
Given that ModelData().contacts.authorization calls a getter function, not a property, how can I inform the SwiftUI view when I know it's changed (e.g. where the TODO is in the requestAccess() function)?
Given that the user can toggle the permission in the Settings app (i.e., the value might change out from under me), how can I ensure the view state is always updated? (Do I need to subscribe to an NSNotification and similarly force a refresh? Or is there a better way?)

As #jnpdx pointed out - using #Published with a class (especially a singleton that never changes) is probably not going to yield any useful results
#Published behaves like CurrentValueSubject and it will trigger an update only in case there are changes in the value it is storing/observing under the hood. Since it is storing a reference to the Contacts.shared instance, it won't provide/trigger any updates for the authorization state changes.
Now to your question -
Given that ModelData().contacts.authorization calls a getter function, not a property, how can I inform the SwiftUI view when I know it's changed
As long as you are directly accessing a value out of the getter ModelData().contacts.authorization, it's just a value of Contacts.Authorization type that does NOT provide any observability.
So even if the value changes over time (from .notDetermined => .authorized), there is no storage (reference point) against which we can compare whether it has changed since last time or not.
We HAVE TO define a storage that can compare the old/new values and trigger updates as needed. This can achieved be by marking authorization as #Published like following -
import SwiftUI
import Contacts
final class Contacts: ObservableObject {
fileprivate let store = CNContactStore()
static let shared = Contacts()
enum Authorization {
case notDetermined
case restricted
case denied
case authorized
}
/// Since we have a storage (and hence a way to compare old/new status values)
/// Anytime a new ( != old ) value is assigned to this
/// It triggers `.send()` which triggers an update
#Published var authorization: Authorization = .notDetermined
init() {
self.refreshAuthorizationStatus()
}
private func refreshAuthorizationStatus() {
authorization = self.currentAuthorization()
}
private func currentAuthorization() -> Authorization {
switch CNContactStore.authorizationStatus(for: .contacts) {
case .notDetermined:
return .notDetermined
case .restricted:
return .restricted
case .denied:
return .denied
case .authorized:
return .authorized
#unknown default:
return .notDetermined
}
}
func requestAccess() {
store.requestAccess(for: .contacts) { [weak self] (granted, error) in
DispatchQueue.main.async {
self?.refreshAuthorizationStatus()
}
}
}
}
struct ContentView: View {
#ObservedObject var contacts = Contacts.shared
var body: some View {
VStack(spacing: 16) {
Text(String(describing: contacts.authorization))
if contacts.authorization == .notDetermined {
Button("Request Access", action: {
contacts.requestAccess()
})
}
}
}
}

I think you have it all working.
This line gets called when user changes the access level from the Settings app.
Text(String(describing: modelData.contacts.authorization))
So your view is always displaying the current state.

Related

How to check if user revoked HealthKit permissions through Settings and act in response to it?

I am using Apple's HealthKit to build an app, and am requesting permission from the user to read and write Sleep Data as soon as a View loads using SwiftUI's .onAppear modifier.
It all works fine and I get the data I need using this method.
However, if a user revokes the read and write permissions for my app through Settings, instead of requesting permission again, the app crashes. This is how I have set things up.
#State var authorized: Bool = false
var body: some View {
Text("\(initTextContent)")
.onAppear {
healthstore.getHealthAuthorization(completionAuth: {success in
authorized = success
if self.authorized == true {
healthstore.getSleepMetrics(startDate: sevendaysAgo, endDate: sevendaysAfter, completion: sleepDictAssign)
}
else {
initTextContent = "Required Authorization Not Provided"
}
})
}
}
I've created a class called healthstore and am simply using HealthKit's requestAuthorization method as follows:
var healthStore: HKHealthStore()
func getHealthAuthorization(completionAuth: #escaping (Bool) -> Void) {
//we will call this inside our view
healthStore.requestAuthorization(toShare: [sleepSampleType], read: [sleepSampleType]) { (success, error) in
if let error = error {
// Handle the error here.
fatalError("*** An error occurred \(error.localizedDescription) ***")
}
else {
completion(success)
}
}
}
If the user revoked the permisions they are revoked. You cannot ask again. If you want to handle this scenario you would need to throw the error and handle it outside of the function by showing a message to the user asking them to enable it again.
Or simply return the success boolean ignoring the error.
func getHealthAuthorization(completionAuth: #escaping (Bool) -> Void) {
//we will call this inside our view
healthStore.requestAuthorization(toShare: [sleepSampleType], read: [sleepSampleType]) { (success, error) in
//ignore error since your func can just return a boolean
completion(success)
}
}

Change view based on permissions with SwiftUI?

I have coded an application that needs the permissions of the photo library. The worry is that it needs it as soon as the application is launched so it must check at the start of the application the permission granted and if it is not determined, ask for it, wait for the result and change the view again! I've been breaking my head for three days, but I can't do it! Can you help me?
Here is my code:
Content view :
struct ContentView: View {
var auth = false
#State var limitedAlert = false
#State var statusPhoto = 0
#State var AuthPictStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
var body: some View {
VStack{}
.task{
while true {
AuthPictStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
}
}
if AuthPictStatus == .authorized {
CardView(canExecute: true)
}
if AuthPictStatus == .denied {
PhotoLybrairyDenied()
}
if AuthPictStatus == .limited {
CardView(canExecute: true)
.onAppear{limitedAlert = true}
.alert(isPresented: $limitedAlert) {
Alert(
title: Text("L'accès au photos est limitées !"),
message: Text("Votre autorisations ne nous permets d'accèder a seulement certaines photos ! De se fait, nous ne pouvons pas trier l'intégralité de vos photos !"),
primaryButton: .destructive(Text("Continuer malgré tout"), action: {
}),
secondaryButton: .default(Text("Modifier l'autorisation"), action: { // 1
guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else {
return
}
if UIApplication.shared.canOpenURL(settingsUrl) {
UIApplication.shared.open(settingsUrl, completionHandler: { (success) in
print("Settings opened: \(success)") // Prints true
})
}
})
)
}
}
if AuthPictStatus == .notDetermined {
CardView(canExecute: false)
.blur(radius: 5)
.disabled(true)
}
}
}
PhotoDeleteApp :
//
// PhotoDeleteApp.swift
// PhotoDelete
//
// Created by Rémy on 09/04/2022.
//
import SwiftUI
import Photos
#main
struct PhotoDeleteApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onAppear{
PHPhotoLibrary.requestAuthorization({status in })
}
}
}
}
//
// PhotoDeleteApp.swift
// PhotoDelete
//
// Created by Rémy on 09/04/2022.
//
import SwiftUI
import Photos
#main
struct PhotoDeleteApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onAppear{
PHPhotoLibrary.requestAuthorization({status in })
}
}
}
}
I dealt with similar headaches related to photo permissions (I'm on SwiftUI and iOS 14). I did some troubleshooting and found that using a SwiftUIViewDelegate to implement custom authorization status wasnt working as expected. I did a test and selected "only selected photos" (which should make the authorization status .limited). Except the authorization status wasn't limited, but .authorized, and .denied only when I denied access entirely.
So in my SwiftUI app, I gave up trying to be fancy and used the package Permissions SwiftUI. It provides some documentation on how to customize the message, but it handles the permissions using a sheet making the implementation carefree (It does not address .limited case either or the bug I described with the user selecting limited being actually full access).
My implementation looks like this, and is in my entry point #main, under the first View HomeTabView(homeViewModel: homeViewModel) inside the WindowGroup { }
.JMModal(showModal: $homeViewModel.showingPermissionsSelector, for: [.photo], autoDismiss: true, autoCheckAuthorization: false, restrictDismissal: false)
.changeHeaderTo("App Permissions")
.changeHeaderDescriptionTo("Export and Import of images requires photos access.")
.changeBottomDescriptionTo("Allowing the app to import photos provides the app access to airdropped and saved user photos.")
I suggest you try it out and see if it's good enough for your purposes.
To include the Permissions SwiftUI package in your project, go to your Project in the left panel, select "Project", go to "Package Dependencies" on the top bar, press the + button, and search for Permissions SwiftUI, there will be many options but only add PermissionsSwiftUIPhoto. If you need other permissions, there are plenty to choose from.
I have the permissions bound to a "import photos" button (in a subview) hence HomeTabViewModel belongs to parent
Button(action: {
let authorization = PHPhotoLibrary.authorizationStatus()
print("switching on image authorization status: \(authorization)")
switch authorization {
case .notDetermined:
parent.homeVM.showingPermissionsSelector = true
case .restricted:
parent.homeVM.showingPermissionsSelector = true
case .denied:
parent.homeVM.showingPermissionsSelector = true
case .authorized:
parent.homeVM.showingImagePicker = true
case .limited:
parent.homeVM.showingImagePicker = true // I've never reached this case (bug?)
#unknown default:
print("unhandled authorization status")
break
}
my homeViewModel (simplified for example)
import SwiftUI
final class HomeTabViewModel: ObservableObject {
#Published var showingPermissionsSelector = false
#Published var showingImagePicker = false
// #Published var showingLimitedSelector = false // Thought I would need this but I dont because there is no differentiation between .authorized and .denied from my testing
}
but you could have your app do an .onAppear { // check auth status and change $homeViewModel.showingPermissionsSelector based on your code's logic}
I dealt with the same problem you are having Rémy, and on one hand I'm glad I dont have to differentiate between .limited and .authorized since it makes it easier for us, but also it's a bit spooky because it means photo authorization is not quite working as expected on iOS...

ATTrackingManager.AuthorizationStatus always returns notDetermined

I don't know can I use this functionality in my UI tests on iOS, but I try it, an have problem with this.
In my UI tests I can choose Allow tracking for my app or I can decline tracking, but after all these actions, I want checkout status IDFA via ATTrackingManager.AuthorizationStatus, but this method always returns notDetermined. If I go to Settings > Privacy > Tracking, here I see that settings applied correctly (switch Allow App To Request To Track is on and switch for my app in right state (on or off)).
I don't have any idea why I recieve wrong AuthorizationStatus.
Here is my code in my XCTestCase:
import AppTrackingTransparency
enum TrackingStatus {
case authorized
case denied
case notDetermined
}
func map(_ status: ATTrackingManager.AuthorizationStatus) -> TrackingStatus {
switch ATTrackingManager.trackingAuthorizationStatus {
case .notDetermined:
return .notDetermined
case .authorized:
return .authorized
default:
return .denied
}
}
func advertisingTrackingStatusCheckout(status: TrackingStatus) {
print("IDFA status: \(ATTrackingManager.trackingAuthorizationStatus)")
var currentTrackingStatus: TrackingStatus {
return map(ATTrackingManager.trackingAuthorizationStatus)
}
guard currentTrackingStatus == status else {
XCTFail("IDFA status: \(currentTrackingStatus), expected: \(status)")
return
}
}
After settings IDFA status in my UI test, i call this method, ex. advertisingTrackingStatusCheckout(status: TrackingStatus.denied)
But it always returns notDetermined.
It behaviors have only one exception: If I manually set switch Allow App To Request To Track to off-state, calling the ATTrackingManager.trackingAuthorizationStatus will returns denied.
Delete the App, And call your function in sceneDidBecomeActive with delay. So once your app become active then it will shown. Am facing the same issue now its resolved. Like this
func sceneDidBecomeActive(_ scene: UIScene) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.requestPermission()
}
}
func requestPermission() {
if #available(iOS 14, *) {
ATTrackingManager.requestTrackingAuthorization { status in
switch status {
case .authorized:
// Tracking authorization dialog was shown
// and we are authorized
print("Authorized Tracking Permission")
// Now that we are authorized we can get the IDFA
case .denied:
// Tracking authorization dialog was
// shown and permission is denied
print("Denied Tracking Permission")
case .notDetermined:
// Tracking authorization dialog has not been shown
print("Not Determined Tracking Permission")
case .restricted:
print("Restricted Tracking Permission")
#unknown default:
print("Unknown Tracking Permission")
}
}
} else {
// Fallback on earlier versions
}
}

Location permission determiner

I'm trying to build a permission determiner class, which basically determine the permission.
So far I've done below code, however I keep getting error in the case statements case LocationUsage.WhenInUse: and case .Always:.
It says that
enum case is not a member fo type PrivateResoure.LocationUsage?
What am I doing wrong in this small struct?
public struct PrivateResource {
public enum LocationUsage {
case WhenInUse
case Always
}
var usage: LocationUsage?
public var isNotDeterminedAuthorization: Bool {
return CLLocationManager.authorizationStatus() == .NotDetermined
}
public var isAuthorized: Bool {
switch usage {
case LocationUsage.WhenInUse:
return CLLocationManager.authorizationStatus() == .AuthorizedWhenInUse
case .Always:
return CLLocationManager.authorizationStatus() == .AuthorizedAlways
}
}
}
I see two problems:
usage is an Optional, so you have to unwrap it.
.AuthorizedWhenInUse and .AuthorizedAlways are not part of the CLAuthorizationStatus choices.
The choices are:
Authorized, Denied, NotDetermined, Restricted
A quick fix:
public var isAuthorized: Bool {
switch usage! {
case LocationUsage.WhenInUse:
return CLLocationManager.authorizationStatus() == .Authorized
case .Always:
return CLLocationManager.authorizationStatus() == .Authorized
}
}
Two notes: it solves the issue but my example solution changes your logic - you may have to adapt. Also, I've force unwrapped the Optional for this example: I'll let you choose the safest way instead that is adapted for your code.

Get notified when the user makes selection for allowing access to Camera in iOS

When the app tries to access the Camera API's in iOS than an OS level alertview is shown.
The user here has to allow access to camera or disable the access.
My question is how can I get notified of the selection made by the user..?
Say he selected don't allow access than is there any notification raised which I can use in my app..?
Any help is appreciated.
Instead of letting the OS show the alert view when the camera appears, you can check for the current authorization status, and request for the authorization manually. That way, you get a callback when the user accepts/rejects your request.
In swift:
let status = AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo)
if status == AVAuthorizationStatus.Authorized {
// Show camera
} else if status == AVAuthorizationStatus.NotDetermined {
// Request permission
AVCaptureDevice.requestAccessForMediaType(AVMediaTypeVideo, completionHandler: { (granted) -> Void in
if granted {
// Show camera
}
})
} else {
// User rejected permission. Ask user to switch it on in the Settings app manually
}
If the user has previously rejected the request, calling requestAccessForMediaType will not show the alert and will execute the completion block immediately. In this case, you can choose to show your custom alert and link the user to the settings page. More info on this here.
Taken from Kens answer, I've created this Swift 3 protocol to handle permission access:
import AVFoundation
protocol PermissionHandler {
func handleCameraPermissions(completion: #escaping ((_ error: Error?) -> Void))
}
extension PermissionHandler {
func handleCameraPermissions(completion: #escaping ((_ error: Error?) -> Void)) {
let status = AVCaptureDevice.authorizationStatus(forMediaType: AVMediaTypeVideo)
switch status {
case .authorized:
completion(nil)
case .restricted:
completion(ClientError.noAccess)
case .notDetermined:
AVCaptureDevice.requestAccess(forMediaType: AVMediaTypeVideo) { granted in
if granted {
completion(nil)
} else {
completion(ClientError.noAccess)
}
}
case .denied:
completion(ClientError.noAccess)
}
}
}
You can then conform to this protocol and call it in your class like so:
handleCameraPermissions() { error in
if let error = error {
//Denied, handle error here
return
}
//Allowed! As you were

Resources