Change view based on permissions with SwiftUI? - ios

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...

Related

Biometrics prompt not appearing after first successful authentication

I have a working example below, but a bit of an explanation.
I want the user to be able to toggle the option to unlock their app data with biometrics (or not if preferred). If they activate the toggle, once the app resigns to background or has been terminated the next time it is launched they should be prompted to log in.
This portion of the app functionality I have operational. However, once the user logs in once, resigns to background and then relaunches they are in instantly.
I altered the codebase so that the "permission" bool was set to false, however when the view to authenticate prompts them, there is none of the Apple biometrics, they are simply granted access.
I tried using the LAContext.invalidate but after adding that into the check when resigning of background the biometric prompts never reappear - unless fully terminated.
Am I missing something or how do other apps like banking create the prompt on every foreground instance?
// main.swift
#main
struct MyApp: App {
#StateObject var biometricsVM = BiometricsViewModel()
var body: some Scene {
WindowGroup {
// toggle for use
if UserDefaults.shared.bool(forKey: .settingsBiometrics) {
// app unlocked
if biometricsVM.authorisationGranted {
MyView() // <-- the app view itself
.onAppear {
NotificationCenter.default.addObserver(
forName: UIApplication.willResignActiveNotification,
object: nil,
queue: .main
) { _ in
biometricsVM.context.invalidate()
biometricsVM.authorisationGranted = false
}
}
} else {
BioCheck(vm: biometricsVM)
}
}
}
}
}
// biometricsVM.swift
final class BiometricsViewModel: ObservableObject {
#Published var authorisationGranted = false
#Published var authorisationError: Error?
let context = LAContext()
func requestAuthorisation() {
var error: NSError? = nil
let hasBiometricsEnabled = context.canEvaluatePolicy(
.deviceOwnerAuthentication, error: &error
)
let reason = "Unlock to gain access to your data"
if hasBiometricsEnabled {
switch context.biometryType {
case .touchID, .faceID:
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason ) { success, error in
DispatchQueue.main.async {
self.authorisationGranted = success
self.authorisationError = error
}
}
case .none:
// other stuff
#unknown default:
// other stuff
}
}
}
}
// biocheck.swift
struct BioCheck: View {
#ObservedObject var vm: BiometricsViewModel
var body: some View {
Button {
vm.requestAuthorisation()
} label: {
Text("Authenticate")
}
.onAppear { vm.requestAuthorisation() }
}
}
Video of issue:
The problem is that the code in MyApp runs once the app opens similar to didFinishLaunchingWithOptions. To fix this, create a new View & place the following code in it:
if UserDefaults.shared.bool(forKey: .settingsBiometrics) {
if biometricsVM.authorisationGranted {
MyView()
.onAppear {
NotificationCenter.default.addObserver(
forName: UIApplication.willResignActiveNotification,
object: nil,
queue: .main
) { _ in
biometricsVM.context.invalidate()
biometricsVM.authorisationGranted = false
}
}
} else {
BioCheck(vm: biometricsVM)
}
}
Then replace the content of WindowGroup with the View you created.
Edit:
It was the function requestAuthorisation giving an error related to context. You should create a new context every time you call that function:
func requestAuthorisation() {
var error: NSError? = nil
let context = LAContext()
let hasBiometricsEnabled = context.canEvaluatePolicy(
.deviceOwnerAuthentication, error: &error
)
let reason = "Unlock to gain access to your data"
if hasBiometricsEnabled {
switch context.biometryType {
case .touchID, .faceID:
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason ) { success, error in
DispatchQueue.main.async {
self.authorisationGranted = success
self.authorisationError = error
}
}
case .none:
// other stuff
break
#unknown default:
break
}
}
}

iOS 15: How to display ATT dialog when the app starts in SwiftUI

