diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/PlatformServices.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/PlatformServices.swift new file mode 100644 index 0000000..f3de052 --- /dev/null +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/PlatformServices.swift @@ -0,0 +1,163 @@ +import Foundation +import AVFoundation + +#if canImport(UIKit) +import UIKit +#endif + +#if canImport(AppKit) +import AppKit +#endif + +public protocol ClipboardWriting: Sendable { + func write(text: String) async +} + +public struct SystemClipboardWriter: ClipboardWriting { + public init() {} + + public func write(text: String) async { + #if os(iOS) && canImport(UIKit) + await MainActor.run { + UIPasteboard.general.string = text + } + #elseif os(macOS) && canImport(AppKit) + await MainActor.run { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + #endif + } +} + +public actor InMemoryClipboardWriter: ClipboardWriting { + public private(set) var lastText: String? + + public init() {} + + public func write(text: String) async { + lastText = text + } + + public func currentText() async -> String? { + lastText + } +} + +public struct NotificationPolicy: Sendable { + public init() {} + + public func shouldNotify(chat: ChatSummary, message: Message, mentionOnly: Bool) -> Bool { + guard !message.isOutgoing, !chat.isMuted else { + return false + } + if mentionOnly { + return message.text.contains("@") + } + return true + } +} + +public protocol NotificationScheduling: Sendable { + func schedule(chat: ChatSummary, message: Message) async throws +} + +public actor RecordingNotificationScheduler: NotificationScheduling { + public private(set) var scheduled: [(ChatSummary, Message)] = [] + + public init() {} + + public func schedule(chat: ChatSummary, message: Message) async throws { + scheduled.append((chat, message)) + } +} + +public protocol URLOpening: Sendable { + func open(_ url: URL) async +} + +public struct SystemURLOpener: URLOpening { + public init() {} + + public func open(_ url: URL) async { + #if os(iOS) && canImport(UIKit) + await MainActor.run { + UIApplication.shared.open(url) + } + #elseif os(macOS) && canImport(AppKit) + await MainActor.run { + _ = NSWorkspace.shared.open(url) + } + #endif + } +} + +public struct MediaCache: Sendable { + public var root: URL + + public init(root: URL) { + self.root = root + } + + public func photoPath(fileId: Int32) -> URL { + root.appendingPathComponent("photos", isDirectory: true).appendingPathComponent("\(fileId).jpg") + } + + public func voicePath(fileId: Int32) -> URL { + root.appendingPathComponent("voices", isDirectory: true).appendingPathComponent("\(fileId).ogg") + } +} + +public protocol VoicePlayback: Sendable { + func load(url: URL) async throws + func play() async + func pause() async + func seek(to seconds: TimeInterval) async +} + +public actor RecordingVoicePlayer: VoicePlayback { + public private(set) var loadedURL: URL? + public private(set) var isPlaying = false + public private(set) var position: TimeInterval = 0 + + public init() {} + + public func load(url: URL) async throws { + loadedURL = url + position = 0 + } + + public func currentLoadedURL() async -> URL? { + loadedURL + } + + public func play() async { + isPlaying = true + } + + public func pause() async { + isPlaying = false + } + + public func seek(to seconds: TimeInterval) async { + position = seconds + } +} + +@MainActor +public final class AccountSwitcherViewModel: ObservableObject { + @Published public private(set) var accounts: [Account] + @Published public private(set) var activeAccount: Account + + public init(accounts: [Account], activeAccount: Account) { + self.accounts = accounts + self.activeAccount = activeAccount + } + + public func switchToAccount(id: String) { + guard let next = accounts.first(where: { $0.id == id }) else { + return + } + activeAccount = next + } +} diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Storage.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Storage.swift index 76e89e8..150a6de 100644 --- a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Storage.swift +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Storage.swift @@ -13,6 +13,13 @@ public struct AppStoragePaths: Sendable { .appendingPathComponent(accountId, isDirectory: true) .appendingPathComponent("tdlib", isDirectory: true) } + + public func mediaCachePath(for accountId: String) -> URL { + root + .appendingPathComponent("Accounts", isDirectory: true) + .appendingPathComponent(accountId, isDirectory: true) + .appendingPathComponent("Media", isDirectory: true) + } } public protocol CredentialStore: Sendable { diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift index 03168d9..48344d8 100644 --- a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift @@ -264,8 +264,15 @@ public final class ProfileViewModel: ObservableObject { public final class MediaViewModel: ObservableObject { @Published public private(set) var activePhotoPath: String? @Published public private(set) var activeVoicePath: String? + @Published public private(set) var isVoicePlaying = false - public init() {} + private let cache: MediaCache? + private let voicePlayer: VoicePlayback? + + public init(cache: MediaCache? = nil, voicePlayer: VoicePlayback? = nil) { + self.cache = cache + self.voicePlayer = voicePlayer + } public func showPhoto(path: String) { activePhotoPath = path @@ -274,4 +281,27 @@ public final class MediaViewModel: ObservableObject { public func showVoice(path: String) { activeVoicePath = path } + + public func cachedPhotoPath(fileId: Int32) -> URL? { + cache?.photoPath(fileId: fileId) + } + + public func cachedVoicePath(fileId: Int32) -> URL? { + cache?.voicePath(fileId: fileId) + } + + public func playVoice(url: URL) async { + do { + try await voicePlayer?.load(url: url) + await voicePlayer?.play() + isVoicePlaying = true + } catch { + isVoicePlaying = false + } + } + + public func pauseVoice() async { + await voicePlayer?.pause() + isVoicePlaying = false + } } diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift index 607051b..f8781aa 100644 --- a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift @@ -8,6 +8,7 @@ struct TeleTuiIOSSmokeTests { try await chatListLoadsDeterministicFakeDataAndFilters() try await chatDetailLoadsAndSendsMessage() try await messageActionsCoverEditReplyForwardReactDeleteSearchAndCopy() + try await platformServicesCoverNotificationsMediaVoiceClipboardAndAccounts() try await profileLoadsFromSelectedChat() appStorageUsesApplicationSupportStyleAccountPaths() print("TeleTuiIOS smoke tests passed") @@ -100,6 +101,46 @@ struct TeleTuiIOSSmokeTests { precondition(!viewModel.messages.contains { $0.id == 1 }) } + @MainActor + private static func platformServicesCoverNotificationsMediaVoiceClipboardAndAccounts() async throws { + let root = URL(fileURLWithPath: "/tmp/TeleTuiIOS") + let paths = AppStoragePaths(root: root) + let cache = MediaCache(root: paths.mediaCachePath(for: "work")) + precondition(cache.photoPath(fileId: 10).path == "/tmp/TeleTuiIOS/Accounts/work/Media/photos/10.jpg") + precondition(cache.voicePath(fileId: 20).path == "/tmp/TeleTuiIOS/Accounts/work/Media/voices/20.ogg") + + let policy = NotificationPolicy() + let chat = ChatSummary(id: 1, title: "Chat", lastMessage: "hello", isMuted: false) + let muted = ChatSummary(id: 2, title: "Muted", lastMessage: "hello", isMuted: true) + let incomingMention = Message(id: 1, chatId: 1, senderName: "Alice", text: "@me hello", isOutgoing: false) + let incomingPlain = Message(id: 2, chatId: 1, senderName: "Alice", text: "hello", isOutgoing: false) + precondition(policy.shouldNotify(chat: chat, message: incomingMention, mentionOnly: true)) + precondition(!policy.shouldNotify(chat: chat, message: incomingPlain, mentionOnly: true)) + precondition(!policy.shouldNotify(chat: muted, message: incomingMention, mentionOnly: false)) + + let clipboard = InMemoryClipboardWriter() + await clipboard.write(text: "copied") + let copiedText = await clipboard.currentText() + precondition(copiedText == "copied") + + let player = RecordingVoicePlayer() + let mediaViewModel = MediaViewModel(cache: cache, voicePlayer: player) + let voiceURL = cache.voicePath(fileId: 20) + await mediaViewModel.playVoice(url: voiceURL) + precondition(mediaViewModel.isVoicePlaying) + let loadedURL = await player.currentLoadedURL() + precondition(loadedURL == voiceURL) + await mediaViewModel.pauseVoice() + precondition(!mediaViewModel.isVoicePlaying) + + let personal = Account(id: "personal", displayName: "Personal", databasePath: paths.databasePath(for: "personal")) + let work = Account(id: "work", displayName: "Work", databasePath: paths.databasePath(for: "work")) + let switcher = AccountSwitcherViewModel(accounts: [personal, work], activeAccount: personal) + switcher.switchToAccount(id: "work") + precondition(switcher.activeAccount.id == "work") + precondition(switcher.activeAccount.databasePath.path.hasSuffix("/Accounts/work/tdlib")) + } + @MainActor private static func profileLoadsFromSelectedChat() async throws { let bridge = FakeSessionBridge(auth: .ready)