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(accountId, isDirectory: true)
|
||||||
.appendingPathComponent("tdlib", 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 {
|
public protocol CredentialStore: Sendable {
|
||||||
|
|||||||
@@ -264,8 +264,15 @@ public final class ProfileViewModel: ObservableObject {
|
|||||||
public final class MediaViewModel: ObservableObject {
|
public final class MediaViewModel: ObservableObject {
|
||||||
@Published public private(set) var activePhotoPath: String?
|
@Published public private(set) var activePhotoPath: String?
|
||||||
@Published public private(set) var activeVoicePath: 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) {
|
public func showPhoto(path: String) {
|
||||||
activePhotoPath = path
|
activePhotoPath = path
|
||||||
@@ -274,4 +281,27 @@ public final class MediaViewModel: ObservableObject {
|
|||||||
public func showVoice(path: String) {
|
public func showVoice(path: String) {
|
||||||
activeVoicePath = path
|
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 chatListLoadsDeterministicFakeDataAndFilters()
|
||||||
try await chatDetailLoadsAndSendsMessage()
|
try await chatDetailLoadsAndSendsMessage()
|
||||||
try await messageActionsCoverEditReplyForwardReactDeleteSearchAndCopy()
|
try await messageActionsCoverEditReplyForwardReactDeleteSearchAndCopy()
|
||||||
|
try await platformServicesCoverNotificationsMediaVoiceClipboardAndAccounts()
|
||||||
try await profileLoadsFromSelectedChat()
|
try await profileLoadsFromSelectedChat()
|
||||||
appStorageUsesApplicationSupportStyleAccountPaths()
|
appStorageUsesApplicationSupportStyleAccountPaths()
|
||||||
print("TeleTuiIOS smoke tests passed")
|
print("TeleTuiIOS smoke tests passed")
|
||||||
@@ -100,6 +101,46 @@ struct TeleTuiIOSSmokeTests {
|
|||||||
precondition(!viewModel.messages.contains { $0.id == 1 })
|
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
|
@MainActor
|
||||||
private static func profileLoadsFromSelectedChat() async throws {
|
private static func profileLoadsFromSelectedChat() async throws {
|
||||||
let bridge = FakeSessionBridge(auth: .ready)
|
let bridge = FakeSessionBridge(auth: .ready)
|
||||||
|
|||||||
Reference in New Issue
Block a user