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

[iOS] - Add ability to download and install PKPasses (Bundled) #27258

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions ios/BUILD.gn
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import("//brave/ios/browser/api/skus/headers.gni")
import("//brave/ios/browser/api/storekit_receipt/headers.gni")
import("//brave/ios/browser/api/translate/headers.gni")
import("//brave/ios/browser/api/unicode/headers.gni")
import("//brave/ios/browser/api/unzip/headers.gni")
import("//brave/ios/browser/api/url/headers.gni")
import("//brave/ios/browser/api/url_sanitizer/headers.gni")
import("//brave/ios/browser/api/web/ui/headers.gni")
Expand Down Expand Up @@ -131,6 +132,7 @@ brave_core_public_headers += browser_api_storekit_receipt_public_headers
brave_core_public_headers += webcompat_reporter_public_headers
brave_core_public_headers += browser_api_translate_public_headers
brave_core_public_headers += browser_api_unicode_public_headers
brave_core_public_headers += browser_api_unzip_public_headers

action("brave_core_umbrella_header") {
script = "//build/config/ios/generate_umbrella_header.py"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ extension BrowserViewController: WKDownloadDelegate {
) async -> URL? {

if let httpResponse = response as? HTTPURLResponse {
if httpResponse.mimeType != MIMEType.passbook {
if ![MIMEType.passbook, MIMEType.passbookBundle].contains(httpResponse.mimeType?.lowercased())
{
let shouldDownload = await downloadAlert(
download,
response: response,
Expand Down Expand Up @@ -83,7 +84,9 @@ extension BrowserViewController: WKDownloadDelegate {
textEncodingName: downloadInfo.response.textEncodingName
)

if downloadInfo.response.mimeType == MIMEType.passbook {
if [MIMEType.passbook, MIMEType.passbookBundle].contains(
downloadInfo.response.mimeType?.lowercased()
) {
downloadQueue.download(downloadInfo, didFinishDownloadingTo: downloadInfo.fileURL)
if let passbookHelper = OpenPassBookHelper(
request: nil,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -789,7 +789,7 @@ extension BrowserViewController: WKNavigationDelegate {
return .download
}

if response.mimeType == MIMEType.passbook {
if [MIMEType.passbook, MIMEType.passbookBundle].contains(response.mimeType?.lowercased()) {
return .download
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

import BraveCore
import BraveShared
import Foundation
import MobileCoreServices
import PassKit
import Shared
import UniformTypeIdentifiers
import WebKit
import os.log

struct MIMEType {
static let bitmap = "image/bmp"
Expand All @@ -19,6 +21,7 @@ struct MIMEType {
static let html = "text/html"
static let octetStream = "application/octet-stream"
static let passbook = "application/vnd.apple.pkpass"
static let passbookBundle = "application/vnd.apple.pkpasses"
static let pdf = "application/pdf"
static let plainText = "text/plain"
static let png = "image/png"
Expand Down Expand Up @@ -128,6 +131,7 @@ class DownloadHelper: NSObject {
}

class OpenPassBookHelper: NSObject {
private let mimeType: String
fileprivate var url: URL

fileprivate let browserViewController: BrowserViewController
Expand All @@ -139,29 +143,22 @@ class OpenPassBookHelper: NSObject {
forceDownload: Bool,
browserViewController: BrowserViewController
) {
guard let mimeType = response.mimeType, mimeType == MIMEType.passbook,
guard let mimeType = response.mimeType,
[MIMEType.passbook, MIMEType.passbookBundle].contains(mimeType.lowercased()),
PKAddPassesViewController.canAddPasses(),
let responseURL = response.url, !forceDownload
else { return nil }
self.mimeType = mimeType
self.url = responseURL
self.browserViewController = browserViewController
super.init()
}

@MainActor func open() async {
let passData = try? await Task.detached { [url] in
try Data(contentsOf: url)
}.value
guard let passData else { return }
do {
let pass = try PKPass(data: passData)
let passLibrary = PKPassLibrary()
if passLibrary.containsPass(pass) {
await UIApplication.shared.open(pass.passURL!, options: [:])
} else {
if let addController = PKAddPassesViewController(pass: pass) {
browserViewController.present(addController, animated: true, completion: nil)
}
let passes = try await parsePasses()
if let addController = PKAddPassesViewController(passes: passes) {
browserViewController.present(addController, animated: true, completion: nil)
}
} catch {
// display an error
Expand All @@ -179,4 +176,41 @@ class OpenPassBookHelper: NSObject {
return
}
}

private func parsePasses() async throws -> [PKPass] {
if mimeType == MIMEType.passbookBundle {
let url = try await ZipImporter.unzip(path: url)
let files = await enumerateFiles(in: url, withExtensions: ["pkpass", "pkpasses"])
let result = try files.map { try PKPass(data: Data(contentsOf: $0)) }
try await AsyncFileManager.default.removeItem(at: url)
return result
}

let passData = try Data(contentsOf: url)
return try [PKPass(data: passData)]
}

func enumerateFiles(
in directory: URL,
withExtensions extensions: [String] = []
) async -> [URL] {
let enumerator = AsyncFileManager.default.enumerator(
at: directory,
includingPropertiesForKeys: [.isRegularFileKey]
)

var result: [URL] = []
for await fileURL in enumerator {
do {
let resourceValues = try fileURL.resourceValues(forKeys: [.isRegularFileKey])
if resourceValues.isRegularFile == true, extensions.contains(fileURL.pathExtension) {
result.append(fileURL)
}
} catch {
Logger.module.error("Error reading file \(fileURL): \(error)")
}
}

return result
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright 2025 The Brave Authors. All rights reserved.
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

import BraveCore
import BraveShared
import Foundation
import os.log

class ZipImporter {
enum ZipImportError: Error {
case failedToUnzip
case invalidFileSystemURL
}

@MainActor
static func unzip(path: URL) async throws -> URL {
// The zip file's name - The name of the extracted folder
let fileName = path.deletingPathExtension().lastPathComponent

// The folder where we want to extract our zip-file's folder to
let extractionFolder =
fileName.replacingOccurrences(of: " ", with: "").replacingOccurrences(of: "%20", with: "")
+ "-Extracted"

// The path where our zip-file's folder will be extracted to
let extractionPath = AsyncFileManager.default.temporaryDirectory.appending(
path: extractionFolder
)

// If the extraction path already exists, delete the folder
// If deletion fails, we'll continue extraction to a unique folder
if await AsyncFileManager.default.fileExists(atPath: extractionPath.path) {
do {
try await AsyncFileManager.default.removeItem(atPath: extractionPath.path)
} catch {
Logger.module.error("ZipImporter - Error deleting directory: \(error)")
}
}

// Get unique extraction path
guard
let tempDirectoryImportPath = try? await ZipImporter.uniqueFileName(
extractionFolder,
folder: AsyncFileManager.default.temporaryDirectory
)
else {
throw ZipImportError.failedToUnzip
}

// Create the temporary directory we'll extract the zip to
do {
try await AsyncFileManager.default.createDirectory(
at: tempDirectoryImportPath,
withIntermediateDirectories: true
)
} catch {
Logger.module.error("ZipImporter - Error creating directory: \(error)")
throw ZipImportError.failedToUnzip
}

// Path to the zip file
guard let nativeImportPath = path.fileSystemRepresentation else {
Logger.module.error("ZipImporter - Invalid FileSystem Path")
throw ZipImportError.invalidFileSystemURL
}

// Path to where the zip will be extracted
guard let nativeDestinationPath = tempDirectoryImportPath.fileSystemRepresentation else {
throw ZipImportError.invalidFileSystemURL
}

// Extract the zip file to the temporary directory
if await Unzip.unzip(nativeImportPath, toDirectory: nativeDestinationPath) {
// If the file was extracted to a folder of the same name, we return that folder
let filePath = tempDirectoryImportPath.appending(path: fileName)
if await AsyncFileManager.default.fileExists(atPath: filePath.path) {
return filePath
}

// Otherwise return the folder we created where we extracted the contents of the file
return tempDirectoryImportPath
}

throw ZipImportError.failedToUnzip
}
}

// MARK: - Parsing
extension ZipImporter {
static func uniqueFileName(_ filename: String, folder: URL) async throws -> URL {
let basePath = folder.appending(path: filename)
let fileExtension = basePath.pathExtension
let filenameWithoutExtension =
!fileExtension.isEmpty ? String(filename.dropLast(fileExtension.count + 1)) : filename

var proposedPath = basePath
var count = 0

while await AsyncFileManager.default.fileExists(atPath: proposedPath.path) {
count += 1

let proposedFilenameWithoutExtension = "\(filenameWithoutExtension) (\(count))"
proposedPath = folder.appending(path: proposedFilenameWithoutExtension)
.appending(path: fileExtension)
}

return proposedPath
}
}
5 changes: 5 additions & 0 deletions ios/brave-ios/Sources/BraveShared/AsyncFileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -332,4 +332,9 @@ extension AsyncFileManager {
public func downloadsPath() async throws -> URL {
try await url(for: .documentDirectory, appending: "Downloads", create: true)
}

/// URL where temporary files are stored.
public var temporaryDirectory: URL {
fileManager.temporaryDirectory
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,13 @@ extension URL {

return renderedString.bidiBaseDirection == .leftToRight
}

public var fileSystemRepresentation: String? {
return self.withUnsafeFileSystemRepresentation { bytes -> String? in
guard let bytes = bytes else { return nil }
return String(cString: bytes)
}
}
}

extension InternalURL {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ class DownloadHelperTests: XCTestCase {
MIMEType.html,
MIMEType.octetStream,
MIMEType.passbook,
MIMEType.passbookBundle,
MIMEType.pdf,
MIMEType.plainText,
MIMEType.png,
Expand Down
1 change: 1 addition & 0 deletions ios/browser/BUILD.gn
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ source_set("browser") {
"api/sync",
"api/translate",
"api/unicode",
"api/unzip",
"api/url",
"api/version_info",
"api/web",
Expand Down
21 changes: 21 additions & 0 deletions ios/browser/api/unzip/BUILD.gn
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Copyright (c) 2025 The Brave Authors. All rights reserved.
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at https://mozilla.org/MPL/2.0/.

source_set("unzip") {
sources = [
"unzip.h",
"unzip.mm",
]

deps = [
"//base",
"//components/services/unzip:in_process",
"//components/services/unzip/public/cpp",
"//net",
"//url",
]

frameworks = [ "Foundation.framework" ]
}
6 changes: 6 additions & 0 deletions ios/browser/api/unzip/headers.gni
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Copyright (c) 2025 The Brave Authors. All rights reserved.
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at https://mozilla.org/MPL/2.0/.

browser_api_unzip_public_headers = [ "//brave/ios/browser/api/unzip/unzip.h" ]
24 changes: 24 additions & 0 deletions ios/browser/api/unzip/unzip.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) 2025 The Brave Authors. All rights reserved.
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.

#include <Foundation/Foundation.h>

#ifndef BRAVE_IOS_BROWSER_API_UNZIP_UNZIP_H_
#define BRAVE_IOS_BROWSER_API_UNZIP_UNZIP_H_

NS_ASSUME_NONNULL_BEGIN

OBJC_EXPORT
NS_SWIFT_NAME(Unzip)
@interface BraveUnzip : NSObject
+ (void)unzip:(NSString*)zipFile
toDirectory:(NSString*)directory
completion:(void (^)(bool))completion;
@end

NS_ASSUME_NONNULL_END

#endif // BRAVE_IOS_BROWSER_API_UNZIP_UNZIP_H_

Loading
Loading