Skip to content

Commit 6fe0b7c

Browse files
authored
Merge pull request #485 from sopt-makers/hotfix/#484-web_download_image_save_library
[Hotfix] #484 - WKWebView에서 이미지 다운로드 발생 시 갤러리에 저장하는 기능을 구현한다.
2 parents e167c41 + 57f0901 commit 6fe0b7c

File tree

5 files changed

+240
-16
lines changed

5 files changed

+240
-16
lines changed

SOPT-iOS/Projects/Core/Sources/Utils/makeAlert.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,20 @@ public extension UIViewController {
3434
alertViewController.addAction(okAction)
3535
self.present(alertViewController, animated: true, completion: completion)
3636
}
37+
38+
func makeAlert(
39+
title: String,
40+
message: String,
41+
actions: UIAlertAction...,
42+
completion: (() -> Void)? = nil
43+
) {
44+
makeVibrate()
45+
let alertVC = UIAlertController(
46+
title: title,
47+
message: message,
48+
preferredStyle: .alert
49+
)
50+
actions.forEach { alertVC.addAction($0) }
51+
self.present(alertVC, animated: true, completion: completion)
52+
}
3753
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//
2+
// WKDownloadExecutable.swift
3+
// BaseFeatureDependency
4+
//
5+
// Created by 장석우 on 1/24/25.
6+
// Copyright © 2025 SOPT-iOS. All rights reserved.
7+
//
8+
9+
import Foundation
10+
import UniformTypeIdentifiers
11+
import WebKit
12+
13+
public protocol WKDownloadExecutable {
14+
15+
static var key: UTType { get }
16+
17+
func execute(
18+
_ download: WKDownload,
19+
_ response: URLResponse,
20+
_ suggestedFilename: String,
21+
_ webVC: SOPTWebViewControllable?
22+
) async -> URL?
23+
}
24+
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
//
2+
// WKDownloadManager.swift
3+
// BaseFeatureDependency
4+
//
5+
// Created by 장석우 on 1/24/25.
6+
// Copyright © 2025 SOPT-iOS. All rights reserved.
7+
//
8+
9+
import Foundation
10+
import UniformTypeIdentifiers
11+
import WebKit
12+
13+
public final class WKDownloadManager {
14+
15+
private var downloadHandlers: [UTType: WKDownloadExecutable]
16+
var webVC: SOPTWebViewControllable?
17+
18+
init(
19+
downloadHandlers: [UTType : WKDownloadExecutable] = [:],
20+
webVC: SOPTWebViewControllable? = nil
21+
) {
22+
self.downloadHandlers = downloadHandlers
23+
self.webVC = webVC
24+
}
25+
26+
public func register<T: WKDownloadExecutable>(_ object: T) {
27+
self.downloadHandlers[T.key] = object
28+
}
29+
30+
func download(
31+
_ download: WKDownload,
32+
decideDestinationUsing response: URLResponse,
33+
suggestedFilename: String
34+
) async -> URL? {
35+
guard let mimeType = response.mimeType,
36+
let utType = UTType(mimeType: mimeType)
37+
else { return nil }
38+
39+
guard let handler = downloadHandlers.first(where: { utType.conforms(to: $0.key) })?.value
40+
else { return nil }
41+
42+
return await handler.execute(download, response, suggestedFilename, webVC)
43+
}
44+
45+
public static let `default`: WKDownloadManager = {
46+
let manager = WKDownloadManager()
47+
manager.register(WKImageDownloadHandler())
48+
return manager
49+
}()
50+
}
51+
52+
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
//
2+
// WKImageDownloadHandler.swift
3+
// BaseFeatureDependency
4+
//
5+
// Created by 장석우 on 1/24/25.
6+
// Copyright © 2025 SOPT-iOS. All rights reserved.
7+
//
8+
9+
import Foundation
10+
import WebKit
11+
import Photos
12+
13+
struct WKImageDownloadHandler: WKDownloadExecutable {
14+
15+
static let key = UTType.image
16+
17+
func execute(
18+
_ download: WKDownload,
19+
_ response: URLResponse,
20+
_ suggestedFilename: String,
21+
_ webVC: SOPTWebViewControllable?
22+
) async -> URL? {
23+
24+
guard let webVC else { return nil }
25+
26+
guard await requestAuthorization() else {
27+
await presentGoToSettingAlert(from: webVC)
28+
return nil
29+
}
30+
31+
guard let urlString = response.url?.absoluteString,
32+
let range = urlString.range(of: "base64,"),
33+
let encodedData = Data(base64Encoded: String(urlString[range.upperBound...])),
34+
let image = UIImage(data: encodedData),
35+
let pngData = image.pngData()
36+
else {
37+
return nil
38+
}
39+
40+
guard let fileURL = try? saveToTemporaryDirectory(pngData, suggestedFilename)
41+
else { return nil }
42+
43+
await presentActivityVC(fileURL, from: webVC)
44+
45+
return nil
46+
}
47+
48+
private func requestAuthorization() async -> Bool {
49+
let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
50+
return status == .authorized || status == .limited
51+
}
52+
53+
private func saveToTemporaryDirectory(_ data: Data, _ suggestedFilename: String) throws -> URL {
54+
let temporaryURL = FileManager.default.temporaryDirectory
55+
.appendingPathComponent(suggestedFilename)
56+
.appendingPathExtension("png")
57+
58+
try data.write(to: temporaryURL, options: [])
59+
return temporaryURL
60+
}
61+
62+
@MainActor
63+
private func presentActivityVC(_ fileURL: URL, from webVC: SOPTWebViewControllable) {
64+
65+
let activityVC = UIActivityViewController(
66+
activityItems: [fileURL],
67+
applicationActivities: nil
68+
)
69+
70+
activityVC.completionWithItemsHandler = {
71+
activityType, completed, returnedItems, activityError in
72+
if activityType == .saveToCameraRoll {
73+
guard activityError == nil else {
74+
ToastUtils.showMDSToast(type: .error, text: "이미지 저장에 실패했습니다.")
75+
return
76+
}
77+
78+
if completed {
79+
ToastUtils.showMDSToast(type: .success, text: "이미지가 저장되었습니다.")
80+
}
81+
}
82+
}
83+
// 공유 화면 표시
84+
webVC.vc.present(activityVC, animated: true, completion: nil)
85+
86+
}
87+
88+
@MainActor
89+
private func presentGoToSettingAlert(from webVC: SOPTWebViewControllable) {
90+
webVC.vc.makeAlert(
91+
title: "갤러리 접근 권한 설정",
92+
message: "이미지를 저장하시려면 갤러리 접근 권한이 필요합니다.",
93+
actions: .init(title: "나중에", style: .destructive),
94+
.init(title: "설정", style: .default, handler: { _ in
95+
guard let url = URL(string: UIApplication.openSettingsURLString),
96+
UIApplication.shared.canOpenURL(url) else { return }
97+
98+
UIApplication.shared.open(url, completionHandler: nil)
99+
})
100+
)
101+
}
102+
}
103+
104+
105+

