diff --git a/StrayScanner.xcodeproj/project.pbxproj b/StrayScanner.xcodeproj/project.pbxproj index e8258ae..7cfbe00 100644 --- a/StrayScanner.xcodeproj/project.pbxproj +++ b/StrayScanner.xcodeproj/project.pbxproj @@ -66,6 +66,8 @@ 38E969CB2572608E00054CC4 /* NewSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E969CA2572608E00054CC4 /* NewSession.swift */; }; 38FB730F2572A9FA007D9CB0 /* RecordSessionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FB730E2572A9FA007D9CB0 /* RecordSessionViewController.swift */; }; 38FB73162572AF63007D9CB0 /* Shaders.metal in Sources */ = {isa = PBXBuildFile; fileRef = 38FB73152572AF63007D9CB0 /* Shaders.metal */; }; + 709259192E0B79AB00A7B62E /* ShareUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 709259182E0B79AB00A7B62E /* ShareUtility.swift */; }; + 70CF35492E17803700276207 /* ShareUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 709259182E0B79AB00A7B62E /* ShareUtility.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -119,6 +121,7 @@ 38FB73152572AF63007D9CB0 /* Shaders.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = Shaders.metal; sourceTree = ""; }; 38FB731F2573EABB007D9CB0 /* ShaderTypes.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ShaderTypes.h; sourceTree = ""; }; 38FB73242573ECE2007D9CB0 /* BridgeHeader.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = BridgeHeader.h; path = StrayScanner/BridgeHeader.h; sourceTree = ""; }; + 709259182E0B79AB00A7B62E /* ShareUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareUtility.swift; sourceTree = ""; }; AC52749A3B5AED09C9753120 /* Pods-StrayScanner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-StrayScanner.debug.xcconfig"; path = "Target Support Files/Pods-StrayScanner/Pods-StrayScanner.debug.xcconfig"; sourceTree = ""; }; DBC054EDDA47FB8A717AA671 /* Pods_StrayScanner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_StrayScanner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -244,6 +247,7 @@ 38694C6B26D2C66F00546EA1 /* Helpers */ = { isa = PBXGroup; children = ( + 709259182E0B79AB00A7B62E /* ShareUtility.swift */, 38694C6C26D2C66F00546EA1 /* DatasetEncoder.swift */, 38694C6D26D2C66F00546EA1 /* ConfidenceEncoder.swift */, 38694C6E26D2C66F00546EA1 /* VideoEncoder.swift */, @@ -513,6 +517,7 @@ 38694C7626D2C66F00546EA1 /* ConfidenceEncoder.swift in Sources */, 386E277825991163007D023B /* RecordButton.swift in Sources */, 385999BF25616F2B00F3F681 /* SceneDelegate.swift in Sources */, + 709259192E0B79AB00A7B62E /* ShareUtility.swift in Sources */, 38694C8026D2C66F00546EA1 /* DepthEncoder.swift in Sources */, 38C17B13259BE1DA006B3FDA /* Recording+CoreDataProperties.swift in Sources */, 38C17B12259BE1DA006B3FDA /* Recording+CoreDataClass.swift in Sources */, @@ -543,6 +548,7 @@ 3863FE6A25FCBB0E00C1DA4F /* RecordButton.swift in Sources */, 3807421027BBD07D003194C1 /* PngEncoder.mm in Sources */, 3863FE6C25FCBB0E00C1DA4F /* SceneDelegate.swift in Sources */, + 70CF35492E17803700276207 /* ShareUtility.swift in Sources */, 38694C8126D2C66F00546EA1 /* DepthEncoder.swift in Sources */, 3863FE6E25FCBB0E00C1DA4F /* Recording+CoreDataProperties.swift in Sources */, 3863FE6F25FCBB0E00C1DA4F /* Recording+CoreDataClass.swift in Sources */, diff --git a/StrayScanner/Helpers/ShareUtility.swift b/StrayScanner/Helpers/ShareUtility.swift new file mode 100644 index 0000000..8a35c27 --- /dev/null +++ b/StrayScanner/Helpers/ShareUtility.swift @@ -0,0 +1,60 @@ +// +// ShareUtility.swift +// StrayScanner +// +// Created by Claude on 6/24/25. +// + +import Foundation +import Compression + +/// Utility class for creating shareable archives from recording datasets +class ShareUtility { + + /// Creates a shareable ZIP archive from a recording's dataset + /// - Parameter recording: The recording to create a ZIP archive for + /// - Returns: URL of the created ZIP file + static func createShareableArchive(for recording: Recording) async throws -> URL { + guard let sourceDirectory = recording.directoryPath() else { + throw NSError(domain: "ShareError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unable to get recording directory path"]) + } + + let tempDirectory = FileManager.default.temporaryDirectory + let archiveName = "\(recording.name ?? "Recording")_\(recording.id?.uuidString.prefix(8) ?? "unknown").zip" + let archiveURL = tempDirectory.appendingPathComponent(archiveName) + + // Remove existing archive if it exists + try? FileManager.default.removeItem(at: archiveURL) + + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + do { + try createZipArchive(sourceDirectory: sourceDirectory, destinationURL: archiveURL) + continuation.resume(returning: archiveURL) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func createZipArchive(sourceDirectory: URL, destinationURL: URL) throws { + let coordinator = NSFileCoordinator() + var error: NSError? + + coordinator.coordinate(readingItemAt: sourceDirectory, options: [.forUploading], error: &error) { (zipURL) in + do { + _ = zipURL.startAccessingSecurityScopedResource() + defer { zipURL.stopAccessingSecurityScopedResource() } + + try FileManager.default.copyItem(at: zipURL, to: destinationURL) + } catch { + print("Failed to create zip: \(error)") + } + } + + if let error = error { + throw error + } + } +} diff --git a/StrayScanner/Views/InformationView.swift b/StrayScanner/Views/InformationView.swift index ca25c53..03eebfb 100644 --- a/StrayScanner/Views/InformationView.swift +++ b/StrayScanner/Views/InformationView.swift @@ -26,13 +26,16 @@ This app lets you record video and depth datasets using the camera and LIDAR sca heading("Transfering Datasets To Your Desktop Computer") bodyText(""" -The recorded datasets can be exported by connecting your device to it with the lightning cable. +The recorded datasets can be exported in several ways: -On Mac, you can access the files through Finder. In the sidebar, select your device. Under the "Files" tab, you should see an entry for Stray Scanner. Expand it, then drag the folders to the desired location. There is one folder per dataset, each named after a random alphanumerical hash. +1. Share directly from the app: Tap the "Share" button inside any recording to export it as a ZIP archive via AirDrop, email, or save to Files. -On Windows, you can access the files through iTunes. +2. Connect your device with the lightning cable: + • On Mac, access files through Finder sidebar > your device > "Files" tab > Stray Scanner + • On Windows, access files through iTunes + • Drag folders to your desired location (one folder per dataset) -Alternatively, you can access the data in the Files app under "Browse > On My iPhone > Stray Scanner" and export them to another app or move them to your iCloud drive. +3. Use the Files app: Browse > On My iPhone > Stray Scanner, then export to another app or iCloud drive. """) } Group { diff --git a/StrayScanner/Views/SessionDetail.swift b/StrayScanner/Views/SessionDetail.swift index 2723554..4144f64 100644 --- a/StrayScanner/Views/SessionDetail.swift +++ b/StrayScanner/Views/SessionDetail.swift @@ -9,6 +9,7 @@ import SwiftUI import AVKit import CoreData +import Foundation class SessionDetailViewModel: ObservableObject { private var dataContext: NSManagedObjectContext? @@ -45,6 +46,10 @@ struct SessionDetailView: View { @ObservedObject var viewModel = SessionDetailViewModel() var recording: Recording @Environment(\.presentationMode) var presentationMode: Binding + @State private var showingShareSheet = false + @State private var tempPackageURL: URL? + @State private var isCreatingPackage = false + @State private var player: AVPlayer? let defaultUrl = URL(fileURLWithPath: "") @@ -55,16 +60,52 @@ struct SessionDetailView: View { Color("BackgroundColor") .edgesIgnoringSafeArea(.all) VStack { - let player = AVPlayer(url: recording.absoluteRgbPath() ?? defaultUrl) - VideoPlayer(player: player) + VideoPlayer(player: player ?? AVPlayer(url: defaultUrl)) .frame(width: width, height: height) .padding(.horizontal, 0.0) - Button(action: deleteItem) { - Text("Delete").foregroundColor(Color("DangerColor")) + .onAppear { + if player == nil { + player = AVPlayer(url: recording.absoluteRgbPath() ?? defaultUrl) + } + } + + HStack(spacing: 20) { + Button(action: shareItem) { + HStack { + if isCreatingPackage { + ProgressView() + .scaleEffect(0.8) + } else { + Image(systemName: "square.and.arrow.up") + } + Text(isCreatingPackage ? "Preparing..." : "Share") + .fixedSize() + } + .foregroundColor(isCreatingPackage ? .gray : .blue) + .frame(minWidth: 100) + } + .disabled(isCreatingPackage) + + Button(action: deleteItem) { + Text("Delete").foregroundColor(Color("DangerColor")) + } } + .padding(.top, 20) } .navigationBarTitle(viewModel.title(recording: recording)) .background(Color("BackgroundColor")) + .sheet(isPresented: $showingShareSheet) { + if let packageURL = tempPackageURL { + ShareSheet(activityItems: [packageURL]) { activityType, completed, returnedItems, activityError in + DispatchQueue.main.async { + // Clean up temporary package after sharing + try? FileManager.default.removeItem(at: packageURL) + tempPackageURL = nil + showingShareSheet = false + } + } + } + } } } @@ -72,9 +113,50 @@ struct SessionDetailView: View { viewModel.delete(recording: recording) self.presentationMode.wrappedValue.dismiss() } + + func shareItem() { + isCreatingPackage = true + + Task { + do { + let packageURL = try await ShareUtility.createShareableArchive(for: recording) + await MainActor.run { + tempPackageURL = packageURL + isCreatingPackage = false + showingShareSheet = true + } + } catch { + await MainActor.run { + isCreatingPackage = false + print("Failed to create package: \(error)") + } + } + } + } } - +struct ShareSheet: UIViewControllerRepresentable { + let activityItems: [Any] + let applicationActivities: [UIActivity]? = nil + let completionWithItemsHandler: UIActivityViewController.CompletionWithItemsHandler? + + init(activityItems: [Any], completionHandler: UIActivityViewController.CompletionWithItemsHandler? = nil) { + self.activityItems = activityItems + self.completionWithItemsHandler = completionHandler + } + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIActivityViewController { + let controller = UIActivityViewController( + activityItems: activityItems, + applicationActivities: applicationActivities + ) + controller.completionWithItemsHandler = completionWithItemsHandler + return controller + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: UIViewControllerRepresentableContext) { + } +} struct SessionDetailView_Previews: PreviewProvider { static var recording: Recording = { () -> Recording in