diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 0133251f0..cc7142011 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1182,7 +1182,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc51"; + version = "0.1.0-rc54"; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5e8860816..2bc650a24 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "80f3d81898ab23279134797e5625cae7232dad15", - "version" : "0.1.0-rc51" + "revision" : "29541375a98e13d7c6d8a072a88b2673f560944f", + "version" : "0.1.0-rc54" } }, { diff --git a/Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/Contents.json new file mode 100644 index 000000000..3561b11b1 --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "subscription-default-icon.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/subscription-default-icon.png b/Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/subscription-default-icon.png new file mode 100644 index 000000000..830189723 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/subscription-default-icon.png differ diff --git a/Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/Contents.json new file mode 100644 index 000000000..a53c8c713 --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "subscription-intro-clock.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/subscription-intro-clock.png b/Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/subscription-intro-clock.png new file mode 100644 index 000000000..bedb79895 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/subscription-intro-clock.png differ diff --git a/Bitkit/Assets.xcassets/icons/asterisk.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/asterisk.imageset/Contents.json new file mode 100644 index 000000000..c3aa265df --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/asterisk.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "asterisk.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/asterisk.imageset/asterisk.svg b/Bitkit/Assets.xcassets/icons/asterisk.imageset/asterisk.svg new file mode 100644 index 000000000..ca8d20d1f --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/asterisk.imageset/asterisk.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Bitkit/Assets.xcassets/icons/timer-outline.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/timer-outline.imageset/Contents.json new file mode 100644 index 000000000..68f946750 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/timer-outline.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "timer-outline.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/timer-outline.imageset/timer-outline.svg b/Bitkit/Assets.xcassets/icons/timer-outline.imageset/timer-outline.svg new file mode 100644 index 000000000..192285d6f --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/timer-outline.imageset/timer-outline.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Bitkit/Components/MoneyCell.swift b/Bitkit/Components/MoneyCell.swift index 14fd219d6..d8aa5a4a7 100644 --- a/Bitkit/Components/MoneyCell.swift +++ b/Bitkit/Components/MoneyCell.swift @@ -5,6 +5,7 @@ struct MoneyCell: View { let sats: Int let prefix: String var enableHide: Bool = false + var symbol: Bool? var body: some View { VStack(alignment: .trailing, spacing: 2) { @@ -12,6 +13,7 @@ struct MoneyCell: View { sats: sats, unitType: .primary, size: .bodyMSB, + symbol: symbol, enableHide: enableHide, prefix: prefix, color: .textPrimary, diff --git a/Bitkit/Components/MoneyText.swift b/Bitkit/Components/MoneyText.swift index 720baa80b..b96686a48 100644 --- a/Bitkit/Components/MoneyText.swift +++ b/Bitkit/Components/MoneyText.swift @@ -30,6 +30,7 @@ struct MoneyText: View { var color: Color = .textPrimary var symbolColor: Color? var testIdentifier: String? + var fillsWidth: Bool = true @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var settings: SettingsViewModel @@ -105,7 +106,8 @@ extension MoneyText { case .display: DisplayText( // Cap symbol font weight to ExtraBold - text, textColor: color, accentColor: symbolColor ?? .textSecondary, accentFont: size == .display ? Fonts.extraBold : nil + text, textColor: color, accentColor: symbolColor ?? .textSecondary, + accentFont: Fonts.extraBold, fillsWidth: fillsWidth ) case .title: TitleText(text, textColor: color, accentColor: symbolColor ?? .textSecondary) diff --git a/Bitkit/Components/NoteTextEditor.swift b/Bitkit/Components/NoteTextEditor.swift index 33bc657b6..c12dccda3 100644 --- a/Bitkit/Components/NoteTextEditor.swift +++ b/Bitkit/Components/NoteTextEditor.swift @@ -5,6 +5,9 @@ struct NoteTextEditor: View { let placeholder: String let testIdentifier: String let isFocused: FocusState.Binding + var minHeight: CGFloat = 30 + var maxHeight: CGFloat = 50 + var backgroundColor: Color = .white06 var body: some View { ZStack(alignment: .topLeading) { @@ -20,13 +23,13 @@ struct NoteTextEditor: View { .submitLabel(.done) .scrollContentBackground(.hidden) .padding(EdgeInsets(top: -8, leading: -5, bottom: -5, trailing: -5)) - .frame(minHeight: 30, maxHeight: 50) + .frame(minHeight: minHeight, maxHeight: maxHeight) .dismissKeyboardOnReturn(text: $text, isFocused: isFocused) .accessibilityValue(text) .accessibilityIdentifier(testIdentifier) } .padding() - .background(Color.white06) + .background(backgroundColor) .cornerRadius(8) } } diff --git a/Bitkit/Components/PubkyContactRow.swift b/Bitkit/Components/PubkyContactRow.swift index 919060b9c..534c29bab 100644 --- a/Bitkit/Components/PubkyContactRow.swift +++ b/Bitkit/Components/PubkyContactRow.swift @@ -15,8 +15,8 @@ struct PubkyContactRow: View { HStack(spacing: 16) { PubkyContactAvatar(contact: contact, size: 48) - VStack(alignment: .leading, spacing: 4) { - CaptionText(contact.profile.truncatedPublicKey.localizedUppercase) + VStack(alignment: .leading, spacing: 0) { + CaptionMText(contact.profile.truncatedPublicKey.localizedUppercase, textColor: .white64) .lineLimit(1) BodyMSBText(contact.displayName) diff --git a/Bitkit/Components/PubkyImage.swift b/Bitkit/Components/PubkyImage.swift index ab0e7b4cc..b38d56af0 100644 --- a/Bitkit/Components/PubkyImage.swift +++ b/Bitkit/Components/PubkyImage.swift @@ -1,12 +1,14 @@ import CryptoKit +import ImageIO import SwiftUI -/// Loads and displays an image from a `pubky://` URI using BitkitCore's PKDNS resolver. +/// Loads and displays an image from a `pubky://` URI using Paykit's Pubky file fetch. /// Handles the Pubky file indirection: the URI may point to a JSON metadata object /// with a `src` field containing the actual blob URI. struct PubkyImage: View { let uri: String let size: CGFloat + var cornerRadius: CGFloat? @State private var uiImage: UIImage? @State private var hasFailed = false @@ -24,7 +26,7 @@ struct PubkyImage: View { } } .frame(width: size, height: size) - .clipShape(Circle()) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius ?? size / 2)) .accessibilityLabel(Text("Profile photo")) .task(id: uri) { await loadImage() @@ -32,7 +34,7 @@ struct PubkyImage: View { } private var placeholder: some View { - Circle() + Rectangle() .fill(Color.gray5) .overlay { Image("user-square") @@ -70,12 +72,10 @@ struct PubkyImage: View { return cached } - let data = try await PubkyService.fetchFile(uri: uri) + let data = try await PubkyService.fetchFile(uri: uri, maxBytes: PubkyImagePolicy.maxDownloadBytes) let blobData = try await resolveImageData(data, originalUri: uri) - guard let image = UIImage(data: blobData) else { - throw PubkyImageError.decodingFailed(blobData.count) - } + let image = try PubkyImageDecoder.image(from: blobData) PubkyImageCache.shared.store(image, data: blobData, for: uri) return image @@ -97,18 +97,47 @@ struct PubkyImage: View { } Logger.debug("File descriptor found, fetching blob from: \(src)", context: "PubkyImage") - return try await PubkyService.fetchFile(uri: src) + return try await PubkyService.fetchFile(uri: src, maxBytes: PubkyImagePolicy.maxDownloadBytes) + } +} + +enum PubkyImagePolicy { + static let maxDownloadBytes: UInt64 = 1024 * 1024 + static let maxPixelSize = 512 + static let memoryCacheBytes = 32 * 1024 * 1024 + static let diskCacheBytes = 32 * 1024 * 1024 +} + +enum PubkyImageDecoder { + static func image(from data: Data, maxPixelSize: Int = PubkyImagePolicy.maxPixelSize) throws -> UIImage { + guard UInt64(data.count) <= PubkyImagePolicy.maxDownloadBytes else { + throw PubkyImageError.fileTooLarge(data.count) + } + guard maxPixelSize > 0, + let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary), + let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, + ] as CFDictionary) + else { + throw PubkyImageError.decodingFailed(data.count) + } + return UIImage(cgImage: thumbnail) } } private enum PubkyImageError: LocalizedError { case decodingFailed(Int) + case fileTooLarge(Int) case crossUserRedirect var errorDescription: String? { switch self { case let .decodingFailed(bytes): return "Could not decode image blob (\(bytes) bytes)" + case let .fileTooLarge(bytes): + return "Image blob exceeds the byte limit (\(bytes) bytes)" case .crossUserRedirect: return "Image descriptor references a different user's namespace" } @@ -120,67 +149,96 @@ private enum PubkyImageError: LocalizedError { final class PubkyImageCache: @unchecked Sendable { static let shared = PubkyImageCache() - private var memoryCache: [String: UIImage] = [:] - private let lock = NSLock() + private struct MemoryEntry { + let image: UIImage + let cost: Int + var lastAccess: UInt64 + } + + private var memoryCache: [String: MemoryEntry] = [:] + private var memoryCost = 0 + private var accessSequence: UInt64 = 0 + private let memoryLock = NSLock() private let diskQueue = DispatchQueue(label: "pubky-image-cache-disk", qos: .utility) private let diskDirectory: URL + private let maxFileBytes: Int + private let maxMemoryBytes: Int + private let maxDiskBytes: Int - private init() { + init( + diskDirectory: URL? = nil, + maxFileBytes: Int = Int(PubkyImagePolicy.maxDownloadBytes), + memoryCostLimit: Int = PubkyImagePolicy.memoryCacheBytes, + diskByteLimit: Int = PubkyImagePolicy.diskCacheBytes + ) { let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! - diskDirectory = caches.appendingPathComponent("pubky-images", isDirectory: true) - try? FileManager.default.createDirectory(at: diskDirectory, withIntermediateDirectories: true) + self.diskDirectory = diskDirectory ?? caches.appendingPathComponent("pubky-images", isDirectory: true) + self.maxFileBytes = maxFileBytes + maxMemoryBytes = memoryCostLimit + maxDiskBytes = diskByteLimit + try? FileManager.default.createDirectory(at: self.diskDirectory, withIntermediateDirectories: true) + diskQueue.async { [self] in + trimDiskCache() + } } /// Fast memory-only check — never blocks behind disk I/O, safe from the main thread. func memoryImage(for uri: String) -> UIImage? { - lock.lock() - defer { lock.unlock() } - return memoryCache[uri] + memoryLock.lock() + defer { memoryLock.unlock() } + guard var entry = memoryCache[uri] else { return nil } + accessSequence &+= 1 + entry.lastAccess = accessSequence + memoryCache[uri] = entry + return entry.image } /// Full lookup (memory + disk). Disk I/O runs on a dedicated queue to avoid blocking cooperative threads. func image(for uri: String) async -> UIImage? { - lock.lock() - if let memoryHit = memoryCache[uri] { - lock.unlock() + if let memoryHit = memoryImage(for: uri) { return memoryHit } - lock.unlock() return await withCheckedContinuation { continuation in diskQueue.async { [self] in let path = diskPath(for: uri) + guard let fileSize = try? path.resourceValues(forKeys: [.fileSizeKey]).fileSize, + fileSize <= maxFileBytes + else { + try? FileManager.default.removeItem(at: path) + continuation.resume(returning: nil) + return + } + guard let diskData = try? Data(contentsOf: path), - let diskImage = UIImage(data: diskData) + let diskImage = try? PubkyImageDecoder.image(from: diskData) else { + try? FileManager.default.removeItem(at: path) continuation.resume(returning: nil) return } - lock.lock() - memoryCache[uri] = diskImage - lock.unlock() + try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: path.path) + storeInMemory(diskImage, for: uri) continuation.resume(returning: diskImage) } } } func store(_ image: UIImage, data: Data, for uri: String) { - lock.lock() - memoryCache[uri] = image - lock.unlock() + guard data.count <= maxFileBytes else { return } - diskQueue.async { [diskDirectory] in - let hash = Self.diskHash(for: uri) - let path = diskDirectory.appendingPathComponent(hash) + storeInMemory(image, for: uri) + + diskQueue.async { [self] in + let path = diskPath(for: uri) try? data.write(to: path, options: .atomic) + trimDiskCache() } } func clear() async { - lock.lock() - memoryCache.removeAll() - lock.unlock() + clearMemoryCache() await withCheckedContinuation { continuation in diskQueue.async { [diskDirectory] in @@ -191,6 +249,13 @@ final class PubkyImageCache: @unchecked Sendable { } } + private func clearMemoryCache() { + memoryLock.lock() + memoryCache.removeAll() + memoryCost = 0 + memoryLock.unlock() + } + private static func diskHash(for uri: String) -> String { let data = Data(uri.utf8) return SHA256.hash(data: data).compactMap { String(format: "%02x", $0) }.joined() @@ -199,4 +264,57 @@ final class PubkyImageCache: @unchecked Sendable { private func diskPath(for uri: String) -> URL { diskDirectory.appendingPathComponent(Self.diskHash(for: uri)) } + + private static func memoryCost(of image: UIImage) -> Int { + guard let cgImage = image.cgImage else { return 0 } + return cgImage.bytesPerRow * cgImage.height + } + + private func storeInMemory(_ image: UIImage, for uri: String) { + let cost = Self.memoryCost(of: image) + + memoryLock.lock() + defer { memoryLock.unlock() } + + if let replaced = memoryCache.removeValue(forKey: uri) { + memoryCost -= replaced.cost + } + guard cost <= maxMemoryBytes else { return } + + accessSequence &+= 1 + memoryCache[uri] = MemoryEntry(image: image, cost: cost, lastAccess: accessSequence) + memoryCost += cost + + while memoryCost > maxMemoryBytes, + let leastRecentlyUsed = memoryCache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) + { + memoryCache.removeValue(forKey: leastRecentlyUsed.key) + memoryCost -= leastRecentlyUsed.value.cost + } + } + + private func trimDiskCache() { + let keys: Set = [.contentModificationDateKey, .fileSizeKey, .isRegularFileKey] + guard let urls = try? FileManager.default.contentsOfDirectory( + at: diskDirectory, + includingPropertiesForKeys: Array(keys), + options: [.skipsHiddenFiles] + ) else { return } + + let entries = urls.compactMap { url -> (url: URL, date: Date, size: Int)? in + guard let values = try? url.resourceValues(forKeys: keys), + values.isRegularFile == true, + let fileSize = values.fileSize + else { return nil } + return (url, values.contentModificationDate ?? .distantPast, fileSize) + } + var totalBytes = entries.reduce(0) { $0 + $1.size } + + for entry in entries.sorted(by: { $0.date < $1.date }) where totalBytes > maxDiskBytes { + do { + try FileManager.default.removeItem(at: entry.url) + totalBytes -= entry.size + } catch {} + } + } } diff --git a/Bitkit/Components/SegmentedControl.swift b/Bitkit/Components/SegmentedControl.swift index 2f1a1b8c0..551674a18 100644 --- a/Bitkit/Components/SegmentedControl.swift +++ b/Bitkit/Components/SegmentedControl.swift @@ -4,11 +4,13 @@ struct TabItem { let tab: T let activeColor: Color? let badge: Int? + let accessibilityIdentifier: String? - init(_ tab: T, activeColor: Color? = nil, badge: Int? = nil) { + init(_ tab: T, activeColor: Color? = nil, badge: Int? = nil, accessibilityIdentifier: String? = nil) { self.tab = tab self.activeColor = activeColor self.badge = badge + self.accessibilityIdentifier = accessibilityIdentifier } } @@ -16,18 +18,21 @@ struct SegmentedControl: View { @Binding var selectedTab: T private let tabItems: [TabItem] private let defaultActiveColor: Color + private let inactiveColor: Color? @Namespace private var underlineNamespace - init(selectedTab: Binding, tabs: [T], activeColor: Color = .textPrimary) { + init(selectedTab: Binding, tabs: [T], activeColor: Color = .textPrimary, inactiveColor: Color? = nil) { _selectedTab = selectedTab tabItems = tabs.map { TabItem($0) } defaultActiveColor = activeColor + self.inactiveColor = inactiveColor } - init(selectedTab: Binding, tabItems: [TabItem], defaultActiveColor: Color = .textPrimary) { + init(selectedTab: Binding, tabItems: [TabItem], defaultActiveColor: Color = .textPrimary, inactiveColor: Color? = nil) { _selectedTab = selectedTab self.tabItems = tabItems self.defaultActiveColor = defaultActiveColor + self.inactiveColor = inactiveColor } var body: some View { @@ -40,7 +45,7 @@ struct SegmentedControl: View { }) { VStack(spacing: 8) { HStack(spacing: 8) { - CaptionBText(tabItem.tab.description, textColor: selectedTab == tabItem.tab ? .white : .secondary) + CaptionBText(tabItem.tab.description, textColor: selectedTab == tabItem.tab ? .white : inactiveColor ?? .secondary) if let badge = tabItem.badge, badge > 0 { CaptionBText("\(badge)", textColor: .black) @@ -54,7 +59,7 @@ struct SegmentedControl: View { ZStack { Rectangle() .frame(height: 2) - .foregroundColor(Color.white64) + .foregroundColor(inactiveColor ?? .white64) if selectedTab == tabItem.tab { Rectangle() @@ -68,7 +73,7 @@ struct SegmentedControl: View { .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) - .accessibilityIdentifier("Tab-\(tabItem.tab.description.lowercased())") + .accessibilityIdentifier(tabItem.accessibilityIdentifier ?? "Tab-\(tabItem.tab.description.lowercased())") } } .frame(maxWidth: .infinity) diff --git a/Bitkit/Components/TextField.swift b/Bitkit/Components/TextField.swift index e4277552e..20c1dc49f 100644 --- a/Bitkit/Components/TextField.swift +++ b/Bitkit/Components/TextField.swift @@ -7,6 +7,7 @@ struct TextField: View { let axis: Axis let testIdentifier: String? let submitLabel: SubmitLabel + let contentPadding: CGFloat @Binding var text: String init( @@ -16,7 +17,8 @@ struct TextField: View { font: Font = .custom(Fonts.semiBold, size: 15), axis: Axis = .horizontal, testIdentifier: String? = nil, - submitLabel: SubmitLabel = .return + submitLabel: SubmitLabel = .return, + contentPadding: CGFloat = 16 ) { self.placeholder = placeholder self.backgroundColor = backgroundColor @@ -24,6 +26,7 @@ struct TextField: View { self.axis = axis self.testIdentifier = testIdentifier self.submitLabel = submitLabel + self.contentPadding = contentPadding _text = text } @@ -41,7 +44,7 @@ struct TextField: View { .submitLabel(submitLabel) .accessibilityIdentifierIfPresent(testIdentifier) } - .padding() + .padding(contentPadding) .background(backgroundColor) .cornerRadius(8) } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 568bbe606..1c96d453e 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1543,9 +1543,22 @@ "subscriptions__active" = "Active"; "subscriptions__cancel" = "Cancel"; "subscriptions__cancel_subscription" = "Cancel Subscription"; +"subscriptions__choose_recipient" = "Choose Recipient"; +"subscriptions__create" = "Create"; +"subscriptions__content_too_long" = "Shorten the subscription name or description and try again."; +"subscriptions__icon_error" = "Could not load this image. Choose another image and try again."; +"subscriptions__create_subscription" = "Create Subscription"; +"subscriptions__created" = "Created"; +"subscriptions__created_summary" = "1 subscriber · {count} payments"; +"subscriptions__created_summary_single_payment" = "1 subscriber · 1 payment"; +"subscriptions__custom_icon" = "Icon"; +"subscriptions__custom_icon_description" = "Tap to upload a custom icon"; "subscriptions__daily" = "Daily"; -"subscriptions__daily_subscription" = "Daily Subscription"; +"subscriptions__daily_subscription" = "Daily subscription"; "subscriptions__details" = "Subscription Details"; +"subscriptions__delete_subscription" = "Delete Subscription"; +"subscriptions__description" = "Description"; +"subscriptions__description_placeholder" = "What is this subscription for?"; "subscriptions__empty_description" = "At the moment, you don’t have any active subscriptions from any providers."; "subscriptions__empty_headline" = "Welcome To\nSubscriptions"; "subscriptions__every_days" = "Every {count} days"; @@ -1562,7 +1575,9 @@ "subscriptions__more_info" = "More Info"; "subscriptions__monthly" = "Monthly"; "subscriptions__monthly_cost" = "Monthly Cost"; -"subscriptions__monthly_subscription" = "Monthly Subscription"; +"subscriptions__monthly_subscription" = "Monthly subscription"; +"subscriptions__name" = "Subscription Name"; +"subscriptions__name_placeholder" = "Subscription name"; "subscriptions__ongoing" = "Ongoing"; "subscriptions__overview" = "Overview"; "subscriptions__payment_due_description" = "Open Bitkit to review a subscription payment."; @@ -1572,6 +1587,15 @@ "subscriptions__per_month" = "per month"; "subscriptions__per_week" = "per week"; "subscriptions__per_year" = "per year"; +"subscriptions__pending" = "Pending"; +"subscriptions__proposal_queued_description" = "Your subscription proposal is queued and will send automatically."; +"subscriptions__proposal_queued_headline" = "Queued\nProposal"; +"subscriptions__proposal_queued_title" = "Queued"; +"subscriptions__proposal_queued_status" = "Proposal queued"; +"subscriptions__proposal_sent_description" = "You have sent a subscription proposal to"; +"subscriptions__proposal_sent_headline" = "Sent\nProposal"; +"subscriptions__proposal_sent_status" = "Proposal sent"; +"subscriptions__propose_subscription" = "Propose Subscription"; "subscriptions__proposals" = "Proposals"; "subscriptions__renews" = "Renews"; "subscriptions__retry_payment" = "Retry Payment"; @@ -1580,7 +1604,9 @@ "subscriptions__status" = "Status"; "subscriptions__subscribed" = "Subscribed"; "subscriptions__subscription" = "Subscription"; +"subscriptions__subscribers" = "Subscribers"; "subscriptions__swipe_to_cancel" = "Swipe To Cancel"; +"subscriptions__swipe_to_delete" = "Swipe To Delete"; "subscriptions__swipe_to_subscribe" = "Swipe To Subscribe"; "subscriptions__swipe_to_subscribe_and_pay" = "Swipe To Subscribe & Pay"; "subscriptions__title" = "Subscriptions"; @@ -1589,9 +1615,9 @@ "subscriptions__unsupported_frequency" = "Unsupported frequency"; "subscriptions__unsupported_payment_description" = "This subscription uses payment details that Bitkit does not support yet."; "subscriptions__weekly" = "Weekly"; -"subscriptions__weekly_subscription" = "Weekly Subscription"; +"subscriptions__weekly_subscription" = "Weekly subscription"; "subscriptions__yearly" = "Yearly"; -"subscriptions__yearly_subscription" = "Yearly Subscription"; +"subscriptions__yearly_subscription" = "Yearly subscription"; "wallet__instant_payment_received" = "Received Instant Bitcoin"; "wallet__error_create_tx" = "Transaction Creation Failed"; "wallet__error_create_tx_msg" = "An error occurred. Please try again {raw}"; diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 5543da489..53d7643a9 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1,6 +1,8 @@ import Foundation +import ImageIO import LDKNode import Paykit +import UIKit struct PaykitPaymentRequest: Identifiable, Hashable { enum ParseFailure: String, Error, Equatable, Sendable { @@ -225,7 +227,8 @@ struct PaykitPaymentRequest: Identifiable, Hashable { subscription: PaykitSubscription, billingPeriod: PaykitBillingPeriod, lifecycleState: Paykit.PaymentRequestLifecycleState, - paymentProofKind: PaykitPaymentProofKind? = nil + paymentProofKind: PaykitPaymentProofKind? = nil, + direction: Direction = .incoming ) { paymentRequestId = subscription.paymentRequestId counterparty = subscription.counterparty @@ -237,7 +240,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { expiresAt = nil acceptedPaymentEndpointIdentifiers = subscription.acceptedPaymentEndpointIdentifiers deliveryStatus = nil - direction = .incoming + self.direction = direction self.lifecycleState = lifecycleState self.billingPeriod = billingPeriod self.paymentProofKind = paymentProofKind @@ -366,6 +369,26 @@ struct PaykitPaymentRequestDraft: Hashable { let expiresAt: Date } +struct PaykitSubscriptionDraft: Hashable { + var amountSats: UInt64 + var name: String + var description: String + var frequency: PaykitSubscriptionRecurrence.Unit + var expiresAt: Date + var iconData: Data? + + static var empty: PaykitSubscriptionDraft { + PaykitSubscriptionDraft( + amountSats: 0, + name: "", + description: "", + frequency: .month, + expiresAt: Date().addingTimeInterval(7 * 24 * 60 * 60), + iconData: nil + ) + } +} + struct PaykitPaymentRequestSnapshot: Equatable { let incoming: [PaykitPaymentRequest] let history: [PaykitPaymentRequest] @@ -425,6 +448,7 @@ enum PaykitPaymentRequestError: LocalizedError, Equatable { case requestExpired case operationInProgress case amountMismatch + case subscriptionTooLong var errorDescription: String? { switch self { @@ -436,6 +460,8 @@ enum PaykitPaymentRequestError: LocalizedError, Equatable { t("wallet__payment_request_in_progress") case .amountMismatch: t("wallet__payment_request_mismatch") + case .subscriptionTooLong: + t("subscriptions__content_too_long") } } } @@ -453,6 +479,7 @@ protocol PaykitPaymentRequestSdkHandling: Sendable { terms: Paykit.PaymentRequestTerms, expectedIdentity: String ) async throws -> Paykit.PaymentRequestRecord + func uploadProfileAvatar(bytes: Data, contentType: String, expectedIdentity: String?) async throws -> String func acceptPaymentRequest( counterparty: String, counterpartyReceiverPath: String, @@ -530,7 +557,7 @@ struct PaykitPaymentRequestService { let history = records.compactMap { PaykitPaymentRequest(historyRecord: $0, now: synchronizationDate) } - let subscriptions = records.compactMap(PaykitSubscription.init) + let subscriptions = records.compactMap { PaykitSubscription(record: $0) } return PaykitPaymentRequestSnapshot( incoming: incoming, history: history, @@ -627,6 +654,119 @@ struct PaykitPaymentRequestService { ) } + func proposeSubscription( + _ draft: PaykitSubscriptionDraft, + to target: PaykitPaymentRequestTarget, + savedPublicKeys: [String], + expectedIdentity: String, + validateBeforeProposing: @MainActor () throws -> Void + ) async throws -> PaykitSubscription { + let acceptedPaymentEndpointIdentifiers = Self.acceptedPaymentEndpointIdentifiers() + let validationDate = now() + let name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines) + guard draft.amountSats > 0, + !name.isEmpty, + draft.frequency.isSupported, + !acceptedPaymentEndpointIdentifiers.isEmpty + else { + throw PaykitPaymentRequestError.requestUnavailable + } + guard draft.expiresAt > validationDate else { + throw PaykitPaymentRequestError.requestExpired + } + guard try await eligibleTargets(savedPublicKeys: savedPublicKeys, expectedIdentity: expectedIdentity).contains(target) else { + throw PaykitPaymentRequestError.requestUnavailable + } + + try PaykitSubscriptionProposal.validate(subscriptionTerms( + draft, + iconURI: draft.iconData == nil ? nil : PaykitSubscriptionProposal.reservedIconURI, + endpoints: acceptedPaymentEndpointIdentifiers, + proposalDate: validationDate + )) + let iconURI: String? = if let iconData = draft.iconData { + try await sdk.uploadProfileAvatar( + bytes: Self.compressedSubscriptionIcon(iconData), + contentType: "image/jpeg", + expectedIdentity: expectedIdentity + ) + } else { + nil + } + guard try await eligibleTargets(savedPublicKeys: savedPublicKeys, expectedIdentity: expectedIdentity).contains(target) else { + throw PaykitPaymentRequestError.requestUnavailable + } + try await validateBeforeProposing() + guard Self.acceptedPaymentEndpointIdentifiers() == acceptedPaymentEndpointIdentifiers else { + throw PaykitPaymentRequestError.requestUnavailable + } + let proposalDate = now() + guard draft.expiresAt > proposalDate else { + throw PaykitPaymentRequestError.requestExpired + } + let terms = try subscriptionTerms( + draft, + iconURI: iconURI, + endpoints: acceptedPaymentEndpointIdentifiers, + proposalDate: proposalDate + ) + try PaykitSubscriptionProposal.validate(terms) + let record = try await sdk.proposePaymentRequest( + counterparty: target.publicKey, + counterpartyReceiverPath: target.receiverPath, + terms: terms, + expectedIdentity: expectedIdentity + ) + let reports = await (try? processPendingMessages()) ?? [] + let deliveryStatus = proposalWasSent(record, reports: reports) + ? PaykitPaymentRequest.DeliveryStatus.sent + : .queued + guard let subscription = PaykitSubscription(record: record, deliveryStatusOverride: deliveryStatus) else { + throw PaykitPaymentRequestError.requestUnavailable + } + return subscription + } + + private func subscriptionTerms( + _ draft: PaykitSubscriptionDraft, + iconURI: String?, + endpoints: [String], + proposalDate: Date + ) throws -> Paykit.PaymentRequestTerms { + var subscriptionMetadata: [String: Any] = [ + "version": 1, + "description": draft.description.trimmingCharacters(in: .whitespacesAndNewlines), + "benefits": [], + ] + if let iconURI { + subscriptionMetadata["icon_uri"] = iconURI + } + let metadataData = try JSONSerialization.data(withJSONObject: [ + "note": draft.name.trimmingCharacters(in: .whitespacesAndNewlines), + "subscription": subscriptionMetadata, + ]) + let metadataText = String(decoding: metadataData, as: UTF8.self) + let timestamp = Self.timestamp(proposalDate) + let recurrence = Paykit.PaymentRequestRecurrence( + every: 1, + unit: draft.frequency.rawValue, + startsAt: timestamp, + anchor: timestamp, + endsAt: nil + ) + return try Paykit.PaymentRequestTerms( + amount: Paykit.PaymentRequestAmount( + value: WalletViewModel.formatBitcoinAmount(sats: draft.amountSats), + asset: PaykitIssuerInterop.bitcoinAsset + ), + paymentReference: Paykit.PaymentReference(text: "bitkit-\(UUID().uuidString)"), + proposalExpiresAt: Self.timestamp(draft.expiresAt), + recurrence: recurrence, + acceptedPaymentEndpointIdentifiers: endpoints, + metadata: Paykit.PrivateJsonObject(text: metadataText) + ) + } + func accept(_ request: PaykitPaymentRequest) async throws { guard !request.isExpired(at: now()) else { throw PaykitPaymentRequestError.requestExpired @@ -682,7 +822,7 @@ struct PaykitPaymentRequestService { } func cancel(_ subscription: PaykitSubscription) async throws -> PaykitSubscription { - guard subscription.isActive(at: now()) else { + guard subscription.canCancel(at: now()) else { throw PaykitPaymentRequestError.requestUnavailable } @@ -717,6 +857,22 @@ struct PaykitPaymentRequestService { return formatter.string(from: date) } + static func compressedSubscriptionIcon(_ data: Data) throws -> Data { + guard let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary), + let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 400, + ] as CFDictionary) + else { + throw PaykitPaymentRequestError.requestUnavailable + } + guard let compressed = UIImage(cgImage: thumbnail).jpegData(compressionQuality: 0.8) else { + throw PaykitPaymentRequestError.requestUnavailable + } + return compressed + } + @discardableResult private func processPendingMessages() async throws -> [Paykit.OutboundPrivateCounterpartySendReport] { do { @@ -747,6 +903,7 @@ struct PaykitPaymentRequestService { _ record: Paykit.PaymentRequestRecord, reports: [Paykit.OutboundPrivateCounterpartySendReport] ) -> Bool { + if case .sent? = record.proposalOutboundStatus { return true } guard let messageId = record.proposalOutboundMessageId else { return false } return reports.contains { report in PubkyPublicKeyFormat.matches(report.counterparty, record.counterparty) && @@ -1037,6 +1194,56 @@ final class PaykitPaymentRequestManager { return request } + func proposeSubscription( + _ draft: PaykitSubscriptionDraft, + to target: PaykitPaymentRequestTarget + ) async throws -> PaykitSubscription { + guard draft.amountSats > 0, + isAvailable(), + let activeIdentity, + eligibleTargets.contains(target) + else { + throw PaykitPaymentRequestError.requestUnavailable + } + guard !isCreatingRequest else { + throw PaykitPaymentRequestError.operationInProgress + } + + let actionGeneration = stateGeneration + let savedPublicKeysSnapshot = savedPublicKeys + isCreatingRequest = true + defer { + if actionGeneration == stateGeneration { + isCreatingRequest = false + } + } + let subscription = try await service.proposeSubscription( + draft, + to: target, + savedPublicKeys: savedPublicKeysSnapshot, + expectedIdentity: activeIdentity, + validateBeforeProposing: { + guard actionGeneration == self.stateGeneration, + self.isAvailable(), + PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity), + self.savedPublicKeys.contains(where: { PubkyPublicKeyFormat.matches($0, target.publicKey) }) + else { + throw PaykitPaymentRequestError.requestUnavailable + } + } + ) + if actionGeneration == stateGeneration, + isAvailable(), + PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) + { + invalidateRefresh() + subscriptions.removeAll { $0.id == subscription.id } + subscriptions.append(subscription) + discardExpiredRequests() + } + return subscription + } + func refresh() async { await refresh(excludingProtectedRequestId: nil) } @@ -1137,6 +1344,7 @@ final class PaykitPaymentRequestManager { func requestSubscriptionPresentation(_ subscription: PaykitSubscription) { guard subscriptions.contains(where: { $0.id == subscription.id }), + subscription.isPayer, subscription.isProposalVisible(at: now()), !isProcessingSubscription else { return } @@ -1146,11 +1354,11 @@ final class PaykitPaymentRequestManager { func subscriptionProposalForPresentation() -> PaykitSubscription? { if let requestedSubscriptionProposalId { return subscriptions.first { - $0.id == requestedSubscriptionProposalId && $0.isProposalVisible(at: now()) + $0.id == requestedSubscriptionProposalId && $0.isPayer && $0.isProposalVisible(at: now()) } } return subscriptions.first { - $0.isProposalVisible(at: now()) && !presentedSubscriptionProposalIds.contains($0.id) + $0.isPayer && $0.isProposalVisible(at: now()) && !presentedSubscriptionProposalIds.contains($0.id) } } @@ -1191,6 +1399,7 @@ final class PaykitPaymentRequestManager { guard !isProcessingSubscription else { throw PaykitPaymentRequestError.operationInProgress } guard let current = subscriptions.first(where: { $0.id == subscription.id }), current == subscription, + current.isPayer, current.isProposalActionable(at: now()), let activeIdentity else { throw PaykitPaymentRequestError.requestUnavailable } @@ -1222,7 +1431,7 @@ final class PaykitPaymentRequestManager { func cancel(_ subscription: PaykitSubscription) async throws { guard !isProcessingSubscription else { throw PaykitPaymentRequestError.operationInProgress } guard let current = subscriptions.first(where: { $0.id == subscription.id }), - current.isActive(at: now()), + current.canCancel(at: now()), let activeIdentity else { throw PaykitPaymentRequestError.requestUnavailable @@ -1236,12 +1445,14 @@ final class PaykitPaymentRequestManager { } } - let protectedRequestIds = try await protectedRequestIdsForSubscriptionCancellation(activeIdentity, current.id) - guard actionGeneration == stateGeneration, - PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) - else { return } - guard protectedRequestIds.isEmpty else { - throw PaykitPaymentRequestError.operationInProgress + if current.isPayer { + let protectedRequestIds = try await protectedRequestIdsForSubscriptionCancellation(activeIdentity, current.id) + guard actionGeneration == stateGeneration, + PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) + else { return } + guard protectedRequestIds.isEmpty else { + throw PaykitPaymentRequestError.operationInProgress + } } let canceledSubscription = try await service.cancel(current) @@ -1505,15 +1716,18 @@ final class PaykitPaymentRequestManager { let handledRequestedExpirationId = recordRequestedPresentationExpiration(at: refreshDate) let previousPending = pendingRequests subscriptions = snapshot.subscriptions.map { $0.withExpiredLifecycle(at: refreshDate) } - let visibleProposalIds = Set(subscriptions.filter { $0.isProposalVisible(at: refreshDate) }.map(\.id)) + let visibleProposalIds = Set(subscriptions.filter { + $0.isPayer && $0.isProposalVisible(at: refreshDate) + }.map(\.id)) presentedSubscriptionProposalIds.formIntersection(visibleProposalIds) for subscription in subscriptions - where subscription.wasAccepted && + where subscription.isPayer && + subscription.wasAccepted && subscriptionAcceptedAt[subscription.id] == nil { subscriptionAcceptedAt[subscription.id] = subscription.paidPeriods.map(\.startsAt).min() ?? subscription.createdAt ?? refreshDate } - let recurringRequestsBySubscription = subscriptions.map { subscription in + let recurringRequestsBySubscription = subscriptions.filter(\.isPayer).map { subscription in let requests: [PaykitPaymentRequest] = if let acceptedAt = subscriptionAcceptedAt[subscription.id] { subscription.requests(through: refreshDate, acceptedAt: acceptedAt) } else { @@ -1606,6 +1820,19 @@ final class PaykitPaymentRequestManager { subscriptions.removeAll { $0.id == subscription.id } subscriptions.append(subscription) + guard subscription.isPayer else { + await subscriptionNotificationScheduler.synchronize( + subscriptions.filter(\.isPayer), + acceptedAt: subscriptionAcceptedAt, + pendingRequestIds: Set(pendingRequests.map(\.id)), + payerIdentity: activeIdentity, + notificationsEnabled: SettingsViewModel.shared.enableNotifications, + now: date + ) + discardExpiredRequests() + return + } + let recurringRequests = subscriptionAcceptedAt[subscription.id].map { subscription.requests(through: date, acceptedAt: $0) } ?? [] @@ -1702,7 +1929,7 @@ final class PaykitPaymentRequestManager { pendingRequests.removeAll { $0.isExpired(at: date) } subscriptions = subscriptions.map { $0.withExpiredLifecycle(at: date) } presentedSubscriptionProposalIds.formIntersection( - Set(subscriptions.filter { $0.isProposalVisible(at: date) }.map(\.id)) + Set(subscriptions.filter { $0.isPayer && $0.isProposalVisible(at: date) }.map(\.id)) ) persistSubscriptionState() if requestedSubscriptionProposalId.map({ id in diff --git a/Bitkit/Services/PaykitSubscription.swift b/Bitkit/Services/PaykitSubscription.swift index 38e867b83..73e5aa5e4 100644 --- a/Bitkit/Services/PaykitSubscription.swift +++ b/Bitkit/Services/PaykitSubscription.swift @@ -11,17 +11,17 @@ private struct PaykitPreciseInstant: Comparable, Hashable { Date(timeIntervalSince1970: Double(seconds) + Double(nanoseconds) / 1_000_000_000) } + var wholeSecondDate: Date { + Date(timeIntervalSince1970: TimeInterval(seconds)) + } + init?(timestamp: String) { let canonical = PaykitSubscriptionTimestamp.canonical(timestamp) let fraction = PaykitSubscriptionTimestamp.fractionalSeconds(from: canonical) ?? "" let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - var parsedDate = formatter.date(from: canonical) - if parsedDate == nil { - formatter.formatOptions = [.withInternetDateTime] - parsedDate = formatter.date(from: canonical) - } - guard let date = parsedDate else { return nil } + formatter.formatOptions = [.withInternetDateTime] + let wholeSecondTimestamp = fraction.isEmpty ? canonical : canonical.replacingOccurrences(of: ".\(fraction)", with: "") + guard let date = formatter.date(from: wholeSecondTimestamp) else { return nil } seconds = Int64(floor(date.timeIntervalSince1970)) nanoseconds = Int(fraction.padding(toLength: 9, withPad: "0", startingAt: 0)) ?? 0 @@ -114,6 +114,7 @@ struct PaykitBillingPeriod: Codable, Hashable { struct PaykitSubscriptionMetadata: Hashable { let description: String? let benefits: [String] + let iconURI: String? init(_ metadata: Paykit.PrivateJsonObject) { guard let data = metadata.exportText().data(using: .utf8), @@ -123,6 +124,7 @@ struct PaykitSubscriptionMetadata: Hashable { else { description = nil benefits = [] + iconURI = nil return } @@ -130,6 +132,8 @@ struct PaykitSubscriptionMetadata: Hashable { benefits = (subscription["benefits"] as? [String] ?? []) .prefix(8) .compactMap { Self.trimmed($0, limit: 160) } + iconURI = Self.trimmed(subscription["icon_uri"] as? String, limit: 512) + .flatMap { $0.hasPrefix("pubky://") ? $0 : nil } } private static func trimmed(_ value: String?, limit: Int) -> String? { @@ -198,8 +202,18 @@ struct PaykitSubscriptionRecurrence: Hashable { } func periods(through date: Date, acceptedAt: Date) -> [PaykitBillingPeriod] { - let preciseDate = PaykitPreciseInstant(date: date) - let preciseAcceptedAt = PaykitPreciseInstant(date: acceptedAt) + periods(through: PaykitPreciseInstant(date: date), acceptedAt: PaykitPreciseInstant(date: acceptedAt)) + } + + func contains(_ period: PaykitBillingPeriod) -> Bool { + guard let start = PaykitPreciseInstant(timestamp: period.sdkValue.startsAt) else { return false } + return periods(through: start, acceptedAt: start).first == period + } + + private func periods( + through preciseDate: PaykitPreciseInstant, + acceptedAt preciseAcceptedAt: PaykitPreciseInstant + ) -> [PaykitBillingPeriod] { guard unit.isSupported, preciseStartsAt <= preciseDate else { return [] } var periods: [PaykitBillingPeriod] = [] @@ -295,17 +309,17 @@ struct PaykitSubscriptionRecurrence: Hashable { let boundaryDate: Date? = switch unit { case .minute: - calendar.date(byAdding: .minute, value: value, to: preciseAnchor.date) + calendar.date(byAdding: .minute, value: value, to: preciseAnchor.wholeSecondDate) case .hour: - calendar.date(byAdding: .hour, value: value, to: preciseAnchor.date) + calendar.date(byAdding: .hour, value: value, to: preciseAnchor.wholeSecondDate) case .day: - calendar.date(byAdding: .day, value: value, to: preciseAnchor.date) + calendar.date(byAdding: .day, value: value, to: preciseAnchor.wholeSecondDate) case .week: - calendar.date(byAdding: .weekOfYear, value: value, to: preciseAnchor.date) + calendar.date(byAdding: .weekOfYear, value: value, to: preciseAnchor.wholeSecondDate) case .month: - Self.monthBoundary(from: preciseAnchor.date, offset: value, calendar: calendar) + Self.monthBoundary(from: preciseAnchor.wholeSecondDate, offset: value, calendar: calendar) case .year: - Self.yearBoundary(from: preciseAnchor.date, offset: value, calendar: calendar) + Self.yearBoundary(from: preciseAnchor.wholeSecondDate, offset: value, calendar: calendar) } guard let boundaryDate else { return nil } return PaykitPreciseInstant(seconds: Int64(floor(boundaryDate.timeIntervalSince1970)), nanoseconds: preciseAnchor.nanoseconds) @@ -316,17 +330,17 @@ struct PaykitSubscriptionRecurrence: Hashable { calendar.timeZone = TimeZone(secondsFromGMT: 0)! let result: Date? = switch unit { case .minute: - calendar.date(byAdding: .minute, value: every, to: date.date) + calendar.date(byAdding: .minute, value: every, to: date.wholeSecondDate) case .hour: - calendar.date(byAdding: .hour, value: every, to: date.date) + calendar.date(byAdding: .hour, value: every, to: date.wholeSecondDate) case .day: - calendar.date(byAdding: .day, value: every, to: date.date) + calendar.date(byAdding: .day, value: every, to: date.wholeSecondDate) case .week: - calendar.date(byAdding: .weekOfYear, value: every, to: date.date) + calendar.date(byAdding: .weekOfYear, value: every, to: date.wholeSecondDate) case .month: - calendar.date(byAdding: .month, value: every, to: date.date) + calendar.date(byAdding: .month, value: every, to: date.wholeSecondDate) case .year: - calendar.date(byAdding: .year, value: every, to: date.date) + calendar.date(byAdding: .year, value: every, to: date.wholeSecondDate) } guard let result else { return nil } return PaykitPreciseInstant(seconds: Int64(floor(result.timeIntervalSince1970)), nanoseconds: date.nanoseconds) @@ -379,6 +393,11 @@ struct PaykitSubscriptionRecurrence: Hashable { } struct PaykitSubscription: Identifiable, Hashable { + enum LocalRole: Hashable { + case payer + case payee + } + struct ID: Codable, Hashable { let paymentRequestId: String let counterparty: String @@ -402,6 +421,8 @@ struct PaykitSubscription: Identifiable, Hashable { let metadata: PaykitSubscriptionMetadata let acceptedPaymentEndpointIdentifiers: [String] let wasAccepted: Bool + let localRole: LocalRole + let deliveryStatus: PaykitPaymentRequest.DeliveryStatus? var lifecycleState: Paykit.PaymentRequestLifecycleState let payments: [Payment] @@ -421,6 +442,14 @@ struct PaykitSubscription: Identifiable, Hashable { lifecycleState == .proposed } + var isPayer: Bool { + localRole == .payer + } + + var isCreatedByUser: Bool { + localRole == .payee + } + func isProposalActionable(at date: Date) -> Bool { isProposalVisible(at: date) && recurrence.unit.isSupported && @@ -438,6 +467,22 @@ struct PaykitSubscription: Identifiable, Hashable { lifecycleState == .activeRecurring && recurrence.endsAt.map { $0 > date } ?? true } + func isCreatedVisible(at date: Date) -> Bool { + isCreatedByUser && (isProposalVisible(at: date) || isActive(at: date)) + } + + func isExpiredVisible(at date: Date) -> Bool { + isExpired(at: date) && (isPayer ? wasAccepted : !payments.isEmpty) + } + + func canCancel(at date: Date) -> Bool { + guard recurrence.endsAt == nil else { return false } + if isCreatedByUser { + return isProposalVisible(at: date) || isActive(at: date) + } + return isActive(at: date) + } + func isExpired(at date: Date) -> Bool { lifecycleState == .canceled || lifecycleState == .rejected || lifecycleState == .proposalExpired || (isProposal && proposalExpiresAt.map { $0 <= date } ?? false) || @@ -453,9 +498,21 @@ struct PaykitSubscription: Identifiable, Hashable { return subscription } - init?(record: Paykit.PaymentRequestRecord) { - guard record.localRole == .payer, - let terms = record.terms, + init?( + record: Paykit.PaymentRequestRecord, + deliveryStatusOverride: PaykitPaymentRequest.DeliveryStatus? = nil + ) { + let localRole: LocalRole + switch record.localRole { + case .payer?: + localRole = .payer + case .payee?: + localRole = .payee + case .unknown?, nil: + return nil + } + + guard let terms = record.terms, let recurrence = terms.recurrence.flatMap(PaykitSubscriptionRecurrence.init), terms.amount.asset == PaykitIssuerInterop.bitcoinAsset, let amountSats = PaykitPaymentRequest.sats(fromBitcoinAmount: terms.amount.value), @@ -482,18 +539,27 @@ struct PaykitSubscription: Identifiable, Hashable { network: Env.network ) wasAccepted = record.acceptedEventId != nil || record.state == .activeRecurring || !record.paymentProofs.isEmpty + self.localRole = localRole + deliveryStatus = localRole == .payee + ? deliveryStatusOverride ?? Self.deliveryStatus(from: record.proposalOutboundStatus) + : nil lifecycleState = record.state - payments = record.paymentProofs.compactMap { proof in - guard let billingPeriod = proof.billingPeriod.flatMap(PaykitBillingPeriod.init) else { return nil } - return Payment( + var paymentsByPeriod: [PaykitBillingPeriod: Payment] = [:] + for proof in record.paymentProofs { + guard let billingPeriod = proof.billingPeriod.flatMap(PaykitBillingPeriod.init), + recurrence.contains(billingPeriod) + else { continue } + paymentsByPeriod[billingPeriod] = Payment( billingPeriod: billingPeriod, proofKind: PaykitPaymentProofKind(paymentEndpointIdentifier: proof.paymentEndpointIdentifier) ) } + payments = paymentsByPeriod.values.sorted { $0.billingPeriod.startsAt < $1.billingPeriod.startsAt } } func requests(through date: Date, acceptedAt: Date) -> [PaykitPaymentRequest] { - recurrence.periods(through: date, acceptedAt: acceptedAt).map { period in + guard isPayer else { return [] } + return recurrence.periods(through: date, acceptedAt: acceptedAt).map { period in let payment = payments.last { $0.billingPeriod == period } return PaykitPaymentRequest( subscription: self, @@ -505,9 +571,32 @@ struct PaykitSubscription: Identifiable, Hashable { } func paymentDueOnAcceptance(at date: Date) -> PaykitPaymentRequest? { + guard isPayer else { return nil } guard let period = recurrence.periods(through: date, acceptedAt: date).first else { return nil } return PaykitPaymentRequest(subscription: self, billingPeriod: period, lifecycleState: .activeRecurring) } + + func receivedPaymentRequests() -> [PaykitPaymentRequest] { + guard isCreatedByUser else { return [] } + return payments.map { + PaykitPaymentRequest( + subscription: self, + billingPeriod: $0.billingPeriod, + lifecycleState: .proofSubmitted, + paymentProofKind: $0.proofKind, + direction: .outgoing + ) + } + } + + private static func deliveryStatus( + from status: Paykit.OutboundPrivateMessageStatus? + ) -> PaykitPaymentRequest.DeliveryStatus { + if case .sent? = status { + return .sent + } + return .queued + } } struct PaykitSubscriptionState: Codable, Equatable { @@ -588,7 +677,8 @@ actor PaykitSubscriptionNotificationScheduler { let currentGeneration = generation let notifications: [(PaykitSubscription, PaykitBillingPeriod)] = notificationsEnabled ? Array(subscriptions .filter { - $0.isActive(at: now) && + $0.isPayer && + $0.isActive(at: now) && $0.recurrence.unit.isSupported && acceptedAt[$0.id] != nil } diff --git a/Bitkit/Services/PaykitSubscriptionProposal.swift b/Bitkit/Services/PaykitSubscriptionProposal.swift new file mode 100644 index 000000000..675a822a0 --- /dev/null +++ b/Bitkit/Services/PaykitSubscriptionProposal.swift @@ -0,0 +1,43 @@ +import Foundation +import Paykit + +enum PaykitSubscriptionProposal { + // Paykit's proposal wire format is carried in a pubky-noise message capped at 1,000 UTF-8 bytes. + static let maximumMessageBytes = 1000 + static let reservedIconURI = "pubky://" + String(repeating: "x", count: 122) + + static func validate(_ terms: Paykit.PaymentRequestTerms) throws { + guard try encodedSize(terms) <= maximumMessageBytes else { + throw PaykitPaymentRequestError.subscriptionTooLong + } + } + + static func encodedSize(_ terms: Paykit.PaymentRequestTerms) throws -> Int { + guard let recurrence = terms.recurrence else { + throw PaykitPaymentRequestError.requestUnavailable + } + let metadata = try JSONSerialization.jsonObject(with: Data(terms.metadata.exportText().utf8)) + let uuid = "00000000-0000-0000-0000-000000000000" + let wire: [String: Any] = [ + "version": 1, + "kind": "paykit.payment_request", + "event_id": uuid, + "payment_request_id": uuid, + "request": [ + "amount": ["value": terms.amount.value, "asset": terms.amount.asset], + "payment_reference": terms.paymentReference.exportText(), + "proposal_expires_at": terms.proposalExpiresAt as Any? ?? NSNull(), + "recurrence": [ + "every": recurrence.every, + "unit": recurrence.unit, + "starts_at": recurrence.startsAt, + "anchor": recurrence.anchor, + "ends_at": recurrence.endsAt as Any? ?? NSNull(), + ], + "accepted_payment_endpoint_identifiers": terms.acceptedPaymentEndpointIdentifiers, + "metadata": metadata, + ], + ] + return try JSONSerialization.data(withJSONObject: wire, options: [.withoutEscapingSlashes]).count + } +} diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index d65872423..82381de6c 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -255,8 +255,8 @@ enum PubkyService { // MARK: - File Fetching /// Fetch raw bytes from a `pubky://` URI via PKDNS resolution. - static func fetchFile(uri: String) async throws -> Data { - try await PaykitSdkService.shared.fetchFile(uri: uri) + static func fetchFile(uri: String, maxBytes: UInt64) async throws -> Data { + try await PaykitSdkService.shared.fetchFile(uri: uri, maxBytes: maxBytes) } // MARK: - Profile @@ -615,9 +615,9 @@ actor PaykitSdkService { } } - func fetchFile(uri: String) async throws -> Data { + func fetchFile(uri: String, maxBytes: UInt64) async throws -> Data { try await operationLock.withLock { - guard let data = try await handle().fetchPubkyFile(uri: uri) else { + guard let data = try await handle().fetchPubkyFileBounded(uri: uri, maxBytes: maxBytes) else { throw PubkyServiceError.profileNotFound } return data @@ -630,9 +630,15 @@ actor PaykitSdkService { } } - func uploadProfileAvatar(bytes: Data, contentType: String) async throws -> String { + func uploadProfileAvatar(bytes: Data, contentType: String, expectedIdentity: String? = nil) async throws -> String { let record = try await withStateRevisionTracking { sdk in - try await sdk.uploadProfileAvatar(bytes: bytes, contentType: contentType) + if let expectedIdentity { + guard let identity = try await sdk.identityStatus(), + identity.liveSessionAvailable, + PubkyPublicKeyFormat.matches(identity.publicKey, expectedIdentity) + else { throw PaykitPaymentRequestError.requestUnavailable } + } + return try await sdk.uploadProfileAvatar(bytes: bytes, contentType: contentType) } return record.uri } diff --git a/Bitkit/Styles/TextStyle.swift b/Bitkit/Styles/TextStyle.swift index 403564a8f..574999f72 100644 --- a/Bitkit/Styles/TextStyle.swift +++ b/Bitkit/Styles/TextStyle.swift @@ -12,6 +12,7 @@ struct DisplayText: View { var accentColor: Color = .brandAccent var accentFont: ((CGFloat) -> Font)? var accentAction: (() -> Void)? + var fillsWidth: Bool private let fontSize: CGFloat = 44 @@ -20,13 +21,15 @@ struct DisplayText: View { textColor: Color = .textPrimary, accentColor: Color = .brandAccent, accentFont: ((CGFloat) -> Font)? = nil, - accentAction: (() -> Void)? = nil + accentAction: (() -> Void)? = nil, + fillsWidth: Bool = true ) { self.text = text self.textColor = textColor self.accentColor = accentColor self.accentFont = accentFont self.accentAction = accentAction + self.fillsWidth = fillsWidth } var body: some View { @@ -42,7 +45,7 @@ struct DisplayText: View { .environment(\._lineHeightMultiple, 0.83) .textCase(.uppercase) .padding(.bottom, -9) - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: fillsWidth ? .infinity : nil, alignment: .leading) .dynamicTypeSize(...DynamicTypeSize.xxLarge) } } diff --git a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift index c3fa47c91..5cbc7bfb0 100644 --- a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift +++ b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift @@ -123,10 +123,32 @@ struct RequestOrPayView: View { } struct PaymentRequestRecipientView: View { + let onSelect: (PaykitPaymentRequestTarget) -> Void + + var body: some View { + PaykitRecipientPicker( + selectedTarget: nil, + onSelect: onSelect, + accessibilityIdentifier: "PaymentRequestRecipient", + testIdentifierPrefix: "PaymentRequest" + ) { + SheetHeader(title: t("wallet__payment_request_choose_recipient"), showBackButton: true) + } footer: { + EmptyView() + } + } +} + +struct PaykitRecipientPicker: View { @EnvironmentObject private var contactsManager: ContactsManager @Environment(PaykitPaymentRequestManager.self) private var paymentRequests + let selectedTarget: PaykitPaymentRequestTarget? let onSelect: (PaykitPaymentRequestTarget) -> Void + let accessibilityIdentifier: String + let testIdentifierPrefix: String + @ViewBuilder let header: () -> Header + @ViewBuilder let footer: () -> Footer @State private var recipientQuery = "" @@ -145,26 +167,29 @@ struct PaymentRequestRecipientView: View { var body: some View { VStack(spacing: 0) { - SheetHeader(title: t("wallet__payment_request_choose_recipient"), showBackButton: true) + header() recipientInput - .padding(.bottom, 16) + .padding(.bottom, 32) ScrollView(showsIndicators: false) { LazyVStack(alignment: .leading, spacing: 0) { CaptionMText(t("contacts__nav_title").localizedUppercase, textColor: .white64) - .padding(.vertical, 16) + .padding(.bottom, 16) CustomDivider() ForEach(recipientTargets) { target in recipientRow(target) } } } + + footer() } .padding(.horizontal, 16) .sheetBackground() .navigationBarHidden(true) - .accessibilityIdentifier("PaymentRequestRecipient") + .accessibilityElement(children: .contain) + .accessibilityIdentifier(accessibilityIdentifier) } private var recipientInput: some View { @@ -176,9 +201,10 @@ struct PaymentRequestRecipientView: View { t("wallet__payment_request_enter_pubky"), text: $recipientQuery, backgroundColor: .clear, - font: .custom(Fonts.regular, size: 17), - testIdentifier: "PaymentRequestRecipientFilter" + testIdentifier: "\(testIdentifierPrefix)RecipientFilter", + contentPadding: 0 ) + .frame(minHeight: 20) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.asciiCapable) @@ -194,14 +220,15 @@ struct PaymentRequestRecipientView: View { .scaledToFit() .frame(width: 16, height: 16) .accessibilityHidden(true) - BodyMSBText(t("common__paste")) + CaptionBText(t("common__paste"), textColor: .textPrimary) } + .padding(.horizontal, 8) } .buttonStyle(.plain) - .accessibilityIdentifier("PaymentRequestRecipientPaste") + .accessibilityIdentifier("\(testIdentifierPrefix)RecipientPaste") } .padding(16) - .background(Color.white08) + .background(Color.white10) .clipShape(RoundedRectangle(cornerRadius: 8)) } } @@ -217,10 +244,10 @@ struct PaymentRequestRecipientView: View { @ViewBuilder private func recipientRow(_ target: PaykitPaymentRequestTarget) -> some View { if let contact = contact(for: target) { - PubkyContactRow(contact: contact, verticalPadding: 20) { + PubkyContactRow(contact: contact, verticalPadding: 24, isSelected: selectedTarget == target) { onSelect(target) } - .accessibilityIdentifier("PaymentRequestContact-\(contact.publicKey)") + .accessibilityIdentifier("\(testIdentifierPrefix)Contact\(contact.publicKey)") } else { Button { onSelect(target) @@ -229,12 +256,19 @@ struct PaymentRequestRecipientView: View { ContactAvatarLetter(source: target.publicKey, size: 48) BodyMSBText(PubkyPublicKeyFormat.displayTruncated(target.publicKey)) Spacer() + if selectedTarget == target { + Image("check-mark") + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(.brandAccent) + .accessibilityHidden(true) + } } - .padding(.vertical, 20) + .padding(.vertical, 24) .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityIdentifier("PaymentRequestTarget-\(target.id)") + .accessibilityIdentifier("\(testIdentifierPrefix)Target-\(target.id)") CustomDivider() } } @@ -245,8 +279,10 @@ struct PaymentRequestAmountView: View { @EnvironmentObject private var currency: CurrencyViewModel let initialDraft: PaykitPaymentRequestDraft - let target: PaykitPaymentRequestTarget + let target: PaykitPaymentRequestTarget? let onContinue: (PaykitPaymentRequestDraft) -> Void + var onBack: (() -> Void)? + var testIdentifierPrefix = "PaymentRequest" @State private var amountViewModel = AmountInputViewModel() @@ -255,14 +291,15 @@ struct PaymentRequestAmountView: View { SheetHeader( title: t("wallet__payment_request_amount"), showBackButton: true, - action: AnyView(targetAvatar) + action: target == nil ? nil : AnyView(targetAvatar), + onBack: onBack ) NumberPadTextField( viewModel: amountViewModel, showEditButton: false, isFocused: true, - testIdentifier: "PaymentRequestAmountField" + testIdentifier: "\(testIdentifierPrefix)AmountField" ) Spacer() @@ -283,7 +320,7 @@ struct PaymentRequestAmountView: View { ) ) } - .accessibilityIdentifier("PaymentRequestAmountContinue") + .accessibilityIdentifier("\(testIdentifierPrefix)AmountContinue") } .padding(.horizontal, 16) .sheetBackground() @@ -295,10 +332,12 @@ struct PaymentRequestAmountView: View { @ViewBuilder private var targetAvatar: some View { - if let contact = contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, target.publicKey) }) { - PubkyContactAvatar(contact: contact, size: 24) - } else { - ContactAvatarLetter(source: target.publicKey, size: 24) + if let target { + if let contact = contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, target.publicKey) }) { + PubkyContactAvatar(contact: contact, size: 24) + } else { + ContactAvatarLetter(source: target.publicKey, size: 24) + } } } } diff --git a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift new file mode 100644 index 000000000..6284a3cc1 --- /dev/null +++ b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift @@ -0,0 +1,373 @@ +import PhotosUI +import SwiftUI + +struct CreateSubscriptionView: View { + @EnvironmentObject private var app: AppViewModel + + @Binding var draft: PaykitSubscriptionDraft + let onEditAmount: () -> Void + let onChooseRecipient: () -> Void + + @State private var selectedPhotoItem: PhotosPickerItem? + @FocusState private var isNameFocused: Bool + @FocusState private var isDescriptionFocused: Bool + + private var isLoadingIcon: Bool { + selectedPhotoItem != nil + } + + private var iconImage: UIImage? { + draft.iconData.flatMap { UIImage(data: $0) } + } + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("subscriptions__create_subscription")) + + ScrollViewReader { proxy in + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 24) { + amount + frequency + name + description + .id("SubscriptionDescription") + customIcon + } + .padding(.bottom, 24) + } + .onChange(of: isDescriptionFocused ? geometry.size.height : nil) { _, focusedHeight in + if focusedHeight != nil { + withAnimation { + proxy.scrollTo("SubscriptionDescription", anchor: .bottom) + } + } + } + } + } + + CustomButton( + title: t("subscriptions__choose_recipient"), + isDisabled: draft.amountSats == 0 || + draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + isLoadingIcon + ) { + onChooseRecipient() + } + .buttonBottomPadding(isFocused: isNameFocused || isDescriptionFocused) + .accessibilityIdentifier("SubscriptionChooseRecipient") + } + .padding(.horizontal, 16) + .sheetBackground() + .navigationBarHidden(true) + .onChange(of: draft.name) { _, value in + if value.count > 256 { + draft.name = String(value.prefix(256)) + } + } + .onChange(of: draft.description) { _, value in + if value.count > 1024 { + draft.description = String(value.prefix(1024)) + } + } + .task(id: selectedPhotoItem) { + await loadIcon(selectedPhotoItem) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("CreateSubscription") + } + + private var amount: some View { + VStack(alignment: .leading, spacing: 12) { + CaptionMText(t("wallet__payment_request_amount").localizedUppercase, textColor: .white64) + Button(action: onEditAmount) { + HStack(spacing: 8) { + MoneyText( + sats: Int(clamping: draft.amountSats), + unitType: .primary, + size: .display, + symbol: true, + color: .textPrimary, + symbolColor: .textSecondary, + fillsWidth: false + ) + .lineLimit(1) + .minimumScaleFactor(0.5) + Image("pencil") + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(.textPrimary) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("SubscriptionEditAmount") + } + } + + private var frequency: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("subscriptions__frequency").localizedUppercase, textColor: .white64) + SegmentedControl( + selectedTab: Binding( + get: { SubscriptionFrequencyOption.allCases.first { $0.unit == draft.frequency } ?? .month }, + set: { draft.frequency = $0.unit } + ), + tabItems: SubscriptionFrequencyOption.allCases.map { + TabItem($0, accessibilityIdentifier: "Tab-\($0.rawValue)") + }, + inactiveColor: .white.opacity(0.5) + ) + } + } + + private var name: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("subscriptions__name").localizedUppercase, textColor: .white64) + TextField( + t("subscriptions__name_placeholder"), + text: $draft.name, + testIdentifier: "SubscriptionName" + ) + .focused($isNameFocused) + } + } + + private var description: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("subscriptions__description").localizedUppercase, textColor: .white64) + NoteTextEditor( + text: $draft.description, + placeholder: t("subscriptions__description_placeholder"), + testIdentifier: "SubscriptionDescription", + isFocused: $isDescriptionFocused, + minHeight: 60, + maxHeight: 60, + backgroundColor: .white10 + ) + } + } + + private var customIcon: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("subscriptions__custom_icon").localizedUppercase, textColor: .white64) + PhotosPicker(selection: $selectedPhotoItem, matching: .images) { + HStack(spacing: 16) { + Group { + if let iconImage { + Image(uiImage: iconImage) + .resizable() + .scaledToFill() + } else { + SubscriptionDefaultIcon(size: 40) + } + } + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + CaptionBText(t("subscriptions__custom_icon_description"), textColor: .white64) + Spacer() + } + .padding(16) + .background(Color.white10) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .disabled(isLoadingIcon) + .accessibilityIdentifier("SubscriptionIconPicker") + } + } + + private func loadIcon(_ item: PhotosPickerItem?) async { + guard let item else { return } + defer { if selectedPhotoItem == item { selectedPhotoItem = nil } } + do { + guard let data = try await item.loadTransferable(type: Data.self) else { + throw PaykitPaymentRequestError.requestUnavailable + } + try Task.checkCancellation() + let compressed = try PaykitPaymentRequestService.compressedSubscriptionIcon(data) + guard selectedPhotoItem == item else { return } + draft.iconData = compressed + } catch is CancellationError { + return + } catch { + app.toast(type: .error, title: t("common__error"), description: t("subscriptions__icon_error")) + } + } +} + +struct SubscriptionAmountView: View { + let initialAmountSats: UInt64 + let onBack: () -> Void + let onContinue: (UInt64) -> Void + + var body: some View { + PaymentRequestAmountView( + initialDraft: PaykitPaymentRequestDraft(amountSats: initialAmountSats, note: "", expiresAt: .distantFuture), + target: nil, + onContinue: { onContinue($0.amountSats) }, + onBack: onBack, + testIdentifierPrefix: "PaymentRequest" + ) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SubscriptionAmount") + } +} + +struct SubscriptionRecipientView: View { + @EnvironmentObject private var app: AppViewModel + @Environment(PaykitPaymentRequestManager.self) private var paymentRequests + + @Binding var draft: PaykitSubscriptionDraft + @Binding var selectedTarget: PaykitPaymentRequestTarget? + let onBack: () -> Void + let onSent: (PaykitSubscription) -> Void + + var body: some View { + PaykitRecipientPicker( + selectedTarget: selectedTarget, + onSelect: { selectedTarget = $0 }, + accessibilityIdentifier: "SubscriptionRecipient", + testIdentifierPrefix: "Subscription" + ) { + SheetHeader( + title: t("subscriptions__choose_recipient"), + showBackButton: true, + action: AnyView(expirationMenu), + onBack: onBack + ) + } footer: { + CustomButton( + title: t("subscriptions__propose_subscription"), + isDisabled: selectedTarget == nil, + isLoading: paymentRequests.isCreatingRequest + ) { + await propose() + } + .padding(.bottom, 16) + .accessibilityIdentifier("SubscriptionPropose") + } + .interactiveDismissDisabled(paymentRequests.isCreatingRequest) + } + + private var expirationMenu: some View { + Menu { + ForEach(PaymentRequestExpiration.allCases) { expiration in + Button(expiration.description) { + draft.expiresAt = expiration.date(from: Date()) + } + } + } label: { + Image("timer-outline") + .resizable() + .frame(width: 18, height: 21) + .frame(width: 24, height: 24, alignment: .top) + .foregroundColor(.textPrimary) + } + .accessibilityLabel(t("wallet__payment_request_expires")) + .accessibilityIdentifier("SubscriptionExpiration") + } + + private func propose() async { + guard let selectedTarget else { return } + do { + let subscription = try await paymentRequests.proposeSubscription(draft, to: selectedTarget) + guard paymentRequests.subscriptions.contains(where: { $0.id == subscription.id }) else { return } + onSent(subscription) + } catch { + app.toast(error) + } + } +} + +struct SubscriptionProposalSentView: View { + @EnvironmentObject private var contactsManager: ContactsManager + @EnvironmentObject private var sheets: SheetViewModel + + let subscription: PaykitSubscription + + private var contact: PubkyContact? { + contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, subscription.counterparty) } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t(subscription.deliveryStatus == .sent + ? "wallet__payment_request_sent_title" + : "subscriptions__proposal_queued_title")) + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + Image("check") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + DisplayText(t(subscription.deliveryStatus == .sent + ? "subscriptions__proposal_sent_headline" + : "subscriptions__proposal_queued_headline"), accentColor: .purpleAccent) + .padding(.bottom, 8) + BodyMText( + subscription.deliveryStatus == .sent + ? t("subscriptions__proposal_sent_description") + : t("subscriptions__proposal_queued_description"), + textColor: .white64 + ) + .padding(.bottom, 16) + if let contact { + PubkyContactRow(contact: contact, verticalPadding: 16, showsDivider: false) {} + .padding(.horizontal, 16) + .background(Color.gray6) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .allowsHitTesting(false) + } + SubscriptionRow( + subscription: subscription, + now: Date(), + subtitle: subscription.recurrence.subscriptionFrequencyLabel + ) + .padding(.top, 16) + Spacer().frame(height: 24) + } + .frame(minHeight: geometry.size.height, alignment: .bottom) + } + } + CustomButton(title: t("common__ok")) { + sheets.hideSheet(reason: "Subscription proposal created") + } + .padding(.bottom, 16) + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .sheetBackground() + .navigationBarHidden(true) + .allowSwipeBack(false) + .accessibilityIdentifier("SubscriptionProposalSent") + } +} + +private enum SubscriptionFrequencyOption: String, CaseIterable, CustomStringConvertible { + case day + case week + case month + case year + + var unit: PaykitSubscriptionRecurrence.Unit { + switch self { + case .day: .day + case .week: .week + case .month: .month + case .year: .year + } + } + + var description: String { + switch self { + case .day: t("subscriptions__daily") + case .week: t("subscriptions__weekly") + case .month: t("subscriptions__monthly") + case .year: t("subscriptions__yearly") + } + } +} diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index cdafd057c..2b77f7360 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -3,6 +3,10 @@ import SwiftUI struct SubscriptionSheetItem: SheetItem { enum Route: Hashable { + case create + case createAmount + case createRecipient + case proposalSent(PaykitSubscription) case review(PaykitSubscription) case success case details(PaykitSubscription) @@ -42,21 +46,23 @@ struct SubscriptionsView: View { } private var proposals: [PaykitSubscription] { - paymentRequests.subscriptions.filter { $0.isProposalVisible(at: now) } + paymentRequests.subscriptions.filter { $0.isPayer && $0.isProposalVisible(at: now) } } private var active: [PaykitSubscription] { - paymentRequests.subscriptions.filter { $0.isActive(at: now) } + paymentRequests.subscriptions.filter { $0.isPayer && $0.isActive(at: now) } } private var expired: [PaykitSubscription] { - paymentRequests.subscriptions.filter { - $0.isExpired(at: now) && $0.wasAccepted - } + paymentRequests.subscriptions.filter { $0.isExpiredVisible(at: now) } + } + + private var created: [PaykitSubscription] { + paymentRequests.subscriptions.filter { $0.isCreatedVisible(at: now) } } private var hasVisibleSubscriptions: Bool { - !proposals.isEmpty || !active.isEmpty || !expired.isEmpty + !proposals.isEmpty || !active.isEmpty || !expired.isEmpty || !created.isEmpty } var body: some View { @@ -67,7 +73,8 @@ struct SubscriptionsView: View { tabItems: [ TabItem(.overview), TabItem(.payments, badge: paymentRequests.pendingRequests.count), - ] + ], + inactiveColor: .white.opacity(0.5) ) if selectedTab == .payments { @@ -81,10 +88,22 @@ struct SubscriptionsView: View { section(t("subscriptions__proposals"), subscriptions: proposals) section(t("subscriptions__active"), subscriptions: active) section(t("subscriptions__expired"), subscriptions: expired) + section(t("subscriptions__created"), subscriptions: created) } .padding(.top, 32) - .padding(.bottom, 120) + .padding(.bottom, 32) + } + } + + if selectedTab == .overview { + CustomButton( + title: t("subscriptions__create"), + variant: .secondary + ) { + sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .create)) } + .padding(.bottom, 16) + .accessibilityIdentifier("SubscriptionCreate") } } .padding(.horizontal, 16) @@ -117,21 +136,21 @@ struct SubscriptionsView: View { VStack(alignment: .leading, spacing: 0) { Spacer() - Image("subscription-clock") + Image("subscription-intro-clock") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 256, height: 256) .frame(maxWidth: .infinity) .accessibilityHidden(true) - Spacer() + Spacer().frame(height: 32) DisplayText(t("subscriptions__empty_headline"), accentColor: .purpleAccent) - Spacer().frame(height: 12) + Spacer().frame(height: 8) BodyMText(t("subscriptions__empty_description"), textColor: .white64) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.bottom, 24) + .padding(.bottom, 32) } private var metrics: some View { @@ -141,17 +160,28 @@ struct SubscriptionsView: View { sats: monthlyCostSats, unitType: .primary, size: .bodyMSB, - prefix: "", + symbol: true, color: .textPrimary, symbolColor: .textSecondary ) + .lineLimit(1) + .minimumScaleFactor(0.5) } + .frame(maxWidth: .infinity, alignment: .leading) Rectangle() .fill(Color.white16) .frame(width: 1, height: 50) SubscriptionMetric(title: t("subscriptions__active"), icon: "arrows-clockwise") { BodyMSBText("\(active.count)") } + .fixedSize(horizontal: true, vertical: false) + Rectangle() + .fill(Color.white16) + .frame(width: 1, height: 50) + SubscriptionMetric(title: t("subscriptions__created"), icon: "asterisk") { + BodyMSBText("\(created.count)") + } + .fixedSize(horizontal: true, vertical: false) } } @@ -162,7 +192,7 @@ struct SubscriptionsView: View { CaptionMText(title.localizedUppercase, textColor: .white64) ForEach(subscriptions) { subscription in Button { - if subscription.isProposalVisible(at: now) { + if subscription.isPayer && subscription.isProposalVisible(at: now) { paymentRequests.requestSubscriptionPresentation(subscription) sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .review(subscription))) } else { @@ -194,7 +224,7 @@ func subscriptionMonthlyCostSats(subscriptions: [PaykitSubscription], now: Date) } } let maximum = NSDecimalNumber(value: Int.max) - return subscriptions.filter { $0.isActive(at: now) }.reduce(into: 0) { total, subscription in + return subscriptions.filter { $0.isPayer && $0.isActive(at: now) }.reduce(into: 0) { total, subscription in var monthlyCost = Decimal(subscription.amountSats) * annualPeriods(subscription.recurrence.unit) / Decimal(subscription.recurrence.every) / 12 var roundedMonthlyCost = Decimal() @@ -222,7 +252,7 @@ private struct SubscriptionMetric: View { @ViewBuilder let content: () -> Content var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { CaptionMText(title.localizedUppercase, textColor: .white64) HStack(spacing: 8) { Image(icon) @@ -232,28 +262,28 @@ private struct SubscriptionMetric: View { content() } } - .frame(maxWidth: .infinity, alignment: .leading) } } struct SubscriptionRow: View { let subscription: PaykitSubscription let now: Date + var subtitle: String? var body: some View { HStack(spacing: 16) { SubscriptionAvatar(subscription: subscription, size: 40) - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 0) { BodyMSBText(subscription.note ?? t("subscriptions__subscription")) .lineLimit(1) - CaptionText(subscription.rowSubtitle(at: now), textColor: .white64) + CaptionBText(subtitle ?? subscription.rowSubtitle(at: now), textColor: .white64) .lineLimit(1) } Spacer(minLength: 8) - MoneyCell(sats: Int(clamping: subscription.amountSats), prefix: "") + MoneyCell(sats: Int(clamping: subscription.amountSats), prefix: "", symbol: true) } .padding(16) .background(Color.gray6) @@ -277,7 +307,11 @@ struct SubscriptionAvatar: View { } var body: some View { - if let contact { + if let iconURI = subscription.metadata.iconURI { + PubkyImage(uri: iconURI, size: size, cornerRadius: size / 5) + } else if subscription.isCreatedByUser { + SubscriptionDefaultIcon(size: size) + } else if let contact { PubkyContactAvatar(contact: contact, size: size) } else { ContactAvatarLetter(source: subscription.counterparty, size: size) @@ -285,6 +319,20 @@ struct SubscriptionAvatar: View { } } +struct SubscriptionDefaultIcon: View { + let size: CGFloat + + var body: some View { + Image("subscription-default-icon") + .resizable() + .scaledToFit() + .padding(size / 8) + .frame(width: size, height: size) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: size / 5)) + } +} + struct SubscriptionDetailView: View { @EnvironmentObject private var sheets: SheetViewModel @Environment(PaykitPaymentRequestManager.self) private var paymentRequests @@ -358,7 +406,7 @@ struct SubscriptionDetailView: View { LabeledDetailCell(title: t("subscriptions__frequency"), value: subscription.recurrence.frequencyValue, icon: "arrows-clockwise") LabeledDetailCell( title: t("subscriptions__status"), - value: subscription.isActive(at: now) ? t("subscriptions__active") : t("subscriptions__expired"), + value: subscription.statusLabel(at: now), icon: "check-mark" ) if subscription.isActive(at: now) || subscription.recurrence.endsAt != nil { @@ -368,23 +416,35 @@ struct SubscriptionDetailView: View { icon: "calendar" ) } + if subscription.isCreatedByUser { + LabeledDetailCell( + title: t("subscriptions__subscribers"), + value: subscription.wasAccepted ? "1" : "0", + icon: "users" + ) + LabeledDetailCell( + title: t("subscriptions__payments"), + value: "\(subscription.payments.count)", + icon: "arrow-down" + ) + } } } @ViewBuilder private func footer(_ subscription: PaykitSubscription) -> some View { let hasMoreInfo = subscription.metadata.description != nil || !subscription.metadata.benefits.isEmpty - let canCancel = subscription.isActive(at: now) && subscription.recurrence.endsAt == nil + let canCancel = subscription.canCancel(at: now) && subscription.recurrence.endsAt == nil if hasMoreInfo || canCancel { HStack(spacing: 16) { - if hasMoreInfo { + if hasMoreInfo && !subscription.isCreatedByUser { CustomButton(title: t("subscriptions__more_info"), variant: .secondary) { sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .details(subscription))) } } if canCancel { CustomButton( - title: t("subscriptions__cancel"), + title: subscription.isCreatedByUser ? t("common__delete") : t("subscriptions__cancel"), icon: Image("x-mark").resizable().frame(width: 16, height: 16) ) { sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .cancel(subscription))) @@ -397,9 +457,9 @@ struct SubscriptionDetailView: View { @ViewBuilder private func payments(_ subscription: PaykitSubscription) -> some View { - let payments = paymentRequests.historyRequests.filter { - $0.belongs(to: subscription) - } + let payments = subscription.isCreatedByUser + ? subscription.receivedPaymentRequests() + : paymentRequests.historyRequests.filter { $0.belongs(to: subscription) } if !payments.isEmpty { VStack(alignment: .leading, spacing: 12) { CaptionMText(t("subscriptions__payments").localizedUppercase, textColor: .white64) @@ -408,7 +468,7 @@ struct SubscriptionDetailView: View { request: payment, subtitleOverride: payment.createdAt.map(Self.dateFormatter.string), isHighlighted: false, - paymentDirection: .incoming + paymentDirection: payment.direction ) } } @@ -460,6 +520,8 @@ struct SubscriptionSheet: View { @State private var previousRoute: SubscriptionSheetItem.Route? @State private var now = Date() @State private var isAccepting = false + @State private var creationDraft = PaykitSubscriptionDraft.empty + @State private var selectedCreationTarget: PaykitPaymentRequestTarget? init(config: SubscriptionSheetItem) { self.config = config @@ -470,6 +532,30 @@ struct SubscriptionSheet: View { var body: some View { Sheet(id: .subscription, data: config) { switch route { + case .create: + CreateSubscriptionView( + draft: $creationDraft, + onEditAmount: { route = .createAmount }, + onChooseRecipient: { route = .createRecipient } + ) + case .createAmount: + SubscriptionAmountView( + initialAmountSats: creationDraft.amountSats, + onBack: { route = .create }, + onContinue: { + creationDraft.amountSats = $0 + route = .create + } + ) + case .createRecipient: + SubscriptionRecipientView( + draft: $creationDraft, + selectedTarget: $selectedCreationTarget, + onBack: { route = .create }, + onSent: { route = .proposalSent($0) } + ) + case let .proposalSent(subscription): + SubscriptionProposalSentView(subscription: subscription) case let .review(subscription): review(subscription) case .success: @@ -512,7 +598,7 @@ struct SubscriptionSheet: View { } now = Date() } - .interactiveDismissDisabled(isAccepting) + .interactiveDismissDisabled(isAccepting || paymentRequests.isCreatingRequest) } private func review(_ subscription: PaykitSubscription) -> some View { @@ -520,7 +606,7 @@ struct SubscriptionSheet: View { return VStack(spacing: 0) { SheetHeader(title: t("subscriptions__review_and_subscribe")) SubscriptionAmountHeader(subscription: subscription) - SubscriptionProviderCard(subscription: subscription) { + SubscriptionProviderCard(subscription: subscription, showsCounterparty: true) { guard !isAccepting else { return } previousRoute = route route = .details(subscription) @@ -737,7 +823,11 @@ struct SubscriptionSheet: View { private func cancel(_ subscription: PaykitSubscription) -> some View { VStack(spacing: 0) { - SheetHeader(title: t("subscriptions__cancel_subscription")) + SheetHeader( + title: subscription.isCreatedByUser + ? t("subscriptions__delete_subscription") + : t("subscriptions__cancel_subscription") + ) SubscriptionAmountHeader(subscription: subscription) SubscriptionProviderCard(subscription: subscription, subtitle: subscription.rowSubtitle(at: now)) { previousRoute = route @@ -753,7 +843,9 @@ struct SubscriptionSheet: View { Spacer() SwipeButton( - title: t("subscriptions__swipe_to_cancel"), + title: subscription.isCreatedByUser + ? t("subscriptions__swipe_to_delete") + : t("subscriptions__swipe_to_cancel"), accentColor: .redAccent, isLoading: paymentRequests.isProcessingSubscription ) { @@ -821,10 +913,17 @@ private struct SubscriptionAmountHeader: View { } private struct SubscriptionProviderCard: View { + @EnvironmentObject private var contactsManager: ContactsManager + let subscription: PaykitSubscription + var showsCounterparty = false var subtitle: String? var action: (() -> Void)? + private var contact: PubkyContact? { + contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, subscription.counterparty) } + } + var body: some View { if let action { Button(action: action) { @@ -844,6 +943,15 @@ private struct SubscriptionProviderCard: View { .lineLimit(1) CaptionText(subtitle ?? subscription.recurrence.subscriptionFrequencyLabel, textColor: .white64) .lineLimit(1) + if showsCounterparty { + if let contact { + CaptionText(contact.displayName, textColor: .white64) + .lineLimit(1) + } + CaptionText(PubkyPublicKeyFormat.displayTruncated(subscription.counterparty), textColor: .white64) + .lineLimit(1) + .accessibilityIdentifier("SubscriptionCounterparty") + } } Spacer() if showsChevron { @@ -910,7 +1018,27 @@ extension PaykitSubscriptionRecurrence { } private extension PaykitSubscription { + func statusLabel(at now: Date) -> String { + if isProposalVisible(at: now) { + return t("subscriptions__pending") + } + return isActive(at: now) ? t("subscriptions__active") : t("subscriptions__expired") + } + func rowSubtitle(at now: Date) -> String { + if isCreatedByUser { + if isProposalVisible(at: now) { + return deliveryStatus == .sent + ? t("subscriptions__proposal_sent_status") + : t("subscriptions__proposal_queued_status") + } + if isActive(at: now) { + let key = payments.count == 1 + ? "subscriptions__created_summary_single_payment" + : "subscriptions__created_summary" + return t(key, variables: ["count": "\(payments.count)"]) + } + } if isProposalVisible(at: now) || !recurrence.unit.isSupported { return recurrence.subscriptionFrequencyLabel } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 4779a4da1..183b8c4ae 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -2,6 +2,7 @@ import BitkitCore import Foundation import Paykit +import UIKit import UserNotifications import XCTest @@ -413,6 +414,374 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(request.billingPeriod?.endsAt, try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-01T08:00:00Z"))) } + func testRefreshKeepsCreatorSubscriptionWithoutGeneratingPayerPayment() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + let record = try paymentRequestRecord( + id: "creator-recurring", + state: .activeRecurring, + role: .payee, + recurrence: recurrence, + metadata: #"{"note":"Creator plan","subscription":{"version":1,"description":"Monthly support","benefits":[],"icon_uri":"pubky://creator/icon"}}"# + ) + let manager = paymentRequestManager( + sdk: PaymentRequestSdkMock(records: [record]), + clock: PaymentRequestTestClock(now) + ) + + await manager.refresh() + + let subscription = try XCTUnwrap(manager.subscriptions.first) + XCTAssertTrue(subscription.isCreatedByUser) + XCTAssertEqual(subscription.note, "Creator plan") + XCTAssertEqual(subscription.metadata.description, "Monthly support") + XCTAssertEqual(subscription.metadata.iconURI, "pubky://creator/icon") + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertTrue(manager.historyRequests.isEmpty) + } + + func testCreatorPaymentsAggregateDuplicateProofEventsForOneBillingPeriod() throws { + let period = BillingPeriod(startsAt: "2027-01-01T08:00:00Z", endsAt: "2027-02-01T08:00:00Z") + let first = try paymentProofRecord( + endpoint: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning, + billingPeriod: period + ) + var second = first + second.eventId = "850e8400-e29b-41d4-a716-446655440000" + var offSchedule = first + offSchedule.eventId = "950e8400-e29b-41d4-a716-446655440000" + offSchedule.billingPeriod = BillingPeriod(startsAt: period.startsAt, endsAt: "2027-02-02T08:00:00Z") + let record = try paymentRequestRecord( + state: .activeRecurring, + role: .payee, + recurrence: PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: period.startsAt, + anchor: period.startsAt, + endsAt: nil + ), + paymentProofs: [first, second, offSchedule] + ) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + XCTAssertEqual(subscription.payments.count, 1) + let received = subscription.receivedPaymentRequests() + XCTAssertEqual(received.count, 1) + XCTAssertEqual(received.first?.direction, .outgoing) + XCTAssertEqual(received.first?.lifecycleState, .proofSubmitted) + XCTAssertEqual(received.first?.paymentProofKind, .lightning) + } + + func testExpiredCreatorSubscriptionsKeepPaidHistoryAccessible() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-15T08:00:00Z")) + let period = BillingPeriod(startsAt: "2027-01-01T08:00:00Z", endsAt: "2027-02-01T08:00:00Z") + let proof = try paymentProofRecord( + endpoint: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning, + billingPeriod: period + ) + for state in [PaymentRequestLifecycleState.canceled, .activeRecurring] { + for hasPayments in [false, true] { + let record = try paymentRequestRecord( + state: state, + role: .payee, + recurrence: PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: period.startsAt, + anchor: period.startsAt, + endsAt: state == .activeRecurring ? period.endsAt : nil + ), + paymentProofs: hasPayments ? [proof] : [] + ) + let manager = paymentRequestManager(sdk: PaymentRequestSdkMock(records: [record]), clock: PaymentRequestTestClock(now)) + await manager.refresh() + let subscription = try XCTUnwrap(manager.subscriptions.first) + XCTAssertFalse(subscription.isCreatedVisible(at: now)) + XCTAssertEqual(subscription.isExpiredVisible(at: now), hasPayments) + XCTAssertEqual(subscription.receivedPaymentRequests().count, hasPayments ? 1 : 0) + XCTAssertFalse(subscription.canCancel(at: now)) + XCTAssertTrue(manager.pendingRequests.isEmpty) + } + } + } + + func testFractionalBillingProofsRemainPaidForCreatorAndPayer() throws { + for fraction in ["123", "123456789", "999999999"] { + let first = BillingPeriod( + startsAt: "2027-01-15T08:00:00.\(fraction)Z", + endsAt: "2027-02-15T08:00:00.\(fraction)Z" + ) + let second = BillingPeriod( + startsAt: first.endsAt, + endsAt: "2027-03-15T08:00:00.\(fraction)Z" + ) + let proofs = try [first, second].map { + try paymentProofRecord( + endpoint: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning, + billingPeriod: $0 + ) + } + for role in [PaymentRequestLocalRole.payee, .payer] { + let record = try paymentRequestRecord( + state: .activeRecurring, + role: role, + recurrence: PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: first.startsAt, + anchor: first.startsAt, + endsAt: nil + ), + paymentProofs: proofs + ) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + XCTAssertEqual(subscription.payments.count, 2) + let requests = if role == .payee { + subscription.receivedPaymentRequests() + } else { + try subscription.requests( + through: XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-16T08:00:00Z")), + acceptedAt: XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:01Z")) + ) + } + XCTAssertEqual(requests.count, 2) + XCTAssertTrue(requests.allSatisfy { $0.lifecycleState == .proofSubmitted }) + } + } + } + + func testCreatorProposalBuildsRecurringTermsAndStaysQueuedUntilDelivery() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let expiresAt = now.addingTimeInterval(60) + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], + receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + ) + try await sdk.setProposalResult(paymentRequestRecord( + id: "creator-proposal", + counterparty: publicKey, + counterpartyReceiverPath: PaykitReceiverPath.wallet, + role: .payee + )) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + + let subscription = try await manager.proposeSubscription( + PaykitSubscriptionDraft( + amountSats: 100_000, + name: " Monthly support ", + description: " Thank you ", + frequency: .month, + expiresAt: expiresAt, + iconData: nil + ), + to: XCTUnwrap(manager.eligibleTargets.first) + ) + + let snapshot = await sdk.snapshot() + let proposed = try XCTUnwrap(snapshot.proposedRequests.first) + XCTAssertEqual(proposed.amount, "0.001") + XCTAssertEqual(proposed.asset, PaykitIssuerInterop.bitcoinAsset) + XCTAssertEqual(proposed.expiresAt, timestamp(expiresAt)) + XCTAssertEqual(proposed.recurrence?.every, 1) + XCTAssertEqual(proposed.recurrence?.unit, "month") + XCTAssertEqual(proposed.recurrence?.startsAt, timestamp(now)) + XCTAssertEqual(proposed.recurrence?.anchor, timestamp(now)) + XCTAssertNil(proposed.recurrence?.endsAt) + let metadataData = try XCTUnwrap(proposed.metadata.data(using: .utf8)) + let metadata = try XCTUnwrap(JSONSerialization.jsonObject(with: metadataData) as? [String: Any]) + XCTAssertEqual(metadata["note"] as? String, "Monthly support") + let subscriptionMetadata = try XCTUnwrap(metadata["subscription"] as? [String: Any]) + XCTAssertEqual(subscriptionMetadata["description"] as? String, "Thank you") + XCTAssertTrue(subscription.isCreatedByUser) + XCTAssertEqual(subscription.deliveryStatus, .queued) + XCTAssertEqual(manager.subscriptions, [subscription]) + } + + func testCreatedSubscriptionSurvivesContactChangesButNotSessionChanges() async throws { + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let otherKey = "pubky\(String(repeating: "a", count: 52))" + let now = Date(timeIntervalSince1970: 1_800_000_000) + + for change in ["reordered", "removed", "added", "cleared", "identity"] { + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], + receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + ) + try await sdk.setProposalResult(paymentRequestRecord(role: .payee)) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey, otherKey]) + let target = try XCTUnwrap(manager.eligibleTargets.first) + await sdk.pauseNextProcess() + let proposal = Task { + try await manager.proposeSubscription( + PaykitSubscriptionDraft( + amountSats: 1000, name: "Support", description: "", frequency: .month, + expiresAt: now.addingTimeInterval(60), iconData: nil + ), + to: target + ) + } + try await waitUntil { await sdk.processIsPaused() } + switch change { + case "reordered": await manager.refreshEligibleTargets(savedPublicKeys: [otherKey, publicKey]) + case "removed": await manager.refreshEligibleTargets(savedPublicKeys: []) + case "added": await manager.refreshEligibleTargets(savedPublicKeys: [publicKey, otherKey, String(repeating: "o", count: 52)]) + case "cleared": manager.clear() + default: manager.activate(identity: otherKey) + } + await sdk.resumeProcess() + let subscription = try await proposal.value + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.proposedRequests.count, 1, change) + XCTAssertEqual(manager.subscriptions, ["cleared", "identity"].contains(change) ? [] : [subscription], change) + XCTAssertFalse(manager.isCreatingRequest, change) + } + } + + func testSubscriptionRevalidatesAfterIconUploadBeforeEnqueue() async throws { + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let now = Date(timeIntervalSince1970: 1_800_000_000) + let image = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 4)).image { context in + UIColor.purple.setFill() + context.fill(CGRect(x: 0, y: 0, width: 4, height: 4)) + } + let iconData = try XCTUnwrap(image.pngData()) + let optionKey = PublicPaykitService.lightningPaymentOptionEnabledKey + let previousOption = UserDefaults.standard.object(forKey: optionKey) + defer { UserDefaults.standard.set(previousOption, forKey: optionKey) } + + for change in ["unchanged", "expired", "removed", "unlinked", "unsupported", "endpoint", "cleared"] { + UserDefaults.standard.set(true, forKey: optionKey) + let clock = PaymentRequestTestClock(now) + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], + receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + ) + try await sdk.setProposalResult(paymentRequestRecord(role: .payee)) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + let target = try XCTUnwrap(manager.eligibleTargets.first) + await sdk.pauseNextUpload() + let proposal = Task { + try await manager.proposeSubscription( + PaykitSubscriptionDraft( + amountSats: 1000, name: "Support", description: "", frequency: .month, + expiresAt: now.addingTimeInterval(60), iconData: iconData + ), + to: target + ) + } + try await waitUntil { await sdk.uploadIsPaused() } + switch change { + case "expired": clock.advance(by: 60) + case "removed": await manager.refreshEligibleTargets(savedPublicKeys: []) + case "unlinked": await sdk.configureRecipients(peers: [], receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]]) + case "unsupported": + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], + receiverPathsByPublicKey: [:] + ) + case "endpoint": UserDefaults.standard.set(false, forKey: optionKey) + case "cleared": manager.clear() + default: break + } + await sdk.resumeUpload() + do { + _ = try await proposal.value + XCTAssertEqual(change, "unchanged") + } catch { + XCTAssertNotEqual(change, "unchanged") + XCTAssertEqual(error as? PaykitPaymentRequestError, change == "expired" ? .requestExpired : .requestUnavailable, change) + } + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.uploadCount, 1, change) + XCTAssertEqual(snapshot.proposedRequests.count, change == "unchanged" ? 1 : 0, change) + XCTAssertEqual(manager.subscriptions.count, change == "unchanged" ? 1 : 0, change) + XCTAssertFalse(manager.isCreatingRequest, change) + } + } + + func testOversizedCreatorProposalIsRejectedBeforeIconUploadOrEnqueue() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], + receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + ) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + let target = try XCTUnwrap(manager.eligibleTargets.first) + + for iconData in [nil, Data([0, 1, 2])] as [Data?] { + do { + _ = try await manager.proposeSubscription( + PaykitSubscriptionDraft( + amountSats: 1000, + name: "Support", + description: String(repeating: "💜", count: 256), + frequency: .month, + expiresAt: now.addingTimeInterval(60), + iconData: iconData + ), + to: target + ) + XCTFail("Oversized proposals must be rejected before external writes") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .subscriptionTooLong) + } + } + + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.uploadCount, 0) + XCTAssertTrue(snapshot.proposedRequests.isEmpty) + XCTAssertTrue(manager.subscriptions.isEmpty) + XCTAssertFalse(manager.isCreatingRequest) + } + + func testCreatorPendingProposalCanBeDeletedButFixedEndProposalCannot() throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let openEnded = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord( + role: .payee, + expiresAt: timestamp(now.addingTimeInterval(60)), + recurrence: PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: timestamp(now), + anchor: timestamp(now), + endsAt: nil + ) + ))) + let fixedEnd = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord( + role: .payee, + expiresAt: timestamp(now.addingTimeInterval(60)), + recurrence: PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: timestamp(now), + anchor: timestamp(now), + endsAt: timestamp(now.addingTimeInterval(3600)) + ) + ))) + + XCTAssertTrue(openEnded.canCancel(at: now)) + XCTAssertFalse(fixedEnd.canCancel(at: now)) + } + func testEndedSubscriptionKeepsItsUnpaidPeriodAvailable() async throws { let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) let recurrence = PaymentRequestRecurrence( @@ -2851,6 +3220,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { private var receiverPathsByPublicKey: [String: [String]] = [:] private var liveSessionAvailable = true private var proposalResult: PaymentRequestRecord? + private var uploadCount = 0 + private var shouldPauseNextUpload = false + private var isUploadPaused = false + private var uploadContinuation: CheckedContinuation? private var processCallCount = 0 private var receiveCallCount = 0 private var processFailuresRemaining = 0 @@ -2936,6 +3309,33 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { receiverPathsByPublicKey[publicKey] ?? [] } + func pauseNextUpload() { + shouldPauseNextUpload = true + } + + func uploadIsPaused() -> Bool { + isUploadPaused + } + + func resumeUpload() { + uploadContinuation?.resume() + uploadContinuation = nil + } + + func uploadProfileAvatar(bytes _: Data, contentType _: String, expectedIdentity: String?) async throws -> String { + if shouldPauseNextUpload { + shouldPauseNextUpload = false + isUploadPaused = true + await withCheckedContinuation { uploadContinuation = $0 } + isUploadPaused = false + } + guard expectedIdentity == nil || PubkyPublicKeyFormat.matches(activeIdentity, expectedIdentity ?? "") else { + throw PaykitPaymentRequestError.requestUnavailable + } + uploadCount += 1 + return "pubky://\(activeIdentity)/pub/paykit/blobs/subscription-icon.jpg" + } + func proposePaymentRequest( counterparty: String, counterpartyReceiverPath: String, @@ -3162,6 +3562,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { func snapshot() -> PaymentRequestSdkSnapshot { PaymentRequestSdkSnapshot( + uploadCount: uploadCount, processCallCount: processCallCount, receiveCallCount: receiveCallCount, acceptedRequests: acceptedRequests, @@ -3187,6 +3588,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { } private struct PaymentRequestSdkSnapshot { + let uploadCount: Int let processCallCount: Int let receiveCallCount: Int let acceptedRequests: [PaymentRequestInvocation] diff --git a/BitkitTests/PaykitSubscriptionProposalTests.swift b/BitkitTests/PaykitSubscriptionProposalTests.swift new file mode 100644 index 000000000..8b2f549b1 --- /dev/null +++ b/BitkitTests/PaykitSubscriptionProposalTests.swift @@ -0,0 +1,64 @@ +@testable import Bitkit +import Paykit +import UIKit +import XCTest + +final class PaykitSubscriptionProposalTests: XCTestCase { + func testTransportLimitIncludesEnvelopeEndpointsAndPublicIcon() throws { + let empty = try terms(description: "", iconURI: PaykitSubscriptionProposal.reservedIconURI) + let available = try PaykitSubscriptionProposal.maximumMessageBytes - (PaykitSubscriptionProposal.encodedSize(empty)) + XCTAssertGreaterThan(available, 0) + let full = try terms(description: String(repeating: "a", count: available), iconURI: PaykitSubscriptionProposal.reservedIconURI) + XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(full), 1000) + XCTAssertNoThrow(try PaykitSubscriptionProposal.validate(full)) + XCTAssertThrowsError(try PaykitSubscriptionProposal.validate(terms( + description: String(repeating: "a", count: available + 1), + iconURI: PaykitSubscriptionProposal.reservedIconURI + ))) { error in + XCTAssertEqual(error as? PaykitPaymentRequestError, .subscriptionTooLong) + } + } + + func testSizeCountsUTF8AndJSONEscapingRatherThanCharacters() throws { + let base = try PaykitSubscriptionProposal.encodedSize(terms(description: "")) + XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(terms(description: "💜")), base + 4) + XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(terms(description: "\"\n\\")), base + 6) + XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(terms(description: "https://example.com")), base + 19) + } + + @MainActor + func testSubscriptionIconIsDownsampledAndInvalidDataIsRejected() throws { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let image = UIGraphicsImageRenderer(size: CGSize(width: 2400, height: 1200), format: format).image { context in + UIColor.purple.setFill() + context.fill(CGRect(x: 0, y: 0, width: 2400, height: 1200)) + } + let input = try XCTUnwrap(image.pngData()) + let compressed = try PaykitPaymentRequestService.compressedSubscriptionIcon(input) + let output = try XCTUnwrap(UIImage(data: compressed)?.cgImage) + XCTAssertEqual(output.width, 400) + XCTAssertEqual(output.height, 200) + XCTAssertThrowsError(try PaykitPaymentRequestService.compressedSubscriptionIcon(Data([0, 1, 2]))) + } + + private func terms(description: String, iconURI: String? = nil) throws -> PaymentRequestTerms { + var subscription: [String: Any] = ["version": 1, "description": description, "benefits": []] + subscription["icon_uri"] = iconURI + let data = try JSONSerialization.data(withJSONObject: ["note": "Support", "subscription": subscription]) + return try PaymentRequestTerms( + amount: PaymentRequestAmount(value: "0.001", asset: "btc"), + paymentReference: PaymentReference(text: "bitkit-00000000-0000-0000-0000-000000000000"), + proposalExpiresAt: "2027-01-22T08:00:00.000Z", + recurrence: PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-15T08:00:00.000Z", + anchor: "2027-01-15T08:00:00.000Z", + endsAt: nil + ), + acceptedPaymentEndpointIdentifiers: ["bitcoin:regtest", "lightning:bolt11", "lightning:lnurl"], + metadata: PrivateJsonObject(text: String(decoding: data, as: UTF8.self)) + ) + } +} diff --git a/BitkitTests/PubkyImageCacheTests.swift b/BitkitTests/PubkyImageCacheTests.swift index 6b3e1d398..d8a61a5ab 100644 --- a/BitkitTests/PubkyImageCacheTests.swift +++ b/BitkitTests/PubkyImageCacheTests.swift @@ -30,10 +30,133 @@ final class PubkyImageCacheTests: XCTestCase { XCTAssertNil(diskImage) } - private func pubkyImageDiskPath(for uri: String) -> URL { - let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + func testDecoderDownsamplesLargeImagesBeforeCaching() throws { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let sourceImage = UIGraphicsImageRenderer(size: CGSize(width: 2400, height: 1200), format: format).image { context in + context.cgContext.setFillColor(UIColor.red.cgColor) + context.cgContext.fill(CGRect(x: 0, y: 0, width: 2400, height: 1200)) + } + let sourceData = try XCTUnwrap(sourceImage.pngData()) + + let decodedImage = try PubkyImageDecoder.image(from: sourceData, maxPixelSize: 512) + let decoded = try XCTUnwrap(decodedImage.cgImage) + + XCTAssertLessThanOrEqual(max(decoded.width, decoded.height), 512) + } + + func testDecoderRejectsDataOverDownloadLimit() { + let data = Data(count: Int(PubkyImagePolicy.maxDownloadBytes) + 1) + + XCTAssertThrowsError(try PubkyImageDecoder.image(from: data)) { error in + XCTAssertEqual((error as? LocalizedError)?.errorDescription, "Image blob exceeds the byte limit (\(data.count) bytes)") + } + } + + func testDiskCacheRemovesLeastRecentlyUsedFilesOverLimit() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let firstURI = "pubky://test-user/pub/bitkit.to/blobs/first.jpg" + let secondURI = "pubky://test-user/pub/bitkit.to/blobs/second.jpg" + let image = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).image { context in + context.cgContext.setFillColor(UIColor.red.cgColor) + context.cgContext.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) + } + let imageData = try XCTUnwrap(image.pngData()) + let cache = PubkyImageCache( + diskDirectory: directory, + maxFileBytes: imageData.count, + memoryCostLimit: 1024, + diskByteLimit: imageData.count + ) + let firstPath = pubkyImageDiskPath(for: firstURI, directory: directory) + let secondPath = pubkyImageDiskPath(for: secondURI, directory: directory) + + cache.store(image, data: imageData, for: firstURI) + let storedFirstFile = await waitForFile(at: firstPath) + XCTAssertTrue(storedFirstFile) + try FileManager.default.setAttributes([.modificationDate: Date.distantPast], ofItemAtPath: firstPath.path) + cache.store(image, data: imageData, for: secondURI) + + let storedSecondFile = await waitForFile(at: secondPath) + let removedFirstFile = await waitForMissingFile(at: firstPath) + XCTAssertTrue(storedSecondFile) + XCTAssertTrue(removedFirstFile) + } + + func testMemoryCacheEnforcesCostLimitAndAccountsForReplacement() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let firstURI = "pubky://test-user/pub/bitkit.to/blobs/first-memory.jpg" + let secondURI = "pubky://test-user/pub/bitkit.to/blobs/second-memory.jpg" + let firstImage = image(color: .red) + let replacementImage = image(color: .blue) + let imageData = try XCTUnwrap(firstImage.pngData()) + let decodedImage = try XCTUnwrap(firstImage.cgImage) + let decodedCost = decodedImage.bytesPerRow * decodedImage.height + let cache = PubkyImageCache( + diskDirectory: directory, + maxFileBytes: imageData.count, + memoryCostLimit: decodedCost, + diskByteLimit: imageData.count * 3 + ) + + cache.store(firstImage, data: imageData, for: firstURI) + cache.store(replacementImage, data: imageData, for: firstURI) + XCTAssertEqual(cache.memoryImage(for: firstURI)?.pngData(), replacementImage.pngData()) + + cache.store(firstImage, data: imageData, for: secondURI) + XCTAssertNil(cache.memoryImage(for: firstURI)) + XCTAssertNotNil(cache.memoryImage(for: secondURI)) + } + + func testDiskCacheTrimsExistingFilesAtInitialization() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let firstURI = "pubky://test-user/pub/bitkit.to/blobs/first-existing.jpg" + let secondURI = "pubky://test-user/pub/bitkit.to/blobs/second-existing.jpg" + let imageData = try XCTUnwrap(image(color: .red).pngData()) + let firstPath = pubkyImageDiskPath(for: firstURI, directory: directory) + let secondPath = pubkyImageDiskPath(for: secondURI, directory: directory) + try imageData.write(to: firstPath) + try imageData.write(to: secondPath) + try FileManager.default.setAttributes([.modificationDate: Date.distantPast], ofItemAtPath: firstPath.path) + + let cache = PubkyImageCache( + diskDirectory: directory, + maxFileBytes: imageData.count, + memoryCostLimit: 1024, + diskByteLimit: imageData.count + ) + + let removedFirstFile = await waitForMissingFile(at: firstPath) + let remainingImage = await cache.image(for: secondURI) + XCTAssertTrue(removedFirstFile) + XCTAssertNotNil(remainingImage) + } + + func testDiskCacheRejectsFilesOverPerFileLimitBeforeDecoding() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let uri = "pubky://test-user/pub/bitkit.to/blobs/oversized.jpg" + let path = pubkyImageDiskPath(for: uri, directory: directory) + let cache = PubkyImageCache(diskDirectory: directory, maxFileBytes: 3, memoryCostLimit: 1024, diskByteLimit: 1024) + try Data([0, 1, 2, 3]).write(to: path) + + let image = await cache.image(for: uri) + XCTAssertNil(image) + XCTAssertFalse(FileManager.default.fileExists(atPath: path.path)) + } + + private func pubkyImageDiskPath(for uri: String, directory: URL? = nil) -> URL { + let caches = directory ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! let hash = SHA256.hash(data: Data(uri.utf8)).compactMap { String(format: "%02x", $0) }.joined() - return caches.appendingPathComponent("pubky-images", isDirectory: true).appendingPathComponent(hash) + let cacheDirectory = directory == nil ? caches.appendingPathComponent("pubky-images", isDirectory: true) : caches + return cacheDirectory.appendingPathComponent(hash) } private func waitForFile(at path: URL, attempts: Int = 10) async -> Bool { @@ -45,4 +168,27 @@ final class PubkyImageCacheTests: XCTestCase { } return false } + + private func waitForMissingFile(at path: URL, attempts: Int = 10) async -> Bool { + for _ in 0 ..< attempts { + if !FileManager.default.fileExists(atPath: path.path) { + return true + } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return false + } + + private func temporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private func image(color: UIColor) -> UIImage { + UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).image { context in + context.cgContext.setFillColor(color.cgColor) + context.cgContext.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) + } + } } diff --git a/changelog.d/next/736.added.md b/changelog.d/next/736.added.md new file mode 100644 index 000000000..6da1146f7 --- /dev/null +++ b/changelog.d/next/736.added.md @@ -0,0 +1 @@ +Bitkit can now propose recurring subscriptions to Paykit contacts.