Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Connect] Enable file downloads #4086

Merged
merged 11 commits into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets us test the SDK on iOS 15 devices

IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
Expand Down Expand Up @@ -577,7 +577,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
Expand All @@ -594,13 +594,13 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = StripeConnectExample/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
Expand All @@ -621,13 +621,13 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = StripeConnectExample/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ struct API {
guard let baseUrl = URL(string: baseURL) else {
return .failure(.invalidURL)
}
let url = baseUrl.appending(path: path)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

appending(path: ) is only available on iOS 16

let url = baseUrl.appendingPathComponent(path)
var request = URLRequest(url: url)
request.httpMethod = method
request.allHTTPHeaderFields = headers
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="32700.99.1234" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23094" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22685"/>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23084"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
Expand All @@ -16,16 +17,20 @@
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="StripeConnect Example" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="tpl-NI-fTA">
<rect key="frame" x="49" y="409" width="294" height="35"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="StripeConnect Example" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="tpl-NI-fTA">
<rect key="frame" x="20" y="408.66666666666669" width="353" height="35"/>
<fontDescription key="fontDescription" type="system" pointSize="29"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstItem="6Tk-OE-BBY" firstAttribute="trailing" secondItem="tpl-NI-fTA" secondAttribute="trailing" constant="20" id="C34-2d-4Rw"/>
<constraint firstItem="tpl-NI-fTA" firstAttribute="leading" secondItem="6Tk-OE-BBY" secondAttribute="leading" constant="20" id="HH7-od-x32"/>
<constraint firstItem="tpl-NI-fTA" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="fIw-p1-lqC"/>
</constraints>
Comment on lines +29 to +33
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centers the launchscreen text with 20pt horizontal padding

</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
Expand Down
4 changes: 2 additions & 2 deletions StripeConnect/StripeConnect.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@
41D17A6D2C5A7429007C6EE6 /* StripeiOS-Shared.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 41D17A622C5A7429007C6EE6 /* StripeiOS-Shared.xcconfig */; };
41D17A6E2C5A7429007C6EE6 /* Version.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 41D17A632C5A7429007C6EE6 /* Version.xcconfig */; };
E6165CBF2CA7BF2200B76DA5 /* FetchInitComponentPropsMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6165CBE2CA7BF2200B76DA5 /* FetchInitComponentPropsMessageHandler.swift */; };
E65691222CA52D5900E0DB00 /* StripeConnect+Exports.swift in Sources */ = {isa = PBXBuildFile; fileRef = E65691212CA52D5900E0DB00 /* StripeConnect+Exports.swift */; };
E6165CC12CA7D09900B76DA5 /* FetchInitComponentPropsMessageHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6165CC02CA7D09900B76DA5 /* FetchInitComponentPropsMessageHandlerTests.swift */; };
E65691222CA52D5900E0DB00 /* StripeConnect+Exports.swift in Sources */ = {isa = PBXBuildFile; fileRef = E65691212CA52D5900E0DB00 /* StripeConnect+Exports.swift */; };
E65691202CA5248300E0DB00 /* AccountManagementViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E656911F2CA5248300E0DB00 /* AccountManagementViewControllerTests.swift */; };
E6C5F5F62C9FEE0200861709 /* AccountManagementViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6C5F5F52C9FEE0200861709 /* AccountManagementViewController.swift */; };
E6F485F82C9E35A5000D914F /* PaymentDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6F485F72C9E35A5000D914F /* PaymentDetailsViewController.swift */; };
Expand Down Expand Up @@ -190,11 +190,11 @@
41D17A612C5A7429007C6EE6 /* StripeiOS-Release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = "StripeiOS-Release.xcconfig"; sourceTree = "<group>"; };
41D17A622C5A7429007C6EE6 /* StripeiOS-Shared.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = "StripeiOS-Shared.xcconfig"; sourceTree = "<group>"; };
41D17A632C5A7429007C6EE6 /* Version.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = "<group>"; };
E65691212CA52D5900E0DB00 /* StripeConnect+Exports.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StripeConnect+Exports.swift"; sourceTree = "<group>"; };
E6165CBE2CA7BF2200B76DA5 /* FetchInitComponentPropsMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchInitComponentPropsMessageHandler.swift; sourceTree = "<group>"; };
E656911F2CA5248300E0DB00 /* AccountManagementViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountManagementViewControllerTests.swift; sourceTree = "<group>"; };
E6C5F5F52C9FEE0200861709 /* AccountManagementViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountManagementViewController.swift; sourceTree = "<group>"; };
E6165CC02CA7D09900B76DA5 /* FetchInitComponentPropsMessageHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchInitComponentPropsMessageHandlerTests.swift; sourceTree = "<group>"; };
E65691212CA52D5900E0DB00 /* StripeConnect+Exports.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StripeConnect+Exports.swift"; sourceTree = "<group>"; };
E6F485F72C9E35A5000D914F /* PaymentDetailsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentDetailsViewController.swift; sourceTree = "<group>"; };
E6F485FB2C9E360A000D914F /* ConnectJSURLParams.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConnectJSURLParams.swift; sourceTree = "<group>"; };
E6F485FD2C9E36B2000D914F /* PaymentDetailsViewControllerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentDetailsViewControllerTests.swift; sourceTree = "<group>"; };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ enum StripeConnectConstants {
/**'
Pages or navigation requests matching any of these hosts will...
- Automatically grant camera permissions
- Accept downloads (TODO MXMOBILE-2485)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not using allowedHosts for downloads because most of our downloads come from external hosts (e.g. S3)

- Open popups in PopupWebViewController (instead of Safari)
*/
static let allowedHosts: Set<String> = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Created by Mel Ludowise on 5/3/24.
//

