Add iOS platform service boundaries
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user