Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Developing And Testing Complex Permission Flows

Developing And Testing Complex Permission Flows

For video at LinkedIn Veronica and her team members have to ask for camera, photo library and microphone permissions. She will cover how they abstracted this code into a shared helper and ensured it was well tested. They ran into interesting challenges with testing all the possible cases of users interacting with the permissions dialogues.

This talk will cover how they solved these challenges and what approach they took.

Presented at iOS Conf SG in Singapore on October 20th, 2017

mathonsunday

October 20, 2017
Tweet

More Decks by mathonsunday

Other Decks in Technology

Transcript

  1. Bug Report #456612 HELP! I uploaded a video from my

    camera roll and when I play it back in the app it has no sound.
  2. vray: A member is saying they can't hear sound for

    a video they uploaded from their camera roll [REDACTED]: Have you checked out the HLS stream? vray: Yeah, the video has sound [REDACTED]: I know this sounds obvious, but did you ask them if volume is turned up on their phones? vray: They said it was. We really need to focus on fixing [REDACTED] issue right now so I'm just gonna close this as cannot reproduce
  3. Hey it's video iOS on call here. I haven't been

    been able to reproduce this issue. Let us know if you run into it again. jira-status: closed
  4. public static let shared = PermissionsHelper() private let photoLibrary: PHPhotoLibraryWrapper

    private let avCaptureDevice: AVCaptureDeviceWrapper private let avAudioSession: AVAudioSessionWrapper init(photoLibrary: PHPhotoLibraryWrapper = PHPhotoLibraryWrapper(), avCaptureDevice: AVCaptureDeviceWrapper = AVCaptureDeviceWrapper(), avAudioSession: AVAudioSessionWrapper = AVAudioSessionWrapper()) { self.photoLibrary = photoLibrary self.avCaptureDevice = avCaptureDevice self.avAudioSession = avAudioSession }
  5. public enum PermissionType { case photoLibrary case camera case microphone

    var noAccessAlertTitle: String { switch self { case .photoLibrary: return StringConstant.noPhotoLibraryAccessAlertTitle case .camera: return StringConstant.noCameraAccessAlertTitle case .microphone: return StringConstant.noMicrophoneAccessAlertTitle } } var noAccessAlertMessage: String { switch self { case .photoLibrary: return StringConstant.noPhotoLibraryAccessAlertMessage case .camera: return StringConstant.noCameraAccessAlertMessage case .microphone: return StringConstant.noMicrophoneAccessAlertMessage } } }
  6. private func requestMicrophoneAccessPermission(fromViewController viewController: UIViewController, completion: @escaping (Bool) -> Void)

    { let status = avAudioSession.recordPermission() switch status { case AVAudioSessionRecordPermission.undetermined: avAudioSession.requestRecordPermission { (granted: Bool) -> Void in if granted { completion(true) } else { self.displayNoAccessAlert(ofType: .microphone, fromViewController: viewController, cancelHandler: { _ in completion(false) }) } } // handle denied and granted case } }
  7. UI Quirks • You won't be able to deep link

    into app se3ngs using your "fake" alert if you don't ask the system alert first • You'll go to the phone se3ngs instead
  8. UIApplica)onOpenSe/ngsURLString Used to create a URL that you can pass

    to the openURL(_:) method. When you open the URL built from this string, the system launches the Se@ngs app and displays the app’s custom se@ngs, if it has any.
  9. Tricky Bit When changing the permissions in device se2ngs, the

    app resets to the ini4al start of the app. For us, that's the feed.
  10. Design Best Prac.ces • Ask for permissions only when you

    need them • If permissions are necessary for the experience, don't let the user proceed without them • If permissions aren't necessary, let them proceed without them
  11. func testCompletionShouldSucceedForMicrophoneAccessGranted() { let mockAVAudioSessionWrapper = MockAVAudioSessionWrapper(status: .undetermined, grantedMicrophoneAccess: true)

    let mockPermissionsHelper = MockPermissionsHelper(avAudioSession: mockAVAudioSessionWrapper) var completionSucceeds = 0 mockPermissionsHelper.requestAccessPermission(ofType: .microphone, fromViewController: UIViewController(), completion: { success in if success { completionSucceeds += 1 } XCTAssertEqual(completionSucceeds, 1, "Completion did not succeed once") }) }
  12. private class MockPHPhotoLibraryWrapper: PhotoLibraryWrapping { let status: PHAuthorizationStatus public init(status:

    PHAuthorizationStatus) { self.status = status } func requestAuthorization(_ handler: @escaping (PHAuthorizationStatus) -> Void) { handler(status) } }
  13. Hardware Features That Are Not Simulated • Mo$on support (accelerometer

    and gyroscope) • Audio and video input (camera and microphone) • Proximity sensor • Barometer • Ambient light sensor
  14. KIF /*! @abstract If present, dismisses a system alert with

    the last bu:on, usually 'Allow'. Returns YES if a dialog was dismissed, NO otherwise. @discussion Use this to dissmiss a locaGon services authorizaGon dialog or a photos access dialog by tapping the 'Allow' bu:on. No acGon is taken if no alert is present. */ - (BOOL)acknowledgeSystemAlert;
  15. EarlGrey • Cannot interact with system alert dialog • Team

    sees this as a known issue and plans to implemet it in the future
  16. func testNoCameraAccessOnTapCamera() { // Step 1 let noPhotoAccessAlertHandler = makeUIInteruptionMonitor(of:

    .photoLibrary, accessGranted: true) app.otherElements["feed"].buttons["feed_open_share_box_camera_button"].tap() // Step 2 app.collectionViews["feed_photo_picker_collection"].cells["photo_picker_camera_cell"].tap() removeUIInterruptionMonitor(noPhotoAccessAlertHandler) // Step 3 let noCameraAccessAlertHandler = makeUIInteruptionMonitor(of: .camera, accessGranted: false) // Step 4 let cancelButton = app.scrollViews.buttons["Cancel"] cancelButton.tap() removeUIInterruptionMonitor(noCameraAccessAlertHandler) // Step 5 let photoPickerTitle = app.staticTexts["feed_photo_picker_title"] waitForElement(element: photoPickerTitle) verifyOnMainCameraRollScreen() }
  17. class VoyagerXCUITestCase: XCTestCase { public func makeUIInteruptionMonitor(of type: AccessHandlerType, accessGranted:

    Bool) -> NSObjectProtocol { // Describes purpose of handler mainly used for debugging var alertHandlerDescription: String switch type { case .photoLibrary: alertHandlerDescription = "No Photo Access Alert Handler" case .camera: alertHandlerDescription = "No Camera Access Alert Handler" case .microphone: alertHandlerDescription = "No Microphone Access Alert Handler" } return addUIInterruptionMonitor(withDescription: alertHandlerDescription, handler: { (alert) -> Bool in if alert.buttons["OK"].exists && accessGranted { alert.buttons["OK"].tap() return true } else if alert.buttons["Don't Allow"].exists && !accessGranted { alert.buttons["Don't Allow"].tap() return true } else { return false } }) } }
  18. class Springboard { static let shared = Springboard() let springboardApp

    = XCUIApplication(privateWithPath: nil, bundleID: "com.apple.springboard")! private init() { } func resetLocationAndPrivacySettings() { XCUIApplication().terminate() let springboardApp = self.springboardApp springboardApp.resolve() let settingsIcon = springboardApp.icons["Settings"] if settingsIcon.exists { // Go to Home Thread.sleep(forTimeInterval: 0.5) XCUIDevice.shared().press(.home) Thread.sleep(forTimeInterval: 0.5) XCUIDevice.shared().press(.home) Thread.sleep(forTimeInterval: 0.5) // Go to Settings settingsIcon.tap() let settings = XCUIApplication(privateWithPath: nil, bundleID: "com.apple.Preferences")! // Reset Location & Privacy Settings settings.tables.staticTexts["General"].tap() settings.tables.staticTexts["Reset"].tap() settings.tables.staticTexts["Reset Location & Privacy"].tap() Thread.sleep(forTimeInterval: 0.5) settings.buttons["Reset Settings"].tap() settings.terminate() } } }
  19. Tricky Bits • In order for the alert handler to

    fire, you must interact with the app a8er crea9ng the UIInterrup9onMonitor (ex. tap on a buAon) • Make sure to remove the handler once you're done. Otherwise the same handler will be invoked for any other alerts that appear.
  20. Fake Alerts • UIAlertController • Does not have customizable accessibility

    iden:fiers • Use UI Recording in Xcode to find the iden:fiers for its bu@ons
  21. Poten&al Solu&ons • Mock the ac+on bu/on to not actually

    take the user to Se5ngs but to act as if the status has been changed • Perform ac+ons, kill app, then relaunch app with the new se5ngs
  22. Changing The Se+ngs • Kills and relaunches the app •

    Func2onally equivalent to running a different scenario test that tests a different permission status.
  23. iOS 11 Changes To Photo Permissions • Apps using the

    na.ve picker no longer have to request access to the user’s en.re library to import just one photo or video. User selec.on of a photo or video is an implied permission. No alert is shown. • For apps with custom UIs, the old alert is s.ll shown. • Write permissions now have their own alert.