import QuickLook
import SafariServices
@_spi(STP) import StripeCore
import WebKit
Expand All @@ -14,11 +15,14 @@ import WebKit
- Camera access
- Popup windows
- Opening email links
- Downloads TODO MXMOBILE-2485
- Downloads
*/
@available(iOS 15, *)
class ConnectWebView: WKWebView {

/// File URL for a downloaded file
var downloadedFile: URL?

Comment on lines +23 to +25
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mxl-stripe do we need to be robust to potentially handle multiple files that simultaneous download?

The current design only stores one file at a time. If there's multiple downloads that occur simultaneously, we could get some unexpected behavior.

If we need to, we could potentially handle multiple files, but it would take some effort. We'd have to effectively make a queue of downloaded files and wait for them to all finish before showing the preview.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should ever be triggering multiple downloads at the same time. Is it easy to detect if that is happening? I wonder if we could add a metric for that situation and monitor that it's zero.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we should be able to add a metric for this – I added a comment to MXMOBILE-2491 so I remember to do that when adding analytics :-)

private var optionalPresentPopup: ((UIViewController) -> Void)?

/// Closure to present a popup web view controller.
Expand All @@ -40,15 +44,20 @@ class ConnectWebView: WKWebView {
/// The instance that will handle opening external urls
let urlOpener: ApplicationURLOpener

/// The file manager responsible for creating temporary file directories to store downloads
let fileManager: FileManager

/// The current version for the SDK
let sdkVersion: String?

init(frame: CGRect,
configuration: WKWebViewConfiguration,
// Only override for tests
urlOpener: ApplicationURLOpener = UIApplication.shared,
fileManager: FileManager = .default,
sdkVersion: String? = StripeAPIConfiguration.STPSDKVersion) {
self.urlOpener = urlOpener
self.fileManager = fileManager
self.sdkVersion = sdkVersion
configuration.applicationNameForUserAgent = "- stripe-ios/\(sdkVersion ?? "")"
super.init(frame: frame, configuration: configuration)
Expand Down Expand Up @@ -101,6 +110,17 @@ private extension ConnectWebView {
func openOnSystem(url: URL) {
urlOpener.openIfPossible(url)
}

func showErrorAlert(for error: Error) {
// TODO: MXMOBILE-2491 Log analytic when receiving an eror
debugPrint(error)

let alert = UIAlertController(
title: nil,
message: NSError.stp_unexpectedErrorMessage(),
preferredStyle: .alert)
presentPopup(alert)
}
Comment on lines +114 to +123
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Displays "There was an unexpected error -- try again in a few seconds"

}

// MARK: - WKUIDelegate
Expand Down Expand Up @@ -158,27 +178,128 @@ extension ConnectWebView: WKNavigationDelegate {
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction
) async -> WKNavigationActionPolicy {
// TODO: MXMOBILE-2485 Handle downloads
.allow
/*
`shouldPerformDownload` will be true if the request has MIME types
or a `Content-Type` header indicating it's a download or it originated
as a JS download.

NOTE: We sometimes can't know if a request should be a download until
after its response is received. Those cases are handled by
`decidePolicyFor navigationResponse` below.
*/
navigationAction.shouldPerformDownload ? .download : .allow
}

func webView(
_ webView: WKWebView,
decidePolicyFor navigationResponse: WKNavigationResponse
) async -> WKNavigationResponsePolicy {
// TODO: MXMOBILE-2485 Handle downloads
.allow

// Downloads will typically originate from a non-allow-listed host (e.g. S3)
// so first check if the response is a download before evaluating the host

// The response should be a download if its Content-Disposition is
// shaped like `attachment; filename=payouts.csv`
if navigationResponse.canShowMIMEType,
let response = navigationResponse.response as? HTTPURLResponse,
let contentDisposition = response.value(forHTTPHeaderField: "Content-Disposition"),
contentDisposition
.split(separator: ";")
.map({ $0.trimmingCharacters(in: .whitespaces) })
.caseInsensitiveContains("attachment") {
return .download
}

return .allow
}

func webView(_ webView: WKWebView,
navigationAction: WKNavigationAction,
didBecome download: WKDownload) {
// TODO: MXMOBILE-2485 Handle downloads
download.delegate = self
}

func webView(_ webView: WKWebView,
navigationResponse: WKNavigationResponse,
didBecome download: WKDownload) {
// TODO: MXMOBILE-2485 Handle downloads
download.delegate = self
}
}

// MARK: - WKDownloadDelegate implementation

@available(iOS 15, *)
extension ConnectWebView {
// This extension is an abstraction layer to implement `WKDownloadDelegate`
// functionality and make it testable. There's no way to instantiate
// `WKDownload` in tests without causing an EXC_BAD_ACCESS error.

func download(decideDestinationUsing response: URLResponse,
suggestedFilename: String) async -> URL? {
// The temporary filename must be unique or the download will fail.
// To ensure uniqueness, append a UUID to the directory path in case a
// file with the same name was already downloaded from this app.
let tempDir = fileManager
.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
do {
try fileManager.createDirectory(at: tempDir, withIntermediateDirectories: true)
} catch {
showErrorAlert(for: error)
return nil
}

downloadedFile = tempDir.appendingPathComponent(suggestedFilename)
return downloadedFile
}

func download(didFailWithError error: any Error,
resumeData: Data?) {
showErrorAlert(for: error)
}

func downloadDidFinish() {

// Display a preview of the file to the user
let previewController = QLPreviewController()
previewController.dataSource = self
previewController.modalPresentationStyle = .pageSheet
presentPopup(previewController)
Comment on lines +263 to +267
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would we want to just present the share sheet directly here rather than having to fix potential bugs involving previewing certain file types?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could – let me get feedback from the team what the preference is.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we can check if the file is previewable, and if it's not then directly open the share sheet.
https://developer.apple.com/documentation/quicklook/qlpreviewcontroller/canpreview(_:)

Really good call out – from the docs:

Discussion
If the system can’t display an item, but you still attempt to display it, a Quick Look preview controller displays a generic error. Always check whether you can display an item before choosing to do so.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to merge this but do a fast-follow PR to address this issue

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
}

// MARK: - WKDownloadDelegate

@available(iOS 15, *)
extension ConnectWebView: WKDownloadDelegate {
func download(_ download: WKDownload,
decideDestinationUsing response: URLResponse,
suggestedFilename: String) async -> URL? {
await self.download(decideDestinationUsing: response,
suggestedFilename: suggestedFilename)
}

func download(_ download: WKDownload,
didFailWithError error: any Error,
resumeData: Data?) {
self.download(didFailWithError: error, resumeData: resumeData)
}

func downloadDidFinish(_ download: WKDownload) {
self.downloadDidFinish()
}
}

// MARK: - QLPreviewControllerDataSource

@available(iOS 15, *)
extension ConnectWebView: QLPreviewControllerDataSource {
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
downloadedFile == nil ? 0 : 1
}

func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> any QLPreviewItem {
// Okay to force-unwrap since numberOfPreviewItems returns 0 when downloadFile is nil
downloadedFile! as QLPreviewItem
}
}
Loading
Loading