SOPT-iOS/Projects/Features/BaseFeatureDependency/Sources/WebView/SOPTWebView.swift

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,36 +14,44 @@ import WebKit
1414

1515
import SnapKit
1616

17-
public final class SOPTWebView: UIViewController {
17+
public protocol SOPTWebViewControllable {
18+
var vc: UIViewController { get }
19+
var webView: WKWebView { get }
20+
}
21+
22+
public final class SOPTWebView: UIViewController, SOPTWebViewControllable {
1823
private enum Metric {
1924
static let navigationBarHeight = 44.f
2025
}
2126

2227
private lazy var navigationBar = WebViewNavigationBar(frame: self.view.frame)
23-
private let wkwebView: WKWebView
28+
public let webView: WKWebView
29+
public var vc: UIViewController { self }
30+
private let downloadManager: WKDownloadManager
2431

2532
// MARK: Variables
2633
private let cancelbag = CancelBag()
2734
private var barrier = false
2835

2936
public init(
3037
config: WebViewConfig = WebViewConfig(),
31-
startWith url: URL
38+
startWith url: URL,
39+
downloadManager: WKDownloadManager = .default
3240
) {
3341
let configuration = WKWebViewConfiguration().then {
3442
$0.allowsInlineMediaPlayback = config.allowsInlineMediaPlayback
3543
$0.mediaTypesRequiringUserActionForPlayback = config.mediaTypesRequiringUserActionForPlayback
3644
}
3745

38-
self.wkwebView = WKWebView(frame: .zero, configuration: configuration).then {
46+
self.webView = WKWebView(frame: .zero, configuration: configuration).then {
3947
$0.allowsBackForwardNavigationGestures = config.allowsBackForwardNavigationGestures
4048
}
41-
49+
self.downloadManager = downloadManager
4250
super.init(nibName: nil, bundle: nil)
4351

4452
DispatchQueue.main.async {
4553
let request = URLRequest(url: url)
46-
self.wkwebView.load(request)
54+
self.webView.load(request)
4755
}
4856
}
4957

@@ -56,10 +64,10 @@ public final class SOPTWebView: UIViewController {
5664

5765
self.view.backgroundColor = DSKitAsset.Colors.black100.color
5866

59-
self.wkwebView.scrollView.delegate = self
60-
self.wkwebView.navigationDelegate = self
61-
self.wkwebView.uiDelegate = self
62-
67+
self.webView.scrollView.delegate = self
68+
self.webView.navigationDelegate = self
69+
self.webView.uiDelegate = self
70+
downloadManager.webVC = self
6371
self.initializeViews()
6472
self.setupConstraints()
6573
self.setupNavigationButtonActions()
@@ -68,7 +76,7 @@ public final class SOPTWebView: UIViewController {
6876

6977
extension SOPTWebView {
7078
private func initializeViews() {
71-
self.view.addSubviews(self.navigationBar, self.wkwebView)
79+
self.view.addSubviews(self.navigationBar, self.webView)
7280
}
7381

7482
private func setupConstraints() {
@@ -78,7 +86,7 @@ extension SOPTWebView {
7886
$0.height.equalTo(Metric.navigationBarHeight)
7987
}
8088

81-
self.wkwebView.snp.makeConstraints {
89+
self.webView.snp.makeConstraints {
8290
$0.top.equalTo(self.navigationBar.snp.bottom)
8391
$0.leading.trailing.bottom.equalToSuperview()
8492
}
@@ -88,12 +96,12 @@ extension SOPTWebView {
8896
self.navigationBar
8997
.signalForClickLeftButton()
9098
.sink { [weak self] _ in
91-
guard self?.wkwebView.canGoBack == true else {
99+
guard self?.webView.canGoBack == true else {
92100
self?.navigationController?.popViewController(animated: true)
93101
return
94102
}
95103

96-
self?.wkwebView.goBack()
104+
self?.webView.goBack()
97105
}.store(in: self.cancelbag)
98106

99107
self.navigationBar
@@ -111,10 +119,10 @@ extension SOPTWebView: WKNavigationDelegate {
111119
}
112120

113121
self.barrier = true
114-
self.wkwebView.evaluateJavaScript(
122+
self.webView.evaluateJavaScript(
115123
"localStorage.setItem(\"serviceAccessToken\", \"\(playgroundToken)\")"
116124
)
117-
self.wkwebView.reload()
125+
self.webView.reload()
118126
}
119127
}
120128

@@ -138,3 +146,22 @@ extension SOPTWebView: UIScrollViewDelegate {
138146
scrollView.pinchGestureRecognizer?.isEnabled = false
139147
}
140148
}
149+
150+
extension SOPTWebView: WKDownloadDelegate {
151+
152+
public func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String) async -> URL? {
153+
return await downloadManager.download(
154+
download,
155+
decideDestinationUsing: response,
156+
suggestedFilename: suggestedFilename
157+
)
158+
}
159+
160+
public func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
161+
download.delegate = self
162+
}
163+
164+
public func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
165+
download.delegate = self
166+
}
167+
}

0 commit comments

Comments
 (0)