In iOS 14, It could display ATT (App Tracking Transparency) dialog when app starts in SwiftUI as follows.
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
if #available(iOS 14, *) {
ATTrackingManager.requestTrackingAuthorization(completionHandler: { status in
// loadAd()
})
} else {
// loadAd()
}
return true
}
But, in iOS 15.0, it does not work. Apple document describes as follows.
Calls to the API only prompt when the application state is: UIApplicationStateActive. Calls to the API through an app extension do not prompt.
https://developer.apple.com/documentation/apptrackingtransparency/attrackingmanager/3547037-requesttrackingauthorization
How to display ATT dialog when the app starts in iOS 15 ?
2021/9/28 update
I solved it as follows.
struct HomeView: View {
var body: some View {
VStack {
Text("Hello!")
}.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
ATTrackingManager.requestTrackingAuthorization(completionHandler: { status in })
}
}
}
Instead of calling app tracking transparency permission in didFinishLaunchingWithOptions call in applicationDidBecomeActive it will solve your issue
In AppDelegate
func applicationDidBecomeActive(_ application: UIApplication) {
requestDataPermission()
}
func requestDataPermission() {
if #available(iOS 14, *) {
ATTrackingManager.requestTrackingAuthorization(completionHandler: { status in
switch status {
case .authorized:
// Tracking authorization dialog was shown
// and we are authorized
print("Authorized")
case .denied:
// Tracking authorization dialog was
// shown and permission is denied
print("Denied")
case .notDetermined:
// Tracking authorization dialog has not been shown
print("Not Determined")
case .restricted:
print("Restricted")
#unknown default:
print("Unknown")
}
})
} else {
//you got permission to track, iOS 14 is not yet installed
}
}
in info.plist
<key>NSUserTrackingUsageDescription</key>
<string>Reason_for_data_tracking</string>
As #donchan already mentioned use the following code:
struct HomeView: View {
var body: some View {
VStack {
Text("Hello!")
}.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
ATTrackingManager.requestTrackingAuthorization(completionHandler: { status in })
}
}
}
For iOS 15, I faced the same problems and I fixed them by delaying code execution for a second.
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if #available(iOS 14, *) {
ATTrackingManager.requestTrackingAuthorization(completionHandler: { status in
DispatchQueue.main.async {
self.bannerView.load(GADRequest())
self.interstitial.load(request)
}
})
} else {
// Fallback on earlier versions
self.bannerView.load(GADRequest())
self.interstitial.load(request)
}
}
A very important addition to all the answers above: the ATT dialogue must be invoked once!
If, for example, inside the advertising manager you have repeated calls to the ATT dialog before requesting an advertisement (as it was for previous OS versions), then the dialog WILL NOT be shown! Therefore, the ATT dialogue request must be inserted directly into the view and with a delay of at least 1 second for its unconditional triggering.
If you are writing a SwiftUI app, you can trigger it on your start screen.
struct HomeView: View {
var body: some View {
VStack {
Text("Hello!")
}.onAppear {
ATTrackingManager.requestTrackingAuthorization(completionHandler: { status in })
}
}
}
Do not forget to add the necessary additions to the .plist.
<key>NSUserTrackingUsageDescription</key>
<string>...</string>
Thus, it will run on the simulator or real device.

How to reference external iOS system state updates in SwiftUI?

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.

Using reachability library to Swiftui based app to notify when network is lost

