Skip to content

Commit 9815fbc

Browse files
[Merge] #182 - pull request #187 from YAPP-Github/feat/#182-share-extension
[Feat] Share Extension을 통한 외부 앱 네컷사진 저장 기능 추가
2 parents 0565565 + e8c9dc0 commit 9815fbc

18 files changed

Lines changed: 856 additions & 26 deletions

File tree

Neki-iOS.xcodeproj/project.pbxproj

Lines changed: 201 additions & 16 deletions
Large diffs are not rendered by default.

Neki-iOS/APP/Sources/Application/AppCoordinator.swift

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ struct AppCoordinator {
3333
}
3434
}
3535
var pendingSessionStatus: UserSessionStatus?
36+
var pendingShareAppGroupID: String?
3637
var lastVersionCheckedTime: Date?
3738

3839
init() {
@@ -50,12 +51,14 @@ struct AppCoordinator {
5051
case onAppLaunched
5152
case didTapUpdateAlert
5253
case didTapLaterAlert
54+
case onOpenURL(URL)
5355

5456
// Internal Actions
5557
case splashSequenceCompleted(UserSessionStatus, AppVersionClient.VersionResult)
5658
case userSessionStatusChanged(UserSessionStatus)
5759
case scenePhaseChanged(ScenePhase)
5860
case backgroundVersionCheckResult(Result<AppVersionClient.VersionResult, Error>)
61+
case executePendingShareExtensionIfNeeded
5962

6063
// Binding Actions
6164
case binding(BindingAction<State>)
@@ -108,6 +111,16 @@ struct AppCoordinator {
108111
await send(.splashSequenceCompleted(finalStatus, finalVersionResult))
109112
}
110113

114+
case let .onOpenURL(url):
115+
guard url.scheme == "neki" && url.host == "shareExtension" else { return .none }
116+
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
117+
let appGroupID = components.queryItems?.first(where: { $0.name == "appGroupID" })?.value
118+
else { return .none }
119+
state.pendingShareAppGroupID = appGroupID
120+
121+
guard case .mainTab = state.route else { return .none }
122+
return .send(.executePendingShareExtensionIfNeeded)
123+
111124
case let .scenePhaseChanged(phase):
112125
guard phase == .active else { return .none }
113126
if state.versionAlert == .updateNeeded { return .none }
@@ -155,6 +168,12 @@ struct AppCoordinator {
155168

156169
return navigateToNextScreen(state: &state, sessionStatus: finalStatus)
157170

171+
case .executePendingShareExtensionIfNeeded:
172+
guard let appGroupID = state.pendingShareAppGroupID else { return .none }
173+
guard case .mainTab = state.route else { return .none }
174+
state.pendingShareAppGroupID = nil
175+
return .send(.route(.mainTab(.archive(.root(.addPhotoFromShareExtension(appGroupID: appGroupID))))))
176+
158177
case .didTapUpdateAlert:
159178
// TODO: 앱스토어 URL 필요
160179
return .run { _ in
@@ -204,7 +223,7 @@ struct AppCoordinator {
204223
case let .route(.auth(.delegate(.moveToMainTab(user)))):
205224
state.$userSessionStatus.withLock { $0 = .signedIn(user) }
206225
state.route = .mainTab(.init(user: user))
207-
return .none
226+
return .send(.executePendingShareExtensionIfNeeded)
208227

209228
case .route(.mainTab(.delegate(.signedOut))):
210229
state.$userSessionStatus.withLock { $0 = .signedOut }
@@ -237,7 +256,7 @@ struct AppCoordinator {
237256
switch sessionStatus {
238257
case .signedIn(let user):
239258
state.route = .mainTab(.init(user: user))
240-
return .none
259+
return .send(.executePendingShareExtensionIfNeeded)
241260

242261
case .signedOut, .expired:
243262
if state.hasSeenOnboarding {

Neki-iOS/APP/Sources/Application/Neki_iOSApp.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ struct Neki_iOSApp: App {
2525
.onOpenURL { handleIncomingURL($0) }
2626
}
2727
}
28-
}
29-
30-
private extension Neki_iOSApp {
31-
func handleIncomingURL(_ url: URL) {
32-
if url.scheme == "neki" { return }
28+
29+
private func handleIncomingURL(_ url: URL) {
30+
if url.scheme == "neki" {
31+
store.send(.onOpenURL(url))
32+
return
33+
}
3334

3435
if url.scheme == "https" || url.scheme == "http" {
3536
guard let host = url.host(), host == "neki.suitestudy.com" else { return }

Neki-iOS/Core/Sources/ImagePicker/Domain/ImageUploadClient.swift

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import PhotosUI
1111

1212
public struct ImageUploadClient {
1313
public var upload: @Sendable (_ data: [ImageUploadEntity], _ mediaType: ImageMediaType) async throws -> [Int]
14+
public var uploadConcurrentlyFromURLs: @Sendable (_ fileURLs: [URL], _ mediaType: ImageMediaType) async throws -> [Int]
1415
public var convert: @Sendable (_ items: [PhotosPickerItem]) async -> [ImageUploadEntity]
1516
}
1617

@@ -22,13 +23,31 @@ extension ImageUploadClient: DependencyKey {
2223
upload: { items, mediaType in
2324
return try await repository.upload(items: items, mediaType: mediaType)
2425
},
26+
27+
uploadConcurrentlyFromURLs: { fileURLs, mediaType in
28+
var uploadedMediaIDs: [Int] = []
29+
let chunkSize: Int = 3
30+
let chunks = stride(from: 0, to: fileURLs.count, by: chunkSize).map { Array(fileURLs[$0..<min($0 + chunkSize, fileURLs.count)]) }
31+
32+
for chunk in chunks {
33+
var entities: [ImageUploadEntity] = []
34+
for url in chunk {
35+
let data = try Data(contentsOf: url)
36+
entities.append(ImageUploadEntity(data: data, format: data.detectedImageFormat))
37+
}
38+
39+
let resultIDs = try await repository.upload(items: entities, mediaType: mediaType)
40+
uploadedMediaIDs.append(contentsOf: resultIDs)
41+
}
42+
return uploadedMediaIDs
43+
},
44+
2545
convert: { items in
2646
await withTaskGroup(of: ImageUploadEntity?.self) { group in
2747
for item in items {
2848
group.addTask {
2949
guard let data = try? await item.loadTransferable(type: Data.self) else { return nil }
30-
let format = data.detectedImageFormat
31-
return ImageUploadEntity(data: data, format: format)
50+
return ImageUploadEntity(data: data, format: data.detectedImageFormat)
3251
}
3352
}
3453

@@ -37,7 +56,6 @@ extension ImageUploadClient: DependencyKey {
3756
guard let result else { continue }
3857
results.append(result)
3958
}
40-
4159
return results
4260
}
4361
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//
2+
// FileManagerSharedImageRepository.swift
3+
// Neki-iOS
4+
//
5+
// Created by SwainYun on 3/20/26.
6+
//
7+
8+
import Foundation
9+
import Dependencies
10+
import DependenciesMacros
11+
import UniformTypeIdentifiers
12+
import os
13+
14+
public struct FileManagerSharedImageRepository {
15+
private let fileManager: FileManager
16+
17+
public init(fileManager: FileManager = .default) { self.fileManager = fileManager }
18+
19+
private func sharedImageURLs(for appGroupID: String) throws -> [URL] {
20+
guard let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) else { return [] }
21+
let sharedDirectoryURL = groupURL.appendingPathComponent("SharedImages", conformingTo: .directory)
22+
23+
guard fileManager.fileExists(atPath: sharedDirectoryURL.path()) else { return [] }
24+
return try fileManager.contentsOfDirectory(at: sharedDirectoryURL, includingPropertiesForKeys: nil)
25+
}
26+
}
27+
28+
29+
// MARK: - FileManagerSharedImageRepository + SharedImageRepository
30+
31+
extension FileManagerSharedImageRepository: SharedImageRepository {
32+
public func fetchSharedImageURLs(appGroupID: String) async throws -> [URL] {
33+
return try sharedImageURLs(for: appGroupID)
34+
}
35+
36+
public func clearSharedImages(appGroupID: String) async throws {
37+
let fileURLs = try sharedImageURLs(for: appGroupID)
38+
39+
for url in fileURLs {
40+
do {
41+
try fileManager.removeItem(at: url)
42+
} catch {
43+
Logger.data.error("파일 정리 실패: \(url.lastPathComponent) - \(error.localizedDescription)")
44+
}
45+
}
46+
}
47+
}
48+
49+
50+
// MARK: - Dependency
51+
52+
private enum SharedImageRepositoryKey: DependencyKey {
53+
static let liveValue: SharedImageRepository = FileManagerSharedImageRepository()
54+
}
55+
56+
extension DependencyValues {
57+
var sharedImageRepository: SharedImageRepository {
58+
get { self[SharedImageRepositoryKey.self] }
59+
set { self[SharedImageRepositoryKey.self] = newValue }
60+
}
61+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//
2+
// SharedImageClient.swift
3+
// Neki-iOS
4+
//
5+
// Created by SwainYun on 3/20/26.
6+
//
7+
8+
import Foundation
9+
import Dependencies
10+
import DependenciesMacros
11+
12+
@DependencyClient
13+
public struct SharedImageClient {
14+
public var fetchSharedImageURLs: @Sendable (_ appGroupID: String) async throws -> [URL]
15+
public var clearSharedImages: @Sendable (_ appGroupID: String) async throws -> Void
16+
}
17+
18+
extension SharedImageClient: DependencyKey {
19+
public static let liveValue: SharedImageClient = {
20+
@Dependency(\.sharedImageRepository) var sharedImageRepository
21+
22+
return SharedImageClient { appGroupID in
23+
try await sharedImageRepository.fetchSharedImageURLs(appGroupID: appGroupID)
24+
} clearSharedImages: { appGroupID in
25+
try await sharedImageRepository.clearSharedImages(appGroupID: appGroupID)
26+
}
27+
}()
28+
}
29+
30+
extension DependencyValues {
31+
public var sharedImageClient: SharedImageClient {
32+
get { self[SharedImageClient.self] }
33+
set { self[SharedImageClient.self] = newValue }
34+
}
35+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//
2+
// SharedImageRepository.swift
3+
// Neki-iOS
4+
//
5+
// Created by SwainYun on 3/20/26.
6+
//
7+
8+
import Foundation
9+
10+
public protocol SharedImageRepository {
11+
func fetchSharedImageURLs(appGroupID: String) async throws -> [URL]
12+
func clearSharedImages(appGroupID: String) async throws
13+
}

Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveFeature.swift

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ struct ArchiveFeature {
6969
case albumsResponse(Result<[AlbumItem], Error>)
7070
case favoriteAlbumResponse(Result<AlbumItem, Error>)
7171
case photoListResponse(Result<[PhotoEntity], Error>)
72+
case uploadSharedImagesResponse(Result<[Int], Error>)
7273

7374
// Image Upload Action
7475
case imagePicker(ImagePickerFeature.Action)
@@ -85,6 +86,8 @@ struct ArchiveFeature {
8586
// Internal Action
8687
case addPhotoFromQRScanner(imageID: Int)
8788
case processUploadImages(imageIDs: [Int])
89+
case addPhotoFromShareExtension(appGroupID: String)
90+
case cleanSharedImages(appGroupID: String)
8891

8992
// Delegate Action
9093
case delegate(DelegateAction)
@@ -95,6 +98,8 @@ struct ArchiveFeature {
9598
}
9699

97100
@Dependency(\.archiveClient) var archiveClient
101+
@Dependency(\.imageUploadClient) var imageUploadClient
102+
@Dependency(\.sharedImageClient) var sharedImageClient
98103

99104
var body: some ReducerOf<Self> {
100105
BindingReducer()
@@ -282,6 +287,31 @@ struct ArchiveFeature {
282287
state.selectUploadAlbum = SelectUploadAlbumFeature.State(uploadedImageIds: imageIDs, albums: state.albums)
283288
return .none
284289

290+
case let .addPhotoFromShareExtension(appGroupID):
291+
state.isLoading = true
292+
return .run { send in
293+
do {
294+
let fileURLs = try await sharedImageClient.fetchSharedImageURLs(appGroupID: appGroupID)
295+
296+
guard fileURLs.isEmpty == false else {
297+
await send(.delegate(.showToast(NekiToastItem("가져올 수 있는 이미지가 없어요.", style: .error))))
298+
await send(.uploadSharedImagesResponse(.failure(UploadError.uploadFailed)))
299+
return
300+
}
301+
302+
let uploadedMediaIDs = try await imageUploadClient.uploadConcurrentlyFromURLs(fileURLs, .photoBooth)
303+
304+
await send(.cleanSharedImages(appGroupID: appGroupID))
305+
await send(.uploadSharedImagesResponse(.success(uploadedMediaIDs)))
306+
307+
} catch {
308+
Logger.presentation.error("공유 확장 이미지 업로드 에러: \(error)")
309+
310+
await send(.cleanSharedImages(appGroupID: appGroupID))
311+
await send(.uploadSharedImagesResponse(.failure(error)))
312+
}
313+
}
314+
285315
case let .selectUploadAlbum(.presented(.delegate(delegateAction))):
286316
switch delegateAction {
287317
case let .uploadDidSuccess(albumId):
@@ -322,6 +352,22 @@ struct ArchiveFeature {
322352
await send(.delegate(.showToast(NekiToastItem("사진 등록에 실패했어요", style: .error))))
323353
}
324354

355+
case let .cleanSharedImages(appGroupID):
356+
return .run { send in
357+
do {
358+
try await sharedImageClient.clearSharedImages(appGroupID: appGroupID)
359+
} catch {
360+
Logger.presentation.error("임시 파일 정리 실패: \(error)")
361+
}
362+
}
363+
364+
case let .uploadSharedImagesResponse(.success(imageIDs)):
365+
return .send(.processUploadImages(imageIDs: imageIDs))
366+
367+
case .uploadSharedImagesResponse(.failure):
368+
state.isLoading = false
369+
return .send(.delegate(.showToast(NekiToastItem("업로드에 실패했어요", style: .error))))
370+
325371
// MARK: - Binding
326372

327373
case .binding(\.newAlbumTitle):
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,9 @@
1010
<array>
1111
<string>applinks:neki.suitestudy.com</string>
1212
</array>
13+
<key>com.apple.security.application-groups</key>
14+
<array>
15+
<string>group.com.Neki-dev.Share-Extension</string>
16+
</array>
1317
</dict>
1418
</plist>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>com.apple.developer.applesignin</key>
6+
<array>
7+
<string>Default</string>
8+
</array>
9+
<key>com.apple.developer.associated-domains</key>
10+
<array>
11+
<string>applinks:neki.suitestudy.com</string>
12+
</array>
13+
<key>com.apple.security.application-groups</key>
14+
<array>
15+
<string>group.com.OneTen.Neki-iOS.Share-Extension</string>
16+
</array>
17+
</dict>
18+
</plist>

0 commit comments

Comments
 (0)