I'm very much new to ios && using cocoapods.
I scanned over SO to find easiest way to detect network status and lots of answers directed me to Reachability git by AshleyMills.
I'm writing my webView app in swiftui & I want to pop up an alert/notifier when user's internet connection is lost. (So they don't sit idle while my webview tries to load)
It would be best if listner to network changes keeps running in the background while the app is on.
Most of the answers in SO seem to be Swift-based (appdelegate, ViewDidload, etc), which I don't know how to use because I started off with SwiftUI
Thanks in advance.
Edit(With attempts for Workaround that Lukas provided)
I tried the below. It compiles and runs BUT the alert wouldn't show up. Nor does it respond to connection change.
I have number of subViews in my ContentView. So I called timer && reachability on app level.
#State var currentDate = Date()
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
let reachability = try! Reachability()
#State private var showAlert: Bool = false
It seems to be not working:
WindowGroup {
ContentView()
.onAppear() {
if !isConnected() {
self.showAlert = true
}
}
.onReceive(timer) { _ in
if !isConnected() {
self.showAlert = true
}else{
self.showAlert = false
}
}
.alert(isPresented: $showAlert) {
Alert(title: Text("Error"), message: Text("Your internet connection is too slow."), dismissButton: .default(Text("ok")))
}
}
NWPathMonitor was introduced in iOS 12 as a replacement for Reachability. A naive implementation would be as follows.
We could expose the status that comes back from pathUpdateHandler, however that would require you to import Network everywhere you wanted to use the status - not ideal. It would be better to create your own enum that maps to each of the cases provided by NWPath.Status, you can see the values here. I’ve created one that just handles the connected or unconnected states.
So we create a very simple ObservedObject that publishes our status. Note that as the monitor operates on its own queue and we may want to update things in the view we will need to make sure that we publish on the main queue.
import Network
import SwiftUI
// An enum to handle the network status
enum NetworkStatus: String {
case connected
case disconnected
}
class Monitor: ObservableObject {
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "Monitor")
#Published var status: NetworkStatus = .connected
init() {
monitor.pathUpdateHandler = { [weak self] path in
guard let self = self else { return }
// Monitor runs on a background thread so we need to publish
// on the main thread
DispatchQueue.main.async {
if path.status == .satisfied {
print("We're connected!")
self.status = .connected
} else {
print("No connection.")
self.status = .disconnected
}
}
}
monitor.start(queue: queue)
}
}
Then we can use our Monitor class how we like, you could use it as a StateObject as I have done below, or you could use it as an EnvironmentObject. The choice is yours. Ideally you should have only a single instance of this class in your app.
struct ContentView: View {
#StateObject var monitor = Monitor()
// #EnvironmentObject var monitor: Monitor
var body: some View {
Text(monitor.status.rawValue)
}
}
Tested in Playgrounds on an iPad Pro running iOS 14.2, and on Xcode 12.2 using iPhone X (real device) on iOS 14.3.
I worked one time with rechabiltiy and had the same problem. My solution is a work-around but in my case it was fine.
When the user get to the view where the connection should be constantly checked you can start a time(https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-a-timer-with-swiftui). The timer calls a function in an interval of your choice where you can check the connection with the following code:
let reachability = try! Reachability()
...
func isConnected() -> Bool{
if reachability.connection == .none{
return "false" //no Connection
}
return true
}
}
You can also call this function .onAppear

Making use of UNLocationNotificationTrigger

I am trying to implement location-triggered-notification in an iOS app, which I recently started using SwiftUI.
As a first experimenting step, I have a button. And when tapped it should create a notification intented to fire once I get close to a certain location. As a starting point I read this page.
But it is not all crystal clear when putting it into practice.
The location part is working but not the notification one.
Though I have used CoreLocation and UserNotifications before, I have no previous experience with location triggered notifications.
Hereafter is the relevant (not working) code that I have at this point.
Any tip or advice, in the right direction to make it work will be welcome.
import SwiftUI
import MapKit
struct ContentView: View {
......
func handleShareLocation() {
print(#function)
//TRIAL-CODE
guard CLLocationManager.locationServicesEnabled() else {
return
}
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .sound, .badge],
completionHandler: { _,_ in //[weak self] granted, _ in
/*guard granted else {
self?.delegate?.notificationPermissionDenied()
return
}*/
let notificationInfo = LocationNotificationInfo (notificationId: "nyc_promenade_notification_id",
locationId: "nyc_promenade_location_id",
radius: 500.0,
latitude: 40.696503,
longitude: -73.997809,
title: "Welcome to the Brooklyn Promenade!",
body: "Tap to see more information",
data: ["location": "NYC Brooklyn Promenade"])
self?.requestNotification(notificationInfo: notificationInfo)
})
//TRIAL-CODE
}
var body: some View {
VStack {
Spacer()
Button(action: {
self.handleShareLocation()
}) {
Text("Create location notification")
.padding()
}
Spacer()
}
}
}
Beside, I am also using the class:
class LocationNotificationScheduler: NSObject {
........
}
coming from the document I mentioned.
In there two unclear points are:
Where is LocationNotificationSchedulerDelegate coming from?
What is notificationScheduled?
Those two points are causing error messages like:
Use of undeclared type 'LocationNotificationSchedulerDelegate'
Let me know if more information is needed.

Resources