mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 14:25:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41c297b889 | ||
|
|
45fec95af2 | ||
|
|
1a569cfb80 | ||
|
|
3ad9cbe48f | ||
|
|
4f62364bf4 | ||
|
|
fb003ba25e | ||
|
|
52c51c9be6 | ||
|
|
96a580491a | ||
|
|
fec5769fd2 | ||
|
|
12d3e91182 | ||
|
|
cf6d169337 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
dependencies:
|
||||||
|
- mtime: 1757258659000000000
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
size: 14166264
|
||||||
|
- mtime: 1754189697000000000
|
||||||
|
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 2261306
|
||||||
|
sdk_relative: true
|
||||||
|
version: 1
|
||||||
|
...
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
dependencies:
|
||||||
|
- mtime: 1757258662000000000
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
size: 18068
|
||||||
|
- mtime: 1754189697000000000
|
||||||
|
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 2261306
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1754191141000000000
|
||||||
|
path: 'usr/lib/swift/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 1224
|
||||||
|
sdk_relative: true
|
||||||
|
version: 1
|
||||||
|
...
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
dependencies:
|
||||||
|
- mtime: 1757258669000000000
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
size: 699544
|
||||||
|
- mtime: 1754189697000000000
|
||||||
|
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 2261306
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1754192470000000000
|
||||||
|
path: 'usr/lib/swift/_Concurrency.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 364219
|
||||||
|
sdk_relative: true
|
||||||
|
version: 1
|
||||||
|
...
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
dependencies:
|
||||||
|
- mtime: 1757258664000000000
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
size: 83568
|
||||||
|
- mtime: 1754189697000000000
|
||||||
|
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 2261306
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1754192532000000000
|
||||||
|
path: 'usr/lib/swift/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 24507
|
||||||
|
sdk_relative: true
|
||||||
|
version: 1
|
||||||
|
...
|
||||||
@@ -66,9 +66,6 @@ __pycache__/
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
|
||||||
## Cache
|
|
||||||
.cache/
|
|
||||||
|
|
||||||
# Local build results
|
# Local build results
|
||||||
.Result*/
|
.Result*/
|
||||||
.Result*.xcresult/
|
.Result*.xcresult/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.5.0
|
MARKETING_VERSION = 1.4.4
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ let package = Package(
|
|||||||
name: "bitchat",
|
name: "bitchat",
|
||||||
defaultLocalization: "en",
|
defaultLocalization: "en",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v17),
|
||||||
.macOS(.v13)
|
.macOS(.v13)
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
|
|||||||
Generated
-1
@@ -312,7 +312,6 @@
|
|||||||
ne,
|
ne,
|
||||||
"pt-BR",
|
"pt-BR",
|
||||||
ru,
|
ru,
|
||||||
tr,
|
|
||||||
uk,
|
uk,
|
||||||
"zh-Hans",
|
"zh-Hans",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"value" : "dark"
|
"value" : "dark"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "image-1024 1.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"value" : "tinted"
|
"value" : "tinted"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "image-1024 2.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 85 KiB |
@@ -26,15 +26,11 @@ struct BitchatApp: App {
|
|||||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private let idBridge = NostrIdentityBridge()
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let keychain = KeychainManager()
|
let keychain = KeychainManager()
|
||||||
let idBridge = self.idBridge
|
|
||||||
_chatViewModel = StateObject(
|
_chatViewModel = StateObject(
|
||||||
wrappedValue: ChatViewModel(
|
wrappedValue: ChatViewModel(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -54,7 +50,7 @@ struct BitchatApp: App {
|
|||||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||||
DispatchQueue.global(qos: .utility).async {
|
DispatchQueue.global(qos: .utility).async {
|
||||||
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
#if os(iOS)
|
|
||||||
import UIKit
|
|
||||||
#else
|
|
||||||
import AppKit
|
|
||||||
import ImageIO
|
|
||||||
import UniformTypeIdentifiers
|
|
||||||
#endif
|
|
||||||
|
|
||||||
enum ImageUtilsError: Error {
|
|
||||||
case invalidImage
|
|
||||||
case encodingFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ImageUtils {
|
|
||||||
private static let compressionQuality: CGFloat = 0.85
|
|
||||||
private static let targetImageBytes: Int = 60_000
|
|
||||||
|
|
||||||
static func processImage(at url: URL, maxDimension: CGFloat = 512) throws -> URL {
|
|
||||||
// Security H1: Check file size BEFORE reading into memory
|
|
||||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
|
||||||
guard let fileSize = attrs[.size] as? Int else {
|
|
||||||
throw ImageUtilsError.invalidImage
|
|
||||||
}
|
|
||||||
// Allow up to 10MB source images (will be scaled down)
|
|
||||||
guard fileSize <= 10 * 1024 * 1024 else {
|
|
||||||
throw ImageUtilsError.invalidImage
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = try Data(contentsOf: url)
|
|
||||||
#if os(iOS)
|
|
||||||
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
|
||||||
return try processImage(image, maxDimension: maxDimension)
|
|
||||||
#else
|
|
||||||
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
|
||||||
return try processImage(image, maxDimension: maxDimension)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
|
|
||||||
return try autoreleasepool {
|
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
|
||||||
var quality = compressionQuality
|
|
||||||
guard var jpegData = scaled.jpegData(compressionQuality: quality) else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
|
||||||
quality -= 0.1
|
|
||||||
autoreleasepool {
|
|
||||||
if let next = scaled.jpegData(compressionQuality: quality) {
|
|
||||||
jpegData = next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let outputURL = try makeOutputURL()
|
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
|
||||||
return outputURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
|
|
||||||
let size = image.size
|
|
||||||
let maxSide = max(size.width, size.height)
|
|
||||||
guard maxSide > maxDimension else { return image }
|
|
||||||
let scale = maxDimension / maxSide
|
|
||||||
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
|
||||||
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
|
||||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
|
||||||
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
|
||||||
UIGraphicsEndImageContext()
|
|
||||||
return rendered ?? image
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
|
|
||||||
return try autoreleasepool {
|
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
|
||||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
let width = inputCG.width
|
|
||||||
let height = inputCG.height
|
|
||||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
|
||||||
guard let context = CGContext(
|
|
||||||
data: nil,
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
bitsPerComponent: 8,
|
|
||||||
bytesPerRow: 0,
|
|
||||||
space: colorSpace,
|
|
||||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
|
||||||
) else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
|
||||||
guard let cgImage = context.makeImage() else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
var quality = compressionQuality
|
|
||||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
|
||||||
quality -= 0.1
|
|
||||||
autoreleasepool {
|
|
||||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
|
||||||
jpegData = next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let outputURL = try makeOutputURL()
|
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
|
||||||
return outputURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
|
|
||||||
let size = image.size
|
|
||||||
let maxSide = max(size.width, size.height)
|
|
||||||
guard maxSide > maxDimension else { return image }
|
|
||||||
let scale = maxDimension / maxSide
|
|
||||||
let newSize = NSSize(width: size.width * scale, height: size.height * scale)
|
|
||||||
let scaledImage = NSImage(size: newSize)
|
|
||||||
scaledImage.lockFocus()
|
|
||||||
image.draw(in: NSRect(origin: .zero, size: newSize),
|
|
||||||
from: NSRect(origin: .zero, size: size),
|
|
||||||
operation: .copy,
|
|
||||||
fraction: 1.0)
|
|
||||||
scaledImage.unlockFocus()
|
|
||||||
return scaledImage
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
|
||||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Security H2: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
|
||||||
// Don't add any metadata dictionary keys - fresh CGContext ensures clean image
|
|
||||||
let options: [CFString: Any] = [
|
|
||||||
kCGImageDestinationLossyCompressionQuality: quality
|
|
||||||
]
|
|
||||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
|
||||||
guard CGImageDestinationFinalize(destination) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return data as Data
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private static func makeOutputURL() throws -> URL {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
|
||||||
let fileName = "img_\(formatter.string(from: Date())).jpg"
|
|
||||||
|
|
||||||
let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
|
||||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
return directory.appendingPathComponent(fileName)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func applicationFilesDirectory() throws -> URL {
|
|
||||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
||||||
return base.appendingPathComponent("files", isDirectory: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import AVFoundation
|
|
||||||
import BitLogger
|
|
||||||
|
|
||||||
/// Controls playback for a single voice note and coordinates exclusive playback across the app.
|
|
||||||
final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
|
||||||
@Published private(set) var isPlaying: Bool = false
|
|
||||||
@Published private(set) var currentTime: TimeInterval = 0
|
|
||||||
@Published private(set) var duration: TimeInterval = 0
|
|
||||||
@Published private(set) var progress: Double = 0
|
|
||||||
|
|
||||||
private var player: AVAudioPlayer?
|
|
||||||
private var timer: Timer?
|
|
||||||
private var url: URL
|
|
||||||
|
|
||||||
init(url: URL) {
|
|
||||||
self.url = url
|
|
||||||
super.init()
|
|
||||||
// Don't load anything eagerly - wait until user interaction or view is fully displayed
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadDuration() {
|
|
||||||
guard duration == 0 else { return }
|
|
||||||
|
|
||||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
do {
|
|
||||||
let player = try AVAudioPlayer(contentsOf: self.url)
|
|
||||||
let loadedDuration = player.duration
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self, self.duration == 0 else { return }
|
|
||||||
self.duration = loadedDuration
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Failed to load audio duration: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
timer?.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
func replaceURL(_ url: URL) {
|
|
||||||
guard url != self.url else { return }
|
|
||||||
stop()
|
|
||||||
self.url = url
|
|
||||||
player = nil
|
|
||||||
duration = 0
|
|
||||||
// Duration will be loaded on demand when needed
|
|
||||||
}
|
|
||||||
|
|
||||||
func togglePlayback() {
|
|
||||||
isPlaying ? pause() : play()
|
|
||||||
}
|
|
||||||
|
|
||||||
func play() {
|
|
||||||
guard ensurePlayerReady() else { return }
|
|
||||||
VoiceNotePlaybackCoordinator.shared.activate(self)
|
|
||||||
player?.play()
|
|
||||||
startTimer()
|
|
||||||
updateProgress()
|
|
||||||
isPlaying = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func pause() {
|
|
||||||
player?.pause()
|
|
||||||
stopTimer()
|
|
||||||
updateProgress()
|
|
||||||
isPlaying = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
player?.stop()
|
|
||||||
player?.currentTime = 0
|
|
||||||
stopTimer()
|
|
||||||
updateProgress()
|
|
||||||
isPlaying = false
|
|
||||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
func seek(to fraction: Double) {
|
|
||||||
guard ensurePlayerReady() else { return }
|
|
||||||
let clamped = max(0, min(1, fraction))
|
|
||||||
if let player = player {
|
|
||||||
player.currentTime = clamped * player.duration
|
|
||||||
if isPlaying {
|
|
||||||
player.play()
|
|
||||||
}
|
|
||||||
updateProgress()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - AVAudioPlayerDelegate
|
|
||||||
|
|
||||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
|
||||||
// Delegate callback may be on background thread - ensure main thread for UI updates
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.stopTimer()
|
|
||||||
self.updateProgress()
|
|
||||||
self.isPlaying = false
|
|
||||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
|
||||||
|
|
||||||
private func preparePlayer(for url: URL) {
|
|
||||||
// Prepare player synchronously (only called when playback is requested)
|
|
||||||
do {
|
|
||||||
let player = try AVAudioPlayer(contentsOf: url)
|
|
||||||
player.delegate = self
|
|
||||||
player.prepareToPlay()
|
|
||||||
self.player = player
|
|
||||||
duration = player.duration
|
|
||||||
currentTime = player.currentTime
|
|
||||||
progress = duration > 0 ? currentTime / duration : 0
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Voice note playback failed for \(url.lastPathComponent): \(error)", category: .session)
|
|
||||||
player = nil
|
|
||||||
duration = 0
|
|
||||||
currentTime = 0
|
|
||||||
progress = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func ensurePlayerReady() -> Bool {
|
|
||||||
if player == nil {
|
|
||||||
preparePlayer(for: url)
|
|
||||||
}
|
|
||||||
#if os(iOS)
|
|
||||||
let session = AVAudioSession.sharedInstance()
|
|
||||||
do {
|
|
||||||
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
|
||||||
try session.setActive(true, options: [])
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
return player != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startTimer() {
|
|
||||||
if timer != nil { return }
|
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
|
||||||
self?.updateProgress()
|
|
||||||
}
|
|
||||||
if let timer = timer {
|
|
||||||
RunLoop.main.add(timer, forMode: .common)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopTimer() {
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateProgress() {
|
|
||||||
guard let player = player else {
|
|
||||||
currentTime = 0
|
|
||||||
duration = 0
|
|
||||||
progress = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
currentTime = player.currentTime
|
|
||||||
duration = player.duration
|
|
||||||
progress = duration > 0 ? currentTime / duration : 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ensures only one voice note plays at a time.
|
|
||||||
final class VoiceNotePlaybackCoordinator {
|
|
||||||
static let shared = VoiceNotePlaybackCoordinator()
|
|
||||||
|
|
||||||
private weak var activeController: VoiceNotePlaybackController?
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
func activate(_ controller: VoiceNotePlaybackController) {
|
|
||||||
if activeController === controller {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
activeController?.pause()
|
|
||||||
activeController = controller
|
|
||||||
}
|
|
||||||
|
|
||||||
func deactivate(_ controller: VoiceNotePlaybackController) {
|
|
||||||
if activeController === controller {
|
|
||||||
activeController = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import AVFoundation
|
|
||||||
|
|
||||||
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
|
||||||
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
|
|
||||||
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|
||||||
enum RecorderError: Error {
|
|
||||||
case microphoneAccessDenied
|
|
||||||
case recorderInitializationFailed
|
|
||||||
case recordingInProgress
|
|
||||||
}
|
|
||||||
|
|
||||||
static let shared = VoiceRecorder()
|
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
|
||||||
private let paddingInterval: TimeInterval = 0.5
|
|
||||||
|
|
||||||
private var recorder: AVAudioRecorder?
|
|
||||||
private var currentURL: URL?
|
|
||||||
private var stopWorkItem: DispatchWorkItem?
|
|
||||||
|
|
||||||
private override init() {
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Permissions
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func requestPermission() async -> Bool {
|
|
||||||
#if os(iOS)
|
|
||||||
return await withCheckedContinuation { continuation in
|
|
||||||
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
|
||||||
continuation.resume(returning: granted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#elseif os(macOS)
|
|
||||||
return await withCheckedContinuation { continuation in
|
|
||||||
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
|
||||||
continuation.resume(returning: granted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
return true
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Recording Lifecycle
|
|
||||||
|
|
||||||
func startRecording() throws -> URL {
|
|
||||||
try queue.sync {
|
|
||||||
if recorder?.isRecording == true {
|
|
||||||
throw RecorderError.recordingInProgress
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
let session = AVAudioSession.sharedInstance()
|
|
||||||
guard session.recordPermission == .granted else {
|
|
||||||
throw RecorderError.microphoneAccessDenied
|
|
||||||
}
|
|
||||||
try session.setCategory(
|
|
||||||
.playAndRecord,
|
|
||||||
mode: .default,
|
|
||||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
|
||||||
)
|
|
||||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
||||||
#endif
|
|
||||||
#if os(macOS)
|
|
||||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
|
||||||
throw RecorderError.microphoneAccessDenied
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
let outputURL = try makeOutputURL()
|
|
||||||
let settings: [String: Any] = [
|
|
||||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
||||||
AVSampleRateKey: 16_000,
|
|
||||||
AVNumberOfChannelsKey: 1,
|
|
||||||
AVEncoderBitRateKey: 20_000
|
|
||||||
]
|
|
||||||
|
|
||||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
|
||||||
audioRecorder.delegate = self
|
|
||||||
audioRecorder.isMeteringEnabled = true
|
|
||||||
audioRecorder.prepareToRecord()
|
|
||||||
audioRecorder.record()
|
|
||||||
|
|
||||||
recorder = audioRecorder
|
|
||||||
currentURL = outputURL
|
|
||||||
stopWorkItem?.cancel()
|
|
||||||
stopWorkItem = nil
|
|
||||||
return outputURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopRecording(completion: @escaping (URL?) -> Void) {
|
|
||||||
queue.async { [weak self] in
|
|
||||||
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
|
|
||||||
completion(self?.currentURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let item = DispatchWorkItem { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
recorder.stop()
|
|
||||||
self.cleanupSession()
|
|
||||||
let url = self.currentURL
|
|
||||||
self.recorder = nil
|
|
||||||
self.currentURL = url
|
|
||||||
completion(url)
|
|
||||||
}
|
|
||||||
self.stopWorkItem = item
|
|
||||||
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancelRecording() {
|
|
||||||
queue.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.stopWorkItem?.cancel()
|
|
||||||
self.stopWorkItem = nil
|
|
||||||
if let recorder = self.recorder, recorder.isRecording {
|
|
||||||
recorder.stop()
|
|
||||||
}
|
|
||||||
self.cleanupSession()
|
|
||||||
if let url = self.currentURL {
|
|
||||||
try? FileManager.default.removeItem(at: url)
|
|
||||||
}
|
|
||||||
self.recorder = nil
|
|
||||||
self.currentURL = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Metering
|
|
||||||
|
|
||||||
func currentAveragePower() -> Float {
|
|
||||||
queue.sync {
|
|
||||||
recorder?.updateMeters()
|
|
||||||
return recorder?.averagePower(forChannel: 0) ?? -160
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
|
|
||||||
private func makeOutputURL() throws -> URL {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
|
||||||
let fileName = "voice_\(formatter.string(from: Date())).m4a"
|
|
||||||
|
|
||||||
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
|
||||||
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
return baseDirectory.appendingPathComponent(fileName)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applicationFilesDirectory() throws -> URL {
|
|
||||||
#if os(iOS)
|
|
||||||
return try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
||||||
.appendingPathComponent("files", isDirectory: true)
|
|
||||||
#else
|
|
||||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
||||||
return base.appendingPathComponent("files", isDirectory: true)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cleanupSession() {
|
|
||||||
#if os(iOS)
|
|
||||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import AVFoundation
|
|
||||||
import Foundation
|
|
||||||
import BitLogger
|
|
||||||
|
|
||||||
/// Generates and caches downsampled waveforms for audio files so UI rendering is cheap.
|
|
||||||
final class WaveformCache {
|
|
||||||
static let shared = WaveformCache()
|
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent)
|
|
||||||
private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:]
|
|
||||||
private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
func cachedWaveform(for url: URL) -> [Float]? {
|
|
||||||
queue.sync {
|
|
||||||
guard let entry = cache[url] else { return nil }
|
|
||||||
return entry.waveform
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) {
|
|
||||||
queue.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
|
|
||||||
// Check cache (read-only, no update needed on cache hit for performance)
|
|
||||||
if let entry = self.cache[url] {
|
|
||||||
DispatchQueue.main.async { completion(entry.waveform) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let computed = self.computeWaveform(url: url, bins: bins) else {
|
|
||||||
DispatchQueue.main.async { completion([]) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.queue.async(flags: .barrier) { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
|
|
||||||
// Evict oldest entry if cache is full
|
|
||||||
if self.cache.count >= self.maxCacheSize {
|
|
||||||
if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) {
|
|
||||||
self.cache.removeValue(forKey: oldest.key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.cache[url] = (computed, Date())
|
|
||||||
}
|
|
||||||
DispatchQueue.main.async { completion(computed) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func purge(url: URL) {
|
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
|
||||||
self?.cache.removeValue(forKey: url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func purgeAll() {
|
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
|
||||||
self?.cache.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
|
||||||
guard bins > 0 else { return nil }
|
|
||||||
// Use autoreleasepool to manage memory from audio buffer allocations
|
|
||||||
return autoreleasepool {
|
|
||||||
do {
|
|
||||||
let audioFile = try AVAudioFile(forReading: url)
|
|
||||||
let length = Int(audioFile.length)
|
|
||||||
guard length > 0 else { return nil }
|
|
||||||
|
|
||||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length))
|
|
||||||
guard let channelData = buffer.floatChannelData else { return nil }
|
|
||||||
|
|
||||||
let channelCount = Int(audioFile.processingFormat.channelCount)
|
|
||||||
let frameLength = Int(buffer.frameLength)
|
|
||||||
let samplesPerBin = max(1, frameLength / bins)
|
|
||||||
|
|
||||||
var magnitudes: [Float] = Array(repeating: 0, count: bins)
|
|
||||||
for bin in 0..<bins {
|
|
||||||
let start = bin * samplesPerBin
|
|
||||||
let end = min(frameLength, start + samplesPerBin)
|
|
||||||
if start >= end { break }
|
|
||||||
|
|
||||||
var sum: Float = 0
|
|
||||||
var sampleCount = 0
|
|
||||||
for frame in start..<end {
|
|
||||||
var sampleValue: Float = 0
|
|
||||||
for channel in 0..<channelCount {
|
|
||||||
sampleValue += fabsf(channelData[channel][frame])
|
|
||||||
}
|
|
||||||
sum += sampleValue / Float(channelCount)
|
|
||||||
sampleCount += 1
|
|
||||||
}
|
|
||||||
magnitudes[bin] = sampleCount > 0 ? sum / Float(sampleCount) : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 {
|
|
||||||
magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) }
|
|
||||||
}
|
|
||||||
return magnitudes
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,10 +37,6 @@
|
|||||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
|
||||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
|
||||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
|
|||||||
+14668
-15600
File diff suppressed because it is too large
Load Diff
@@ -335,6 +335,20 @@ extension BitchatMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - System Message Factory
|
||||||
|
|
||||||
|
extension BitchatMessage {
|
||||||
|
/// Creates a system message with default values
|
||||||
|
static func system(_ content: String, timestamp: Date = Date()) -> BitchatMessage {
|
||||||
|
return BitchatMessage(
|
||||||
|
sender: "system",
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isRelay: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension Array where Element == BitchatMessage {
|
extension Array where Element == BitchatMessage {
|
||||||
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
||||||
func cleanedAndDeduped() -> [Element] {
|
func cleanedAndDeduped() -> [Element] {
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ struct BitchatPacket: Codable {
|
|||||||
var signature: Data?
|
var signature: Data?
|
||||||
var ttl: UInt8
|
var ttl: UInt8
|
||||||
|
|
||||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1) {
|
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
|
||||||
self.version = version
|
self.version = 1
|
||||||
self.type = type
|
self.type = type
|
||||||
self.senderID = senderID
|
self.senderID = senderID
|
||||||
self.recipientID = recipientID
|
self.recipientID = recipientID
|
||||||
@@ -80,8 +80,7 @@ struct BitchatPacket: Codable {
|
|||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil, // Remove signature for signing
|
signature: nil, // Remove signature for signing
|
||||||
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||||
version: version
|
|
||||||
)
|
)
|
||||||
return BinaryProtocol.encode(unsignedPacket)
|
return BinaryProtocol.encode(unsignedPacket)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//
|
||||||
|
// GeoPerson.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Model representing a participant in a geohash channel
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Represents a person participating in a geohash-based location channel
|
||||||
|
struct GeoPerson: Identifiable, Equatable {
|
||||||
|
let id: String // pubkey hex (lowercased)
|
||||||
|
let displayName: String
|
||||||
|
let lastSeen: Date
|
||||||
|
}
|
||||||
@@ -161,14 +161,9 @@ extension PeerID {
|
|||||||
id.rangeOfCharacter(from: validCharset.inverted) == nil
|
id.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the `bare` id is all hex
|
|
||||||
var isHex: Bool {
|
|
||||||
bare.allSatisfy { $0.isHexDigit }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Short routing IDs (exact 16-hex)
|
/// Short routing IDs (exact 16-hex)
|
||||||
var isShort: Bool {
|
var isShort: Bool {
|
||||||
bare.count == Constants.hexIDLength && isHex
|
bare.count == Constants.hexIDLength && Data(hexString: bare) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full Noise key hex (exact 64-hex)
|
/// Full Noise key hex (exact 64-hex)
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Bech32 encoding for Nostr (minimal implementation)
|
|
||||||
enum Bech32 {
|
|
||||||
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
||||||
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
|
||||||
|
|
||||||
static func encode(hrp: String, data: Data) throws -> String {
|
|
||||||
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
|
||||||
let checksum = createChecksum(hrp: hrp, values: values)
|
|
||||||
let combined = values + checksum
|
|
||||||
|
|
||||||
return hrp + "1" + combined.map {
|
|
||||||
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
|
||||||
return String(charset[index])
|
|
||||||
}.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
|
||||||
// Find the last occurrence of '1'
|
|
||||||
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
|
||||||
throw Bech32Error.invalidFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
let hrp = String(bech32String[..<separatorIndex])
|
|
||||||
|
|
||||||
// Validate HRP contains only ASCII characters
|
|
||||||
for char in hrp {
|
|
||||||
guard char.asciiValue != nil else {
|
|
||||||
throw Bech32Error.invalidCharacter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
|
||||||
|
|
||||||
// Convert characters to values
|
|
||||||
var values = [UInt8]()
|
|
||||||
for char in dataString {
|
|
||||||
guard let index = charset.firstIndex(of: char) else {
|
|
||||||
throw Bech32Error.invalidCharacter
|
|
||||||
}
|
|
||||||
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify checksum
|
|
||||||
guard values.count >= 6 else {
|
|
||||||
throw Bech32Error.invalidChecksum
|
|
||||||
}
|
|
||||||
|
|
||||||
let payloadValues = Array(values.dropLast(6))
|
|
||||||
let checksum = Array(values.suffix(6))
|
|
||||||
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
|
||||||
|
|
||||||
guard checksum == expectedChecksum else {
|
|
||||||
throw Bech32Error.invalidChecksum
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert back to bytes
|
|
||||||
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
|
||||||
return (hrp: hrp, data: Data(bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Bech32Error: Error {
|
|
||||||
case invalidFormat
|
|
||||||
case invalidCharacter
|
|
||||||
case invalidChecksum
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
|
||||||
var acc = 0
|
|
||||||
var bits = 0
|
|
||||||
var result = [UInt8]()
|
|
||||||
let maxv = (1 << to) - 1
|
|
||||||
|
|
||||||
for value in data {
|
|
||||||
acc = (acc << from) | Int(value)
|
|
||||||
bits += from
|
|
||||||
|
|
||||||
while bits >= to {
|
|
||||||
bits -= to
|
|
||||||
result.append(UInt8((acc >> bits) & maxv))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if pad && bits > 0 {
|
|
||||||
result.append(UInt8((acc << (to - bits)) & maxv))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
|
||||||
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
|
||||||
let polymod = polymod(checksumValues) ^ 1
|
|
||||||
var checksum = [UInt8]()
|
|
||||||
|
|
||||||
for i in 0..<6 {
|
|
||||||
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
|
||||||
}
|
|
||||||
|
|
||||||
return checksum
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
|
||||||
var result = [UInt8]()
|
|
||||||
for c in hrp {
|
|
||||||
guard let asciiValue = c.asciiValue else {
|
|
||||||
return [] // Return empty array for invalid input
|
|
||||||
}
|
|
||||||
result.append(UInt8(asciiValue >> 5))
|
|
||||||
}
|
|
||||||
result.append(0)
|
|
||||||
for c in hrp {
|
|
||||||
guard let asciiValue = c.asciiValue else {
|
|
||||||
return [] // Return empty array for invalid input
|
|
||||||
}
|
|
||||||
result.append(UInt8(asciiValue & 31))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func polymod(_ values: [UInt8]) -> Int {
|
|
||||||
var chk = 1
|
|
||||||
for value in values {
|
|
||||||
let b = chk >> 25
|
|
||||||
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
|
||||||
for i in 0..<5 {
|
|
||||||
if (b >> i) & 1 == 1 {
|
|
||||||
chk ^= generator[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return chk
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
protocol KeychainHelperProtocol {
|
|
||||||
func save(key: String, data: Data, service: String, accessible: CFString?)
|
|
||||||
func load(key: String, service: String) -> Data?
|
|
||||||
func delete(key: String, service: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Keychain helper for secure storage
|
|
||||||
struct KeychainHelper: KeychainHelperProtocol {
|
|
||||||
func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
|
||||||
var query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecValueData as String: data
|
|
||||||
]
|
|
||||||
if let accessible = accessible {
|
|
||||||
query[kSecAttrAccessible as String] = accessible
|
|
||||||
}
|
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
|
||||||
SecItemAdd(query as CFDictionary, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func load(key: String, service: String) -> Data? {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecReturnData as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
|
|
||||||
guard status == errSecSuccess else { return nil }
|
|
||||||
return result as? Data
|
|
||||||
}
|
|
||||||
|
|
||||||
func delete(key: String, service: String) {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: key
|
|
||||||
]
|
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,50 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
import P256K
|
import P256K
|
||||||
|
import Security
|
||||||
|
|
||||||
|
// Keychain helper for secure storage
|
||||||
|
struct KeychainHelper {
|
||||||
|
static func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
||||||
|
var query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecValueData as String: data
|
||||||
|
]
|
||||||
|
if let accessible = accessible {
|
||||||
|
query[kSecAttrAccessible as String] = accessible
|
||||||
|
}
|
||||||
|
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
SecItemAdd(query as CFDictionary, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load(key: String, service: String) -> Data? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecReturnData as String: true
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
|
||||||
|
guard status == errSecSuccess else { return nil }
|
||||||
|
return result as? Data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func delete(key: String, service: String) {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key
|
||||||
|
]
|
||||||
|
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||||
struct NostrIdentity: Codable {
|
struct NostrIdentity: Codable {
|
||||||
@@ -58,3 +103,266 @@ struct NostrIdentity: Codable {
|
|||||||
return publicKey.hexEncodedString()
|
return publicKey.hexEncodedString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bridge between Noise and Nostr identities
|
||||||
|
struct NostrIdentityBridge {
|
||||||
|
private static let keychainService = "chat.bitchat.nostr"
|
||||||
|
private static let currentIdentityKey = "nostr-current-identity"
|
||||||
|
private static let deviceSeedKey = "nostr-device-seed"
|
||||||
|
// In-memory cache to avoid transient keychain access issues
|
||||||
|
private static var deviceSeedCache: Data?
|
||||||
|
|
||||||
|
/// Get or create the current Nostr identity
|
||||||
|
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||||
|
// Check if we already have a Nostr identity
|
||||||
|
if let existingData = KeychainHelper.load(key: currentIdentityKey, service: keychainService),
|
||||||
|
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new Nostr identity
|
||||||
|
let nostrIdentity = try NostrIdentity.generate()
|
||||||
|
|
||||||
|
// Store it
|
||||||
|
let data = try JSONEncoder().encode(nostrIdentity)
|
||||||
|
KeychainHelper.save(key: currentIdentityKey, data: data, service: keychainService)
|
||||||
|
|
||||||
|
return nostrIdentity
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||||
|
static func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||||
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||||
|
if let data = nostrPubkey.data(using: .utf8) {
|
||||||
|
KeychainHelper.save(key: key, data: data, service: keychainService)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Nostr public key associated with a Noise public key
|
||||||
|
static func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||||
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||||
|
guard let data = KeychainHelper.load(key: key, service: keychainService),
|
||||||
|
let pubkey = String(data: data, encoding: .utf8) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all Nostr identity associations and current identity
|
||||||
|
static func clearAllAssociations() {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: keychainService,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||||
|
kSecReturnAttributes as String: true
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||||
|
for item in items {
|
||||||
|
var deleteQuery: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: keychainService
|
||||||
|
]
|
||||||
|
if let account = item[kSecAttrAccount as String] as? String {
|
||||||
|
deleteQuery[kSecAttrAccount as String] = account
|
||||||
|
}
|
||||||
|
SecItemDelete(deleteQuery as CFDictionary)
|
||||||
|
}
|
||||||
|
} else if status == errSecItemNotFound {
|
||||||
|
// nothing persisted; no action needed
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceSeedCache = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Per-Geohash Identities (Location Channels)
|
||||||
|
|
||||||
|
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||||
|
/// Stored only on device keychain.
|
||||||
|
private static func getOrCreateDeviceSeed() -> Data {
|
||||||
|
if let cached = deviceSeedCache { return cached }
|
||||||
|
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
|
||||||
|
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
||||||
|
KeychainHelper.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||||
|
deviceSeedCache = existing
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
var seed = Data(count: 32)
|
||||||
|
_ = seed.withUnsafeMutableBytes { ptr in
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||||
|
}
|
||||||
|
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||||
|
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||||
|
deviceSeedCache = seed
|
||||||
|
return seed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||||
|
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||||
|
/// if the candidate is not a valid secp256k1 private key.
|
||||||
|
static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||||
|
let seed = getOrCreateDeviceSeed()
|
||||||
|
guard let msg = geohash.data(using: .utf8) else {
|
||||||
|
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateKey(iteration: UInt32) -> Data {
|
||||||
|
var input = Data(msg)
|
||||||
|
var iterBE = iteration.bigEndian
|
||||||
|
withUnsafeBytes(of: &iterBE) { bytes in
|
||||||
|
input.append(contentsOf: bytes)
|
||||||
|
}
|
||||||
|
let code = CryptoKit.HMAC<CryptoKit.SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
||||||
|
return Data(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try a few iterations to ensure a valid key can be formed
|
||||||
|
for i in 0..<10 {
|
||||||
|
let keyData = candidateKey(iteration: UInt32(i))
|
||||||
|
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// As a final fallback, hash the seed+msg and try again
|
||||||
|
let fallback = (seed + msg).sha256Hash()
|
||||||
|
return try NostrIdentity(privateKeyData: fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bech32 encoding for Nostr (minimal implementation)
|
||||||
|
enum Bech32 {
|
||||||
|
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||||
|
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||||
|
|
||||||
|
static func encode(hrp: String, data: Data) throws -> String {
|
||||||
|
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
||||||
|
let checksum = createChecksum(hrp: hrp, values: values)
|
||||||
|
let combined = values + checksum
|
||||||
|
|
||||||
|
return hrp + "1" + combined.map {
|
||||||
|
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
||||||
|
return String(charset[index])
|
||||||
|
}.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
||||||
|
// Find the last occurrence of '1'
|
||||||
|
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
||||||
|
throw Bech32Error.invalidFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
let hrp = String(bech32String[..<separatorIndex])
|
||||||
|
|
||||||
|
// Validate HRP contains only ASCII characters
|
||||||
|
for char in hrp {
|
||||||
|
guard char.asciiValue != nil else {
|
||||||
|
throw Bech32Error.invalidCharacter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
||||||
|
|
||||||
|
// Convert characters to values
|
||||||
|
var values = [UInt8]()
|
||||||
|
for char in dataString {
|
||||||
|
guard let index = charset.firstIndex(of: char) else {
|
||||||
|
throw Bech32Error.invalidCharacter
|
||||||
|
}
|
||||||
|
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checksum
|
||||||
|
guard values.count >= 6 else {
|
||||||
|
throw Bech32Error.invalidChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
let payloadValues = Array(values.dropLast(6))
|
||||||
|
let checksum = Array(values.suffix(6))
|
||||||
|
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
||||||
|
|
||||||
|
guard checksum == expectedChecksum else {
|
||||||
|
throw Bech32Error.invalidChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert back to bytes
|
||||||
|
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
||||||
|
return (hrp: hrp, data: Data(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Bech32Error: Error {
|
||||||
|
case invalidFormat
|
||||||
|
case invalidCharacter
|
||||||
|
case invalidChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
||||||
|
var acc = 0
|
||||||
|
var bits = 0
|
||||||
|
var result = [UInt8]()
|
||||||
|
let maxv = (1 << to) - 1
|
||||||
|
|
||||||
|
for value in data {
|
||||||
|
acc = (acc << from) | Int(value)
|
||||||
|
bits += from
|
||||||
|
|
||||||
|
while bits >= to {
|
||||||
|
bits -= to
|
||||||
|
result.append(UInt8((acc >> bits) & maxv))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pad && bits > 0 {
|
||||||
|
result.append(UInt8((acc << (to - bits)) & maxv))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
||||||
|
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
||||||
|
let polymod = polymod(checksumValues) ^ 1
|
||||||
|
var checksum = [UInt8]()
|
||||||
|
|
||||||
|
for i in 0..<6 {
|
||||||
|
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
||||||
|
}
|
||||||
|
|
||||||
|
return checksum
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
||||||
|
var result = [UInt8]()
|
||||||
|
for c in hrp {
|
||||||
|
guard let asciiValue = c.asciiValue else {
|
||||||
|
return [] // Return empty array for invalid input
|
||||||
|
}
|
||||||
|
result.append(UInt8(asciiValue >> 5))
|
||||||
|
}
|
||||||
|
result.append(0)
|
||||||
|
for c in hrp {
|
||||||
|
guard let asciiValue = c.asciiValue else {
|
||||||
|
return [] // Return empty array for invalid input
|
||||||
|
}
|
||||||
|
result.append(UInt8(asciiValue & 31))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func polymod(_ values: [UInt8]) -> Int {
|
||||||
|
var chk = 1
|
||||||
|
for value in values {
|
||||||
|
let b = chk >> 25
|
||||||
|
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
||||||
|
for i in 0..<5 {
|
||||||
|
if (b >> i) & 1 == 1 {
|
||||||
|
chk ^= generator[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data hex encoding extension moved to BinaryEncodingUtils.swift to avoid duplication
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
/// Bridge between Noise and Nostr identities
|
|
||||||
final class NostrIdentityBridge {
|
|
||||||
private let keychainService = "chat.bitchat.nostr"
|
|
||||||
private let currentIdentityKey = "nostr-current-identity"
|
|
||||||
private let deviceSeedKey = "nostr-device-seed"
|
|
||||||
// In-memory cache to avoid transient keychain access issues
|
|
||||||
private var deviceSeedCache: Data?
|
|
||||||
// Cache derived identities to avoid repeated crypto during view rendering
|
|
||||||
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
|
||||||
private let cacheLock = NSLock()
|
|
||||||
|
|
||||||
private let keychain: KeychainHelperProtocol
|
|
||||||
|
|
||||||
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
|
||||||
self.keychain = keychain
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get or create the current Nostr identity
|
|
||||||
func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
|
||||||
// Check if we already have a Nostr identity
|
|
||||||
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
|
|
||||||
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
|
||||||
return identity
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new Nostr identity
|
|
||||||
let nostrIdentity = try NostrIdentity.generate()
|
|
||||||
|
|
||||||
// Store it
|
|
||||||
let data = try JSONEncoder().encode(nostrIdentity)
|
|
||||||
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
|
|
||||||
|
|
||||||
return nostrIdentity
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
|
||||||
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
||||||
if let data = nostrPubkey.data(using: .utf8) {
|
|
||||||
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get Nostr public key associated with a Noise public key
|
|
||||||
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
||||||
guard let data = keychain.load(key: key, service: keychainService),
|
|
||||||
let pubkey = String(data: data, encoding: .utf8) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return pubkey
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all Nostr identity associations and current identity
|
|
||||||
func clearAllAssociations() {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: keychainService,
|
|
||||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
|
||||||
kSecReturnAttributes as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
|
||||||
for item in items {
|
|
||||||
var deleteQuery: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: keychainService
|
|
||||||
]
|
|
||||||
if let account = item[kSecAttrAccount as String] as? String {
|
|
||||||
deleteQuery[kSecAttrAccount as String] = account
|
|
||||||
}
|
|
||||||
SecItemDelete(deleteQuery as CFDictionary)
|
|
||||||
}
|
|
||||||
} else if status == errSecItemNotFound {
|
|
||||||
// nothing persisted; no action needed
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceSeedCache = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Per-Geohash Identities (Location Channels)
|
|
||||||
|
|
||||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
|
||||||
/// Stored only on device keychain.
|
|
||||||
private func getOrCreateDeviceSeed() -> Data {
|
|
||||||
if let cached = deviceSeedCache { return cached }
|
|
||||||
if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {
|
|
||||||
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
|
||||||
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
|
||||||
deviceSeedCache = existing
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
var seed = Data(count: 32)
|
|
||||||
_ = seed.withUnsafeMutableBytes { ptr in
|
|
||||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
|
||||||
}
|
|
||||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
|
||||||
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
|
||||||
deviceSeedCache = seed
|
|
||||||
return seed
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
|
||||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
|
||||||
/// if the candidate is not a valid secp256k1 private key.
|
|
||||||
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
|
||||||
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
|
|
||||||
cacheLock.lock()
|
|
||||||
if let cached = derivedIdentityCache[geohash] {
|
|
||||||
cacheLock.unlock()
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
cacheLock.unlock()
|
|
||||||
|
|
||||||
let seed = getOrCreateDeviceSeed()
|
|
||||||
guard let msg = geohash.data(using: .utf8) else {
|
|
||||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func candidateKey(iteration: UInt32) -> Data {
|
|
||||||
var input = Data(msg)
|
|
||||||
var iterBE = iteration.bigEndian
|
|
||||||
withUnsafeBytes(of: &iterBE) { bytes in
|
|
||||||
input.append(contentsOf: bytes)
|
|
||||||
}
|
|
||||||
let code = HMAC<SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
|
||||||
return Data(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try a few iterations to ensure a valid key can be formed
|
|
||||||
for i in 0..<10 {
|
|
||||||
let keyData = candidateKey(iteration: UInt32(i))
|
|
||||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
|
||||||
// Cache the result
|
|
||||||
cacheLock.lock()
|
|
||||||
derivedIdentityCache[geohash] = identity
|
|
||||||
cacheLock.unlock()
|
|
||||||
return identity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// As a final fallback, hash the seed+msg and try again
|
|
||||||
let fallback = (seed + msg).sha256Hash()
|
|
||||||
let identity = try NostrIdentity(privateKeyData: fallback)
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
cacheLock.lock()
|
|
||||||
derivedIdentityCache[geohash] = identity
|
|
||||||
cacheLock.unlock()
|
|
||||||
|
|
||||||
return identity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -109,7 +109,20 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Clean up timers and active connections
|
||||||
|
reconnectionTimer?.invalidate()
|
||||||
|
for (_, tracker) in eoseTrackers {
|
||||||
|
tracker.timer?.invalidate()
|
||||||
|
}
|
||||||
|
for (_, task) in connections {
|
||||||
|
task.cancel(with: .goingAway, reason: nil)
|
||||||
|
}
|
||||||
|
cancellables.removeAll()
|
||||||
|
SecureLogger.debug("NostrRelayManager deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
/// Connect to all configured relays
|
/// Connect to all configured relays
|
||||||
func connect() {
|
func connect() {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// MARK: - Hex Encoding/Decoding
|
// MARK: - Hex Encoding/Decoding
|
||||||
|
|
||||||
@@ -17,11 +16,6 @@ extension Data {
|
|||||||
}
|
}
|
||||||
return self.map { String(format: "%02x", $0) }.joined()
|
return self.map { String(format: "%02x", $0) }.joined()
|
||||||
}
|
}
|
||||||
|
|
||||||
func sha256Hex() -> String {
|
|
||||||
let digest = SHA256.hash(data: self)
|
|
||||||
return digest.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(hexString: String) {
|
init?(hexString: String) {
|
||||||
let len = hexString.count / 2
|
let len = hexString.count / 2
|
||||||
|
|||||||
@@ -22,11 +22,11 @@
|
|||||||
///
|
///
|
||||||
/// ## Wire Format
|
/// ## Wire Format
|
||||||
/// ```
|
/// ```
|
||||||
/// Header (Fixed 14 bytes for v1, 16 bytes for v2):
|
/// Header (Fixed 13 bytes):
|
||||||
/// +--------+------+-----+-----------+-------+------------------+
|
/// +--------+------+-----+-----------+-------+----------------+
|
||||||
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||||
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 or 4 bytes |
|
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
|
||||||
/// +--------+------+-----+-----------+-------+------------------+
|
/// +--------+------+-----+-----------+-------+----------------+
|
||||||
///
|
///
|
||||||
/// Variable sections:
|
/// Variable sections:
|
||||||
/// +----------+-------------+---------+------------+
|
/// +----------+-------------+---------+------------+
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
/// ## Flag Bits
|
/// ## Flag Bits
|
||||||
/// - Bit 0: Has recipient ID (directed message)
|
/// - Bit 0: Has recipient ID (directed message)
|
||||||
/// - Bit 1: Has signature (authenticated message)
|
/// - Bit 1: Has signature (authenticated message)
|
||||||
/// - Bit 2: Is compressed (zlib compression applied)
|
/// - Bit 2: Is compressed (LZ4 compression applied)
|
||||||
/// - Bits 3-7: Reserved for future use
|
/// - Bits 3-7: Reserved for future use
|
||||||
///
|
///
|
||||||
/// ## Size Constraints
|
/// ## Size Constraints
|
||||||
@@ -89,7 +89,6 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitLogger
|
|
||||||
|
|
||||||
extension Data {
|
extension Data {
|
||||||
func trimmingNullBytes() -> Data {
|
func trimmingNullBytes() -> Data {
|
||||||
@@ -106,32 +105,10 @@ extension Data {
|
|||||||
/// their binary wire format representation.
|
/// their binary wire format representation.
|
||||||
/// - Note: All multi-byte values use network byte order (big-endian)
|
/// - Note: All multi-byte values use network byte order (big-endian)
|
||||||
struct BinaryProtocol {
|
struct BinaryProtocol {
|
||||||
static let v1HeaderSize = 14
|
static let headerSize = 13
|
||||||
static let v2HeaderSize = 16
|
|
||||||
static let senderIDSize = 8
|
static let senderIDSize = 8
|
||||||
static let recipientIDSize = 8
|
static let recipientIDSize = 8
|
||||||
static let signatureSize = 64
|
static let signatureSize = 64
|
||||||
|
|
||||||
// Field offsets within packet header
|
|
||||||
struct Offsets {
|
|
||||||
static let version = 0
|
|
||||||
static let type = 1
|
|
||||||
static let ttl = 2
|
|
||||||
static let timestamp = 3
|
|
||||||
static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func headerSize(for version: UInt8) -> Int? {
|
|
||||||
switch version {
|
|
||||||
case 1: return v1HeaderSize
|
|
||||||
case 2: return v2HeaderSize
|
|
||||||
default: return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func lengthFieldSize(for version: UInt8) -> Int {
|
|
||||||
return version == 2 ? 4 : 2
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Flags {
|
struct Flags {
|
||||||
static let hasRecipient: UInt8 = 0x01
|
static let hasRecipient: UInt8 = 0x01
|
||||||
@@ -141,69 +118,70 @@ struct BinaryProtocol {
|
|||||||
|
|
||||||
// Encode BitchatPacket to binary format
|
// Encode BitchatPacket to binary format
|
||||||
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
||||||
let version = packet.version
|
var data = Data()
|
||||||
guard version == 1 || version == 2 else { return nil }
|
|
||||||
|
|
||||||
// Try to compress payload when beneficial, keeping original size for later decoding
|
// Try to compress payload if beneficial
|
||||||
var payload = packet.payload
|
var payload = packet.payload
|
||||||
|
var originalPayloadSize: UInt16? = nil
|
||||||
var isCompressed = false
|
var isCompressed = false
|
||||||
var originalPayloadSize: Int?
|
|
||||||
if CompressionUtil.shouldCompress(payload) {
|
if CompressionUtil.shouldCompress(payload) {
|
||||||
// Only compress when we can represent the original length in the outbound frame
|
if let compressedPayload = CompressionUtil.compress(payload) {
|
||||||
let maxRepresentable = version == 2 ? Int(UInt32.max) : Int(UInt16.max)
|
// Store original size for decompression (2 bytes after payload)
|
||||||
if payload.count <= maxRepresentable,
|
originalPayloadSize = UInt16(payload.count)
|
||||||
let compressedPayload = CompressionUtil.compress(payload) {
|
|
||||||
originalPayloadSize = payload.count
|
|
||||||
payload = compressedPayload
|
payload = compressedPayload
|
||||||
isCompressed = true
|
isCompressed = true
|
||||||
}
|
|
||||||
}
|
} else {
|
||||||
|
|
||||||
let lengthFieldBytes = lengthFieldSize(for: version)
|
|
||||||
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
|
||||||
let payloadDataSize = payload.count + originalSizeFieldBytes
|
|
||||||
|
|
||||||
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
|
|
||||||
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
|
|
||||||
|
|
||||||
guard let headerSize = headerSize(for: version) else { return nil }
|
|
||||||
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize)
|
|
||||||
let estimatedPayload = payloadDataSize
|
|
||||||
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
|
|
||||||
var data = Data()
|
|
||||||
data.reserveCapacity(estimatedHeader + estimatedPayload + estimatedSignature + 255)
|
|
||||||
|
|
||||||
data.append(version)
|
|
||||||
data.append(packet.type)
|
|
||||||
data.append(packet.ttl)
|
|
||||||
|
|
||||||
for shift in stride(from: 56, through: 0, by: -8) {
|
|
||||||
data.append(UInt8((packet.timestamp >> UInt64(shift)) & 0xFF))
|
|
||||||
}
|
|
||||||
|
|
||||||
var flags: UInt8 = 0
|
|
||||||
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
|
||||||
if packet.signature != nil { flags |= Flags.hasSignature }
|
|
||||||
if isCompressed { flags |= Flags.isCompressed }
|
|
||||||
data.append(flags)
|
|
||||||
|
|
||||||
if version == 2 {
|
|
||||||
let length = UInt32(payloadDataSize)
|
|
||||||
for shift in stride(from: 24, through: 0, by: -8) {
|
|
||||||
data.append(UInt8((length >> UInt32(shift)) & 0xFF))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let length = UInt16(payloadDataSize)
|
|
||||||
data.append(UInt8((length >> 8) & 0xFF))
|
|
||||||
data.append(UInt8(length & 0xFF))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
|
||||||
|
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
|
||||||
|
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
|
||||||
|
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
|
||||||
|
data.reserveCapacity(estimated)
|
||||||
|
data.append(packet.version)
|
||||||
|
data.append(packet.type)
|
||||||
|
data.append(packet.ttl)
|
||||||
|
|
||||||
|
// Timestamp (8 bytes, big-endian)
|
||||||
|
for i in (0..<8).reversed() {
|
||||||
|
data.append(UInt8((packet.timestamp >> (i * 8)) & 0xFF))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flags
|
||||||
|
var flags: UInt8 = 0
|
||||||
|
if packet.recipientID != nil {
|
||||||
|
flags |= Flags.hasRecipient
|
||||||
|
}
|
||||||
|
if packet.signature != nil {
|
||||||
|
flags |= Flags.hasSignature
|
||||||
|
}
|
||||||
|
if isCompressed {
|
||||||
|
flags |= Flags.isCompressed
|
||||||
|
}
|
||||||
|
data.append(flags)
|
||||||
|
|
||||||
|
// Payload length (2 bytes, big-endian) - includes original size if compressed
|
||||||
|
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
|
||||||
|
let payloadLength = UInt16(payloadDataSize)
|
||||||
|
|
||||||
|
|
||||||
|
data.append(UInt8((payloadLength >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(payloadLength & 0xFF))
|
||||||
|
|
||||||
|
// SenderID (exactly 8 bytes)
|
||||||
let senderBytes = packet.senderID.prefix(senderIDSize)
|
let senderBytes = packet.senderID.prefix(senderIDSize)
|
||||||
data.append(senderBytes)
|
data.append(senderBytes)
|
||||||
if senderBytes.count < senderIDSize {
|
if senderBytes.count < senderIDSize {
|
||||||
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
|
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecipientID (if present)
|
||||||
if let recipientID = packet.recipientID {
|
if let recipientID = packet.recipientID {
|
||||||
let recipientBytes = recipientID.prefix(recipientIDSize)
|
let recipientBytes = recipientID.prefix(recipientIDSize)
|
||||||
data.append(recipientBytes)
|
data.append(recipientBytes)
|
||||||
@@ -211,30 +189,30 @@ struct BinaryProtocol {
|
|||||||
data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count))
|
data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Payload (with original size prepended if compressed)
|
||||||
if isCompressed, let originalSize = originalPayloadSize {
|
if isCompressed, let originalSize = originalPayloadSize {
|
||||||
if version == 2 {
|
// Prepend original size (2 bytes, big-endian)
|
||||||
let value = UInt32(originalSize)
|
data.append(UInt8((originalSize >> 8) & 0xFF))
|
||||||
for shift in stride(from: 24, through: 0, by: -8) {
|
data.append(UInt8(originalSize & 0xFF))
|
||||||
data.append(UInt8((value >> UInt32(shift)) & 0xFF))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let value = UInt16(originalSize)
|
|
||||||
data.append(UInt8((value >> 8) & 0xFF))
|
|
||||||
data.append(UInt8(value & 0xFF))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
data.append(payload)
|
data.append(payload)
|
||||||
|
|
||||||
|
// Signature (if present)
|
||||||
if let signature = packet.signature {
|
if let signature = packet.signature {
|
||||||
data.append(signature.prefix(signatureSize))
|
data.append(signature.prefix(signatureSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Apply padding to standard block sizes for traffic analysis resistance
|
||||||
if padding {
|
if padding {
|
||||||
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
||||||
return MessagePadding.pad(data, toSize: optimalSize)
|
let paddedData = MessagePadding.pad(data, toSize: optimalSize)
|
||||||
|
return paddedData
|
||||||
|
} else {
|
||||||
|
// Caller explicitly requested no padding (e.g., BLE write path)
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode binary data to BitchatPacket
|
// Decode binary data to BitchatPacket
|
||||||
@@ -249,112 +227,87 @@ struct BinaryProtocol {
|
|||||||
|
|
||||||
// Core decoding implementation used by decode(_:) with and without padding removal
|
// Core decoding implementation used by decode(_:) with and without padding removal
|
||||||
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
||||||
guard raw.count >= v1HeaderSize + senderIDSize else { return nil }
|
// Minimum size: header + senderID
|
||||||
|
guard raw.count >= headerSize + senderIDSize else { return nil }
|
||||||
|
|
||||||
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
||||||
guard let base = buf.baseAddress else { return nil }
|
guard let base = buf.baseAddress else { return nil }
|
||||||
var offset = 0
|
var offset = 0
|
||||||
func require(_ n: Int) -> Bool { offset + n <= buf.count }
|
func require(_ n: Int) -> Bool { offset + n <= buf.count }
|
||||||
|
// Read single byte
|
||||||
func read8() -> UInt8? {
|
func read8() -> UInt8? {
|
||||||
guard require(1) else { return nil }
|
guard require(1) else { return nil }
|
||||||
let value = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
|
let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
|
||||||
offset += 1
|
offset += 1
|
||||||
return value
|
return v
|
||||||
}
|
}
|
||||||
|
// Read big-endian 16-bit
|
||||||
func read16() -> UInt16? {
|
func read16() -> UInt16? {
|
||||||
guard require(2) else { return nil }
|
guard require(2) else { return nil }
|
||||||
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||||
let value = (UInt16(ptr[0]) << 8) | UInt16(ptr[1])
|
let v = (UInt16(p[0]) << 8) | UInt16(p[1])
|
||||||
offset += 2
|
offset += 2
|
||||||
return value
|
return v
|
||||||
}
|
|
||||||
func read32() -> UInt32? {
|
|
||||||
guard require(4) else { return nil }
|
|
||||||
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
|
||||||
let value = (UInt32(ptr[0]) << 24) | (UInt32(ptr[1]) << 16) | (UInt32(ptr[2]) << 8) | UInt32(ptr[3])
|
|
||||||
offset += 4
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
// Copy N bytes into Data
|
||||||
func readData(_ n: Int) -> Data? {
|
func readData(_ n: Int) -> Data? {
|
||||||
guard require(n) else { return nil }
|
guard require(n) else { return nil }
|
||||||
let ptr = base.advanced(by: offset)
|
let ptr = base.advanced(by: offset)
|
||||||
let data = Data(bytes: ptr, count: n)
|
let d = Data(bytes: ptr, count: n)
|
||||||
offset += n
|
offset += n
|
||||||
return data
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let version = read8(), version == 1 || version == 2 else { return nil }
|
// Version
|
||||||
let lengthFieldBytes = lengthFieldSize(for: version)
|
guard let version = read8(), version == 1 else { return nil }
|
||||||
guard let headerSize = headerSize(for: version) else { return nil }
|
guard let type = read8() else { return nil }
|
||||||
let minimumRequired = headerSize + senderIDSize
|
guard let ttl = read8() else { return nil }
|
||||||
guard raw.count >= minimumRequired else { return nil }
|
|
||||||
|
|
||||||
guard let type = read8(), let ttl = read8() else { return nil }
|
// Timestamp 8 bytes BE
|
||||||
|
guard require(8) else { return nil }
|
||||||
var timestamp: UInt64 = 0
|
var ts: UInt64 = 0
|
||||||
for _ in 0..<8 {
|
for _ in 0..<8 {
|
||||||
guard let byte = read8() else { return nil }
|
guard let b = read8() else { return nil }
|
||||||
timestamp = (timestamp << 8) | UInt64(byte)
|
ts = (ts << 8) | UInt64(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flags
|
||||||
guard let flags = read8() else { return nil }
|
guard let flags = read8() else { return nil }
|
||||||
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
||||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||||
|
|
||||||
let payloadLength: Int
|
// Payload length
|
||||||
if version == 2 {
|
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
|
||||||
guard let len = read32() else { return nil }
|
|
||||||
payloadLength = Int(len)
|
|
||||||
} else {
|
|
||||||
guard let len = read16() else { return nil }
|
|
||||||
payloadLength = Int(len)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard payloadLength >= 0 else { return nil }
|
|
||||||
|
|
||||||
|
// SenderID
|
||||||
guard let senderID = readData(senderIDSize) else { return nil }
|
guard let senderID = readData(senderIDSize) else { return nil }
|
||||||
|
|
||||||
|
// Recipient
|
||||||
var recipientID: Data? = nil
|
var recipientID: Data? = nil
|
||||||
if hasRecipient {
|
if hasRecipient {
|
||||||
recipientID = readData(recipientIDSize)
|
recipientID = readData(recipientIDSize)
|
||||||
if recipientID == nil { return nil }
|
if recipientID == nil { return nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Payload
|
||||||
let payload: Data
|
let payload: Data
|
||||||
if isCompressed {
|
if isCompressed {
|
||||||
guard payloadLength >= lengthFieldBytes else { return nil }
|
// Need original size (2 bytes)
|
||||||
let originalSize: Int
|
guard let origSize16 = read16() else { return nil }
|
||||||
if version == 2 {
|
let originalSize = Int(origSize16)
|
||||||
guard let rawSize = read32() else { return nil }
|
guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil }
|
||||||
originalSize = Int(rawSize)
|
let compSize = Int(payloadLen) - 2
|
||||||
} else {
|
guard compSize >= 0, let compressed = readData(compSize) else { return nil }
|
||||||
guard let rawSize = read16() else { return nil }
|
|
||||||
originalSize = Int(rawSize)
|
|
||||||
}
|
|
||||||
// Guard to keep decompression bounded to sane BLE payload limits
|
|
||||||
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxPayloadBytes else { return nil }
|
|
||||||
let compressedSize = payloadLength - lengthFieldBytes
|
|
||||||
guard compressedSize >= 0, let compressed = readData(compressedSize) else { return nil }
|
|
||||||
|
|
||||||
// Validate compression ratio to prevent zip bomb attacks
|
|
||||||
// Primary protection: originalSize capped at 1MB (line 336)
|
|
||||||
// Defense-in-depth: reject extreme ratios (prevents DoS via memory allocation)
|
|
||||||
guard compressedSize > 0 else { return nil }
|
|
||||||
let compressionRatio = Double(originalSize) / Double(compressedSize)
|
|
||||||
guard compressionRatio <= 50_000.0 else {
|
|
||||||
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
|
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
|
||||||
decompressed.count == originalSize else { return nil }
|
decompressed.count == originalSize else { return nil }
|
||||||
payload = decompressed
|
payload = decompressed
|
||||||
} else {
|
} else {
|
||||||
guard let rawPayload = readData(payloadLength) else { return nil }
|
guard let p = readData(Int(payloadLen)) else { return nil }
|
||||||
payload = rawPayload
|
payload = p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Signature
|
||||||
var signature: Data? = nil
|
var signature: Data? = nil
|
||||||
if hasSignature {
|
if hasSignature {
|
||||||
signature = readData(signatureSize)
|
signature = readData(signatureSize)
|
||||||
@@ -367,11 +320,10 @@ struct BinaryProtocol {
|
|||||||
type: type,
|
type: type,
|
||||||
senderID: senderID,
|
senderID: senderID,
|
||||||
recipientID: recipientID,
|
recipientID: recipientID,
|
||||||
timestamp: timestamp,
|
timestamp: ts,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: signature,
|
signature: signature,
|
||||||
ttl: ttl,
|
ttl: ttl
|
||||||
version: version
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
//
|
|
||||||
// BitchatFilePacket.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import BitLogger
|
|
||||||
|
|
||||||
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
|
||||||
/// Mirrors the Android client specification to ensure cross-platform interoperability.
|
|
||||||
struct BitchatFilePacket {
|
|
||||||
var fileName: String?
|
|
||||||
var fileSize: UInt64?
|
|
||||||
var mimeType: String?
|
|
||||||
var content: Data
|
|
||||||
|
|
||||||
/// Canonical TLV tags defined by the Android implementation.
|
|
||||||
private enum TLVType: UInt8 {
|
|
||||||
case fileName = 0x01
|
|
||||||
case fileSize = 0x02
|
|
||||||
case mimeType = 0x03
|
|
||||||
case content = 0x04
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
|
||||||
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
|
||||||
func encode() -> Data? {
|
|
||||||
let resolvedSize = fileSize ?? UInt64(content.count)
|
|
||||||
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
|
||||||
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
|
||||||
guard content.count <= Int(UInt32.max) else { return nil }
|
|
||||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
|
||||||
|
|
||||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
|
||||||
var big = value.bigEndian
|
|
||||||
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
var encoded = Data()
|
|
||||||
|
|
||||||
if let name = fileName, let nameData = name.data(using: .utf8), nameData.count <= Int(UInt16.max) {
|
|
||||||
encoded.append(TLVType.fileName.rawValue)
|
|
||||||
appendBE(UInt16(nameData.count), into: &encoded)
|
|
||||||
encoded.append(nameData)
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded.append(TLVType.fileSize.rawValue)
|
|
||||||
appendBE(UInt16(4), into: &encoded)
|
|
||||||
appendBE(UInt32(resolvedSize), into: &encoded)
|
|
||||||
|
|
||||||
if let mime = mimeType, let mimeData = mime.data(using: .utf8), mimeData.count <= Int(UInt16.max) {
|
|
||||||
encoded.append(TLVType.mimeType.rawValue)
|
|
||||||
appendBE(UInt16(mimeData.count), into: &encoded)
|
|
||||||
encoded.append(mimeData)
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded.append(TLVType.content.rawValue)
|
|
||||||
appendBE(UInt32(content.count), into: &encoded)
|
|
||||||
encoded.append(content)
|
|
||||||
|
|
||||||
return encoded
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
|
||||||
static func decode(_ data: Data) -> BitchatFilePacket? {
|
|
||||||
var cursor = data.startIndex
|
|
||||||
let end = data.endIndex
|
|
||||||
|
|
||||||
var fileName: String?
|
|
||||||
var fileSize: UInt64?
|
|
||||||
var mimeType: String?
|
|
||||||
var content = Data()
|
|
||||||
|
|
||||||
while cursor < end {
|
|
||||||
let typeRaw = data[cursor]
|
|
||||||
cursor = data.index(after: cursor)
|
|
||||||
|
|
||||||
guard cursor <= end else { return nil }
|
|
||||||
let tlvType = TLVType(rawValue: typeRaw)
|
|
||||||
|
|
||||||
func readBigEndianLength(bytes: Int) -> Int? {
|
|
||||||
guard data.distance(from: cursor, to: end) >= bytes else { return nil }
|
|
||||||
// Use UInt64 to prevent integer overflow during shift operations
|
|
||||||
var result: UInt64 = 0
|
|
||||||
for _ in 0..<bytes {
|
|
||||||
result = (result << 8) | UInt64(data[cursor])
|
|
||||||
cursor = data.index(after: cursor)
|
|
||||||
}
|
|
||||||
// Safely convert to Int with overflow check
|
|
||||||
guard result <= Int.max else { return nil }
|
|
||||||
return Int(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
let length: Int?
|
|
||||||
if tlvType == .content {
|
|
||||||
let snapshot = cursor
|
|
||||||
let canonical = readBigEndianLength(bytes: 4)
|
|
||||||
if let canonical = canonical,
|
|
||||||
canonical <= data.distance(from: cursor, to: end) {
|
|
||||||
length = canonical
|
|
||||||
} else {
|
|
||||||
cursor = snapshot
|
|
||||||
length = readBigEndianLength(bytes: 2)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
length = readBigEndianLength(bytes: 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let tlvLength = length, tlvLength >= 0 else { return nil }
|
|
||||||
guard data.distance(from: cursor, to: end) >= tlvLength else { return nil }
|
|
||||||
|
|
||||||
let valueStart = cursor
|
|
||||||
cursor = data.index(cursor, offsetBy: tlvLength)
|
|
||||||
let value = data[valueStart..<cursor]
|
|
||||||
|
|
||||||
switch tlvType {
|
|
||||||
case .fileName:
|
|
||||||
fileName = String(data: Data(value), encoding: .utf8)
|
|
||||||
case .fileSize:
|
|
||||||
if tlvLength == 4 || tlvLength == 8 {
|
|
||||||
var size: UInt64 = 0
|
|
||||||
for byte in value {
|
|
||||||
size = (size << 8) | UInt64(byte)
|
|
||||||
}
|
|
||||||
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fileSize = size
|
|
||||||
}
|
|
||||||
case .mimeType:
|
|
||||||
mimeType = String(data: Data(value), encoding: .utf8)
|
|
||||||
case .content:
|
|
||||||
let proposedSize = content.count + value.count
|
|
||||||
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
content.append(contentsOf: value)
|
|
||||||
case nil:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !content.isEmpty else { return nil }
|
|
||||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
|
||||||
return BitchatFilePacket(
|
|
||||||
fileName: fileName,
|
|
||||||
fileSize: fileSize ?? UInt64(content.count),
|
|
||||||
mimeType: mimeType,
|
|
||||||
content: content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -79,7 +79,6 @@ enum MessageType: UInt8 {
|
|||||||
|
|
||||||
// Fragmentation (simplified)
|
// Fragmentation (simplified)
|
||||||
case fragment = 0x20 // Single fragment type for large messages
|
case fragment = 0x20 // Single fragment type for large messages
|
||||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -90,7 +89,6 @@ enum MessageType: UInt8 {
|
|||||||
case .noiseHandshake: return "noiseHandshake"
|
case .noiseHandshake: return "noiseHandshake"
|
||||||
case .noiseEncrypted: return "noiseEncrypted"
|
case .noiseEncrypted: return "noiseEncrypted"
|
||||||
case .fragment: return "fragment"
|
case .fragment: return "fragment"
|
||||||
case .fileTransfer: return "fileTransfer"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+458
-1539
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,328 @@
|
|||||||
|
//
|
||||||
|
// ColorPaletteService.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Manages consistent color assignment for peers using minimal-distance algorithm
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Service that assigns consistent, visually distinct colors to peers
|
||||||
|
/// Uses a minimal-distance hue assignment algorithm to maximize color separation
|
||||||
|
final class ColorPaletteService {
|
||||||
|
|
||||||
|
// MARK: - Palette State
|
||||||
|
|
||||||
|
private var peerPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||||
|
private var peerPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||||
|
private var peerPaletteSeeds: [String: String] = [:] // peerID -> seed used
|
||||||
|
|
||||||
|
private var nostrPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||||
|
private var nostrPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||||
|
private var nostrPaletteSeeds: [String: String] = [:] // pubkey -> seed used
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
private let slotCount: Int
|
||||||
|
private let avoidCenter: Double // Hue to avoid (typically orange for self)
|
||||||
|
private let avoidDelta: Double
|
||||||
|
private let saturationDark: Double
|
||||||
|
private let saturationLight: Double
|
||||||
|
private let baseBrightnessDark: Double
|
||||||
|
private let baseBrightnessLight: Double
|
||||||
|
private let ringDeltaDark: Double
|
||||||
|
private let ringDeltaLight: Double
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(
|
||||||
|
slotCount: Int = max(8, TransportConfig.uiPeerPaletteSlots),
|
||||||
|
avoidCenter: Double = 30.0 / 360.0, // Orange hue
|
||||||
|
avoidDelta: Double = TransportConfig.uiColorHueAvoidanceDelta,
|
||||||
|
saturationDark: Double = 0.80,
|
||||||
|
saturationLight: Double = 0.70,
|
||||||
|
baseBrightnessDark: Double = 0.75,
|
||||||
|
baseBrightnessLight: Double = 0.45,
|
||||||
|
ringDeltaDark: Double = TransportConfig.uiPeerPaletteRingBrightnessDeltaDark,
|
||||||
|
ringDeltaLight: Double = TransportConfig.uiPeerPaletteRingBrightnessDeltaLight
|
||||||
|
) {
|
||||||
|
self.slotCount = slotCount
|
||||||
|
self.avoidCenter = avoidCenter
|
||||||
|
self.avoidDelta = avoidDelta
|
||||||
|
self.saturationDark = saturationDark
|
||||||
|
self.saturationLight = saturationLight
|
||||||
|
self.baseBrightnessDark = baseBrightnessDark
|
||||||
|
self.baseBrightnessLight = baseBrightnessLight
|
||||||
|
self.ringDeltaDark = ringDeltaDark
|
||||||
|
self.ringDeltaLight = ringDeltaLight
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Get color for a mesh peer
|
||||||
|
func colorForMeshPeer(
|
||||||
|
peerID: String,
|
||||||
|
isDark: Bool,
|
||||||
|
myPeerID: String,
|
||||||
|
allPeers: [BitchatPeer],
|
||||||
|
getNoiseKeyForShortID: (String) -> String?
|
||||||
|
) -> Color {
|
||||||
|
// Ensure palette is up to date
|
||||||
|
rebuildPeerPaletteIfNeeded(
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
allPeers: allPeers,
|
||||||
|
getNoiseKeyForShortID: getNoiseKeyForShortID
|
||||||
|
)
|
||||||
|
|
||||||
|
let entry = (isDark ? peerPaletteDark[peerID] : peerPaletteLight[peerID])
|
||||||
|
let orange = Color.orange
|
||||||
|
if peerID == myPeerID { return orange }
|
||||||
|
|
||||||
|
let saturation: Double = isDark ? saturationDark : saturationLight
|
||||||
|
let baseBrightness: Double = isDark ? baseBrightnessDark : baseBrightnessLight
|
||||||
|
let ringDelta = isDark ? ringDeltaDark : ringDeltaLight
|
||||||
|
|
||||||
|
if let e = entry {
|
||||||
|
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
|
||||||
|
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to seed color if not in palette
|
||||||
|
let seed = meshSeed(for: peerID, getNoiseKeyForShortID: getNoiseKeyForShortID)
|
||||||
|
return Color(peerSeed: seed, isDark: isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get color for a Nostr participant
|
||||||
|
func colorForNostrPubkey(
|
||||||
|
pubkeyHexLowercased: String,
|
||||||
|
isDark: Bool,
|
||||||
|
myNostrPubkey: String?,
|
||||||
|
geohashPeople: [(id: String, seed: String)]
|
||||||
|
) -> Color {
|
||||||
|
rebuildNostrPaletteIfNeeded(
|
||||||
|
myNostrPubkey: myNostrPubkey,
|
||||||
|
geohashPeople: geohashPeople
|
||||||
|
)
|
||||||
|
|
||||||
|
let entry = (isDark ? nostrPaletteDark[pubkeyHexLowercased] : nostrPaletteLight[pubkeyHexLowercased])
|
||||||
|
if let me = myNostrPubkey, pubkeyHexLowercased == me { return .orange }
|
||||||
|
|
||||||
|
let saturation: Double = isDark ? saturationDark : saturationLight
|
||||||
|
let baseBrightness: Double = isDark ? baseBrightnessDark : baseBrightnessLight
|
||||||
|
let ringDelta = isDark ? ringDeltaDark : ringDeltaLight
|
||||||
|
|
||||||
|
if let e = entry {
|
||||||
|
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
|
||||||
|
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to seed color
|
||||||
|
return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get color for a message sender (auto-detects type)
|
||||||
|
func peerColor(
|
||||||
|
for message: BitchatMessage,
|
||||||
|
isDark: Bool,
|
||||||
|
myPeerID: String,
|
||||||
|
myNostrPubkey: String?,
|
||||||
|
nostrKeyMapping: [String: String],
|
||||||
|
allPeers: [BitchatPeer],
|
||||||
|
geohashPeople: [(id: String, seed: String)],
|
||||||
|
getNoiseKeyForShortID: (String) -> String?
|
||||||
|
) -> Color {
|
||||||
|
if let spid = message.senderPeerID?.id {
|
||||||
|
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
|
||||||
|
let bare: String = {
|
||||||
|
if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) }
|
||||||
|
if spid.hasPrefix("nostr_") { return String(spid.dropFirst(6)) }
|
||||||
|
return spid
|
||||||
|
}()
|
||||||
|
let full = nostrKeyMapping[spid]?.lowercased() ?? bare.lowercased()
|
||||||
|
return colorForNostrPubkey(
|
||||||
|
pubkeyHexLowercased: full,
|
||||||
|
isDark: isDark,
|
||||||
|
myNostrPubkey: myNostrPubkey,
|
||||||
|
geohashPeople: geohashPeople
|
||||||
|
)
|
||||||
|
} else if spid.count == 16 {
|
||||||
|
return colorForMeshPeer(
|
||||||
|
peerID: spid,
|
||||||
|
isDark: isDark,
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
allPeers: allPeers,
|
||||||
|
getNoiseKeyForShortID: getNoiseKeyForShortID
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return colorForMeshPeer(
|
||||||
|
peerID: spid.lowercased(),
|
||||||
|
isDark: isDark,
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
allPeers: allPeers,
|
||||||
|
getNoiseKeyForShortID: getNoiseKeyForShortID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback when we only have a display name
|
||||||
|
return Color(peerSeed: message.sender.lowercased(), isDark: isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset all palette state (useful for testing)
|
||||||
|
func reset() {
|
||||||
|
peerPaletteLight.removeAll()
|
||||||
|
peerPaletteDark.removeAll()
|
||||||
|
peerPaletteSeeds.removeAll()
|
||||||
|
nostrPaletteLight.removeAll()
|
||||||
|
nostrPaletteDark.removeAll()
|
||||||
|
nostrPaletteSeeds.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private func meshSeed(for peerID: String, getNoiseKeyForShortID: (String) -> String?) -> String {
|
||||||
|
if let full = getNoiseKeyForShortID(peerID)?.lowercased() {
|
||||||
|
return "noise:" + full
|
||||||
|
}
|
||||||
|
return peerID.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuildPeerPaletteIfNeeded(
|
||||||
|
myPeerID: String,
|
||||||
|
allPeers: [BitchatPeer],
|
||||||
|
getNoiseKeyForShortID: (String) -> String?
|
||||||
|
) {
|
||||||
|
// Build current peer->seed map (excluding self)
|
||||||
|
var currentSeeds: [String: String] = [:]
|
||||||
|
for p in allPeers where p.peerID.id != myPeerID {
|
||||||
|
currentSeeds[p.peerID.id] = meshSeed(for: p.peerID.id, getNoiseKeyForShortID: getNoiseKeyForShortID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If seeds unchanged and palette exists for both themes, skip
|
||||||
|
if currentSeeds == peerPaletteSeeds,
|
||||||
|
peerPaletteLight.keys.count == currentSeeds.count,
|
||||||
|
peerPaletteDark.keys.count == currentSeeds.count {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peerPaletteSeeds = currentSeeds
|
||||||
|
|
||||||
|
// Generate palette
|
||||||
|
let mapping = assignColorsMinimalDistance(seeds: currentSeeds, previousMapping: peerPaletteLight)
|
||||||
|
peerPaletteLight = mapping
|
||||||
|
peerPaletteDark = mapping
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuildNostrPaletteIfNeeded(
|
||||||
|
myNostrPubkey: String?,
|
||||||
|
geohashPeople: [(id: String, seed: String)]
|
||||||
|
) {
|
||||||
|
// Build seeds map from currently visible geohash people (excluding self)
|
||||||
|
var currentSeeds: [String: String] = [:]
|
||||||
|
for p in geohashPeople where p.id != myNostrPubkey {
|
||||||
|
currentSeeds[p.id] = p.seed
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentSeeds == nostrPaletteSeeds,
|
||||||
|
nostrPaletteLight.keys.count == currentSeeds.count,
|
||||||
|
nostrPaletteDark.keys.count == currentSeeds.count {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nostrPaletteSeeds = currentSeeds
|
||||||
|
|
||||||
|
let mapping = assignColorsMinimalDistance(seeds: currentSeeds, previousMapping: nostrPaletteLight)
|
||||||
|
nostrPaletteLight = mapping
|
||||||
|
nostrPaletteDark = mapping
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Minimal-Distance Color Assignment Algorithm
|
||||||
|
|
||||||
|
private func assignColorsMinimalDistance(
|
||||||
|
seeds: [String: String],
|
||||||
|
previousMapping: [String: (slot: Int, ring: Int, hue: Double)]
|
||||||
|
) -> [String: (slot: Int, ring: Int, hue: Double)] {
|
||||||
|
// Generate evenly spaced hue slots avoiding self-orange range
|
||||||
|
var slots: [Double] = []
|
||||||
|
for i in 0..<slotCount {
|
||||||
|
let hue = Double(i) / Double(slotCount)
|
||||||
|
if abs(hue - avoidCenter) < avoidDelta { continue }
|
||||||
|
slots.append(hue)
|
||||||
|
}
|
||||||
|
if slots.isEmpty {
|
||||||
|
// Safety: if avoidance consumed all (shouldn't happen), fall back to full slots
|
||||||
|
for i in 0..<slotCount { slots.append(Double(i) / Double(slotCount)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to compute circular distance
|
||||||
|
func circDist(_ a: Double, _ b: Double) -> Double {
|
||||||
|
let d = abs(a - b)
|
||||||
|
return d > 0.5 ? 1.0 - d : d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign slots to peers to maximize minimal distance, deterministically
|
||||||
|
let peers = seeds.keys.sorted() // stable order
|
||||||
|
|
||||||
|
// Preferred slot index by seed (wrapping to available slots)
|
||||||
|
let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in
|
||||||
|
let h = (seeds[id] ?? id).djb2()
|
||||||
|
let idx = Int(h % UInt64(slots.count))
|
||||||
|
return (id, idx)
|
||||||
|
})
|
||||||
|
|
||||||
|
var mapping: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||||
|
var usedSlots = Set<Int>()
|
||||||
|
var usedHues: [Double] = []
|
||||||
|
|
||||||
|
// Keep previous assignments if still valid to minimize churn
|
||||||
|
for (id, entry) in previousMapping {
|
||||||
|
if seeds.keys.contains(id), entry.slot < slots.count { // slot index still valid
|
||||||
|
mapping[id] = (entry.slot, entry.ring, slots[entry.slot])
|
||||||
|
usedSlots.insert(entry.slot)
|
||||||
|
usedHues.append(slots[entry.slot])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First ring assignment using free slots
|
||||||
|
let unassigned = peers.filter { mapping[$0] == nil }
|
||||||
|
for id in unassigned {
|
||||||
|
// If a preferred slot free, take it
|
||||||
|
let preferred = prefIndex[id] ?? 0
|
||||||
|
if !usedSlots.contains(preferred) && preferred < slots.count {
|
||||||
|
mapping[id] = (preferred, 0, slots[preferred])
|
||||||
|
usedSlots.insert(preferred)
|
||||||
|
usedHues.append(slots[preferred])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Choose free slot maximizing minimal distance to used hues
|
||||||
|
var bestSlot: Int? = nil
|
||||||
|
var bestScore: Double = -1
|
||||||
|
for sIdx in 0..<slots.count where !usedSlots.contains(sIdx) {
|
||||||
|
let hue = slots[sIdx]
|
||||||
|
let minDist = usedHues.isEmpty ? 1.0 : usedHues.map { circDist(hue, $0) }.min() ?? 1.0
|
||||||
|
// Bias toward preferred index for stability
|
||||||
|
let bias = 1.0 - (Double((abs(sIdx - (prefIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
|
||||||
|
let score = minDist + 0.05 * bias
|
||||||
|
if score > bestScore { bestScore = score; bestSlot = sIdx }
|
||||||
|
}
|
||||||
|
if let s = bestSlot {
|
||||||
|
mapping[id] = (s, 0, slots[s])
|
||||||
|
usedSlots.insert(s)
|
||||||
|
usedHues.append(slots[s])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overflow peers: assign additional rings by reusing slots with stable preference
|
||||||
|
let stillUnassigned = peers.filter { mapping[$0] == nil }
|
||||||
|
if !stillUnassigned.isEmpty {
|
||||||
|
for (idx, id) in stillUnassigned.enumerated() {
|
||||||
|
let preferred = prefIndex[id] ?? 0
|
||||||
|
// Spread over slots by rotating from preferred with a golden-step
|
||||||
|
let goldenStep = 7 // small prime step for dispersion
|
||||||
|
let s = (preferred + idx * goldenStep) % slots.count
|
||||||
|
mapping[id] = (s, 1, slots[s])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ final class CommandProcessor {
|
|||||||
case .location: return true
|
case .location: return true
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true
|
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||||
|
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "/m", "/msg":
|
case "/m", "/msg":
|
||||||
@@ -104,7 +104,7 @@ final class CommandProcessor {
|
|||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Geohash context: show visible geohash participants (exclude self)
|
// Geohash context: show visible geohash participants (exclude self)
|
||||||
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
||||||
let myHex = (try? chatViewModel?.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||||
let people = vm.visibleGeohashPeople().filter { person in
|
let people = vm.visibleGeohashPeople().filter { person in
|
||||||
if let me = myHex { return person.id.lowercased() != me }
|
if let me = myHex { return person.id.lowercased() != me }
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//
|
||||||
|
// DeliveryTrackingService.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Service for tracking message delivery and read status
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Service that manages delivery status updates for messages
|
||||||
|
/// Prevents status downgrades (e.g., read → delivered) and maintains consistency
|
||||||
|
final class DeliveryTrackingService {
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Update delivery status for a message, preventing downgrades
|
||||||
|
/// - Parameters:
|
||||||
|
/// - messageID: The message ID to update
|
||||||
|
/// - status: The new delivery status
|
||||||
|
/// - messages: Array of public messages (inout for mutation)
|
||||||
|
/// - privateChats: Dictionary of private chats (inout for mutation)
|
||||||
|
/// - notifyChange: Closure to trigger UI update
|
||||||
|
func updateStatus(
|
||||||
|
messageID: String,
|
||||||
|
status: DeliveryStatus,
|
||||||
|
messages: inout [BitchatMessage],
|
||||||
|
privateChats: inout [String: [BitchatMessage]],
|
||||||
|
notifyChange: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
// Update in main messages
|
||||||
|
if let index = messages.firstIndex(where: { $0.id == messageID }) {
|
||||||
|
let currentStatus = messages[index].deliveryStatus
|
||||||
|
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
|
||||||
|
messages[index].deliveryStatus = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in private chats
|
||||||
|
for (peerID, chatMessages) in privateChats {
|
||||||
|
guard let index = chatMessages.firstIndex(where: { $0.id == messageID }) else { continue }
|
||||||
|
|
||||||
|
let currentStatus = chatMessages[index].deliveryStatus
|
||||||
|
guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue }
|
||||||
|
|
||||||
|
// Update delivery status
|
||||||
|
privateChats[peerID]?[index].deliveryStatus = status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger UI update
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
notifyChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
/// Check if we should skip a status update to prevent downgrades
|
||||||
|
private func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool {
|
||||||
|
guard let current = currentStatus else { return false }
|
||||||
|
|
||||||
|
// Don't downgrade from read to delivered or sent
|
||||||
|
switch (current, newStatus) {
|
||||||
|
case (.read, .delivered):
|
||||||
|
return true
|
||||||
|
case (.read, .sent):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
private static let storageKey = "chat.bitchat.favorites"
|
private static let storageKey = "chat.bitchat.favorites"
|
||||||
private static let keychainService = "chat.bitchat.favorites"
|
private static let keychainService = "chat.bitchat.favorites"
|
||||||
private let keychain: KeychainHelperProtocol
|
|
||||||
|
|
||||||
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
||||||
@Published private(set) var mutualFavorites: Set<Data> = []
|
@Published private(set) var mutualFavorites: Set<Data> = []
|
||||||
@@ -36,8 +35,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
static let shared = FavoritesPersistenceService()
|
static let shared = FavoritesPersistenceService()
|
||||||
|
|
||||||
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
private init() {
|
||||||
self.keychain = keychain
|
|
||||||
loadFavorites()
|
loadFavorites()
|
||||||
|
|
||||||
// Update mutual favorites when favorites change
|
// Update mutual favorites when favorites change
|
||||||
@@ -47,7 +45,13 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
}
|
}
|
||||||
.assign(to: &$mutualFavorites)
|
.assign(to: &$mutualFavorites)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Clean up Combine subscriptions
|
||||||
|
cancellables.removeAll()
|
||||||
|
SecureLogger.debug("FavoritesPersistenceService deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
/// Add or update a favorite
|
/// Add or update a favorite
|
||||||
func addFavorite(
|
func addFavorite(
|
||||||
peerNoisePublicKey: Data,
|
peerNoisePublicKey: Data,
|
||||||
@@ -198,7 +202,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
saveFavorites()
|
saveFavorites()
|
||||||
|
|
||||||
// Delete from keychain directly
|
// Delete from keychain directly
|
||||||
keychain.delete(
|
KeychainHelper.delete(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
service: Self.keychainService
|
service: Self.keychainService
|
||||||
)
|
)
|
||||||
@@ -218,11 +222,10 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
let data = try encoder.encode(relationships)
|
let data = try encoder.encode(relationships)
|
||||||
|
|
||||||
// Store in keychain for security
|
// Store in keychain for security
|
||||||
keychain.save(
|
KeychainHelper.save(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
data: data,
|
data: data,
|
||||||
service: Self.keychainService,
|
service: Self.keychainService
|
||||||
accessible: nil
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Successfully saved favorites
|
// Successfully saved favorites
|
||||||
@@ -234,7 +237,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
private func loadFavorites() {
|
private func loadFavorites() {
|
||||||
// Loading favorites from keychain
|
// Loading favorites from keychain
|
||||||
|
|
||||||
guard let data = keychain.load(
|
guard let data = KeychainHelper.load(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
service: Self.keychainService
|
service: Self.keychainService
|
||||||
) else {
|
) else {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Stores a user-maintained list of bookmarked geohash channels.
|
/// Stores a user-maintained list of bookmarked geohash channels.
|
||||||
/// - Persistence: UserDefaults (JSON string array)
|
/// - Persistence: UserDefaults (JSON string array)
|
||||||
@@ -16,10 +15,8 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
private let storeKey = "locationChannel.bookmarks"
|
private let storeKey = "locationChannel.bookmarks"
|
||||||
private let namesStoreKey = "locationChannel.bookmarkNames"
|
private let namesStoreKey = "locationChannel.bookmarkNames"
|
||||||
private var membership: Set<String> = []
|
private var membership: Set<String> = []
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
private let geocoder = CLGeocoder()
|
private let geocoder = CLGeocoder()
|
||||||
private var resolving: Set<String> = []
|
private var resolving: Set<String> = []
|
||||||
#endif
|
|
||||||
|
|
||||||
private let storage: UserDefaults
|
private let storage: UserDefaults
|
||||||
|
|
||||||
@@ -28,6 +25,12 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Cancel any pending geocoding operations
|
||||||
|
geocoder.cancelGeocode()
|
||||||
|
SecureLogger.debug("GeohashBookmarksStore deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Public API
|
// MARK: - Public API
|
||||||
func isBookmarked(_ geohash: String) -> Bool {
|
func isBookmarked(_ geohash: String) -> Bool {
|
||||||
return membership.contains(Self.normalize(geohash))
|
return membership.contains(Self.normalize(geohash))
|
||||||
@@ -118,7 +121,6 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
let gh = Self.normalize(geohash)
|
let gh = Self.normalize(geohash)
|
||||||
guard !gh.isEmpty else { return }
|
guard !gh.isEmpty else { return }
|
||||||
if bookmarkNames[gh] != nil { return }
|
if bookmarkNames[gh] != nil { return }
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
if resolving.contains(gh) { return }
|
if resolving.contains(gh) { return }
|
||||||
resolving.insert(gh)
|
resolving.insert(gh)
|
||||||
// For very coarse geohashes, sample multiple points to capture multiple admin areas
|
// For very coarse geohashes, sample multiple points to capture multiple admin areas
|
||||||
@@ -149,10 +151,8 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
||||||
var uniqueAdmins = OrderedSet<String>()
|
var uniqueAdmins = OrderedSet<String>()
|
||||||
var idx = 0
|
var idx = 0
|
||||||
@@ -215,7 +215,6 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
/// Testing-only reset helper
|
/// Testing-only reset helper
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
//
|
||||||
|
// GeohashParticipantsService.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Manages tracking of participants in geohash-based location channels
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Service for tracking and managing participants in geohash channels
|
||||||
|
/// Handles automatic expiration, refresh timers, and participant list management
|
||||||
|
final class GeohashParticipantsService: ObservableObject {
|
||||||
|
|
||||||
|
// MARK: - Published Properties
|
||||||
|
|
||||||
|
@Published private(set) var geohashPeople: [GeoPerson] = []
|
||||||
|
|
||||||
|
// MARK: - Private State
|
||||||
|
|
||||||
|
private var geoParticipants: [String: [String: Date]] = [:] // geohash -> [pubkeyHex -> lastSeen]
|
||||||
|
private var geoParticipantsTimer: Timer? = nil
|
||||||
|
private var currentGeohash: String? = nil
|
||||||
|
|
||||||
|
// MARK: - Dependencies
|
||||||
|
|
||||||
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
|
private let displayNameProvider: (String) -> String
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
private let activityWindowSeconds: TimeInterval
|
||||||
|
private let refreshIntervalSeconds: TimeInterval
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(
|
||||||
|
identityManager: SecureIdentityStateManagerProtocol,
|
||||||
|
displayNameProvider: @escaping (String) -> String,
|
||||||
|
activityWindowSeconds: TimeInterval = TransportConfig.uiRecentCutoffFiveMinutesSeconds,
|
||||||
|
refreshIntervalSeconds: TimeInterval = 30.0
|
||||||
|
) {
|
||||||
|
self.identityManager = identityManager
|
||||||
|
self.displayNameProvider = displayNameProvider
|
||||||
|
self.activityWindowSeconds = activityWindowSeconds
|
||||||
|
self.refreshIntervalSeconds = refreshIntervalSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Note: deinit cannot call @MainActor methods
|
||||||
|
// Timer cleanup will happen automatically when service is deallocated
|
||||||
|
SecureLogger.debug("GeohashParticipantsService deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Set the current geohash being tracked (starts/stops timer accordingly)
|
||||||
|
func setCurrentGeohash(_ geohash: String?) {
|
||||||
|
if currentGeohash != geohash {
|
||||||
|
currentGeohash = geohash
|
||||||
|
refreshPeopleList()
|
||||||
|
|
||||||
|
if geohash != nil {
|
||||||
|
startTimer()
|
||||||
|
} else {
|
||||||
|
stopTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a participant activity in the current geohash
|
||||||
|
func recordParticipant(pubkeyHex: String) {
|
||||||
|
guard let gh = currentGeohash else { return }
|
||||||
|
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a participant activity in a specific geohash
|
||||||
|
func recordParticipant(pubkeyHex: String, geohash: String) {
|
||||||
|
let key = pubkeyHex.lowercased()
|
||||||
|
var map = geoParticipants[geohash] ?? [:]
|
||||||
|
map[key] = Date()
|
||||||
|
geoParticipants[geohash] = map
|
||||||
|
|
||||||
|
// Only refresh list if this geohash is currently selected
|
||||||
|
if currentGeohash == geohash {
|
||||||
|
refreshPeopleList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get visible people for the current geohash (without mutating state)
|
||||||
|
func visiblePeople() -> [GeoPerson] {
|
||||||
|
guard let gh = currentGeohash else { return [] }
|
||||||
|
return visiblePeople(for: gh)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get visible people for a specific geohash
|
||||||
|
func visiblePeople(for geohash: String) -> [GeoPerson] {
|
||||||
|
let cutoff = Date().addingTimeInterval(-activityWindowSeconds)
|
||||||
|
let map = (geoParticipants[geohash] ?? [:])
|
||||||
|
.filter { $0.value >= cutoff }
|
||||||
|
.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
|
||||||
|
let people = map
|
||||||
|
.map { (pub, seen) in
|
||||||
|
GeoPerson(id: pub, displayName: displayNameProvider(pub), lastSeen: seen)
|
||||||
|
}
|
||||||
|
.sorted { $0.lastSeen > $1.lastSeen }
|
||||||
|
return people
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get participant count for a specific geohash (using activity window)
|
||||||
|
func participantCount(for geohash: String) -> Int {
|
||||||
|
let cutoff = Date().addingTimeInterval(-activityWindowSeconds)
|
||||||
|
let map = geoParticipants[geohash] ?? [:]
|
||||||
|
return map.values.filter { $0 >= cutoff }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a participant from all geohashes (e.g., when blocked)
|
||||||
|
func removeParticipant(pubkeyHexLowercased: String) {
|
||||||
|
let hex = pubkeyHexLowercased.lowercased()
|
||||||
|
for (gh, var map) in geoParticipants {
|
||||||
|
map.removeValue(forKey: hex)
|
||||||
|
geoParticipants[gh] = map
|
||||||
|
}
|
||||||
|
refreshPeopleList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all participant data (for testing or reset)
|
||||||
|
func reset() {
|
||||||
|
stopTimer()
|
||||||
|
geoParticipants.removeAll()
|
||||||
|
geohashPeople.removeAll()
|
||||||
|
currentGeohash = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private func refreshPeopleList() {
|
||||||
|
guard let gh = currentGeohash else {
|
||||||
|
geohashPeople = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cutoff = Date().addingTimeInterval(-activityWindowSeconds)
|
||||||
|
var map = geoParticipants[gh] ?? [:]
|
||||||
|
|
||||||
|
// Prune expired entries
|
||||||
|
map = map.filter { $0.value >= cutoff }
|
||||||
|
|
||||||
|
// Remove blocked Nostr pubkeys
|
||||||
|
map = map.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
|
||||||
|
|
||||||
|
// Update cleaned map
|
||||||
|
geoParticipants[gh] = map
|
||||||
|
|
||||||
|
// Build display list
|
||||||
|
let people = map
|
||||||
|
.map { (pub, seen) in
|
||||||
|
GeoPerson(id: pub, displayName: displayNameProvider(pub), lastSeen: seen)
|
||||||
|
}
|
||||||
|
.sorted { $0.lastSeen > $1.lastSeen }
|
||||||
|
|
||||||
|
geohashPeople = people
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startTimer() {
|
||||||
|
stopTimer()
|
||||||
|
geoParticipantsTimer = Timer.scheduledTimer(withTimeInterval: refreshIntervalSeconds, repeats: true) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.refreshPeopleList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopTimer() {
|
||||||
|
geoParticipantsTimer?.invalidate()
|
||||||
|
geoParticipantsTimer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,12 @@ final class LocationNotesCounter: ObservableObject {
|
|||||||
self.dependencies = testDependencies
|
self.dependencies = testDependencies
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Note: deinit cannot call @MainActor functions
|
||||||
|
// Subscription cleanup will happen automatically when counter is deallocated
|
||||||
|
SecureLogger.debug("LocationNotesCounter deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
func subscribe(geohash gh: String) {
|
func subscribe(geohash gh: String) {
|
||||||
let norm = gh.lowercased()
|
let norm = gh.lowercased()
|
||||||
if geohash == norm, subscriptionID != nil { return }
|
if geohash == norm, subscriptionID != nil { return }
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ struct LocationNotesDependencies {
|
|||||||
var sendEvent: SendEvent
|
var sendEvent: SendEvent
|
||||||
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
||||||
var now: () -> Date
|
var now: () -> Date
|
||||||
|
|
||||||
private static let idBridge = NostrIdentityBridge()
|
|
||||||
|
|
||||||
static let live = LocationNotesDependencies(
|
static let live = LocationNotesDependencies(
|
||||||
relayLookup: { geohash, count in
|
relayLookup: { geohash, count in
|
||||||
@@ -37,7 +35,7 @@ struct LocationNotesDependencies {
|
|||||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||||
},
|
},
|
||||||
deriveIdentity: { geohash in
|
deriveIdentity: { geohash in
|
||||||
try idBridge.deriveIdentity(forGeohash: geohash)
|
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
||||||
},
|
},
|
||||||
now: { Date() }
|
now: { Date() }
|
||||||
)
|
)
|
||||||
@@ -103,6 +101,12 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
subscribe()
|
subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Note: deinit cannot call @MainActor functions
|
||||||
|
// Subscription cleanup will happen automatically when manager is deallocated
|
||||||
|
SecureLogger.debug("LocationNotesManager deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
func setGeohash(_ newGeohash: String) {
|
func setGeohash(_ newGeohash: String) {
|
||||||
let norm = newGeohash.lowercased()
|
let norm = newGeohash.lowercased()
|
||||||
guard norm != geohash else { return }
|
guard norm != geohash else { return }
|
||||||
|
|||||||
@@ -0,0 +1,618 @@
|
|||||||
|
//
|
||||||
|
// MessageFormattingService.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Service for formatting chat messages with syntax highlighting
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Service that formats BitchatMessages into styled AttributedStrings
|
||||||
|
/// Handles hashtags, mentions, links, payment tokens, and more
|
||||||
|
final class MessageFormattingService {
|
||||||
|
|
||||||
|
// MARK: - Precompiled Regexes
|
||||||
|
|
||||||
|
private enum Regexes {
|
||||||
|
static let hashtag: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
|
||||||
|
}()
|
||||||
|
static let mention: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
|
||||||
|
}()
|
||||||
|
static let cashu: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let bolt11: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let lnurl: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let lightningScheme: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
|
||||||
|
}()
|
||||||
|
static let linkDetector: NSDataDetector? = {
|
||||||
|
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||||
|
}()
|
||||||
|
static let quickCashuPresence: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dependencies
|
||||||
|
|
||||||
|
private let colorPalette: ColorPaletteService
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(colorPalette: ColorPaletteService) {
|
||||||
|
self.colorPalette = colorPalette
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Format a message with full syntax highlighting (hashtags, mentions, links, payments)
|
||||||
|
/// This is the primary formatter used in the main chat view
|
||||||
|
func formatMessageAsText(
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
colorScheme: ColorScheme,
|
||||||
|
nickname: String,
|
||||||
|
myPeerID: String,
|
||||||
|
myNostrPubkey: String?,
|
||||||
|
activeChannel: ChannelID,
|
||||||
|
nostrKeyMapping: [String: String],
|
||||||
|
allPeers: [BitchatPeer],
|
||||||
|
geohashPeople: [GeoPerson],
|
||||||
|
getNoiseKeyForShortID: @escaping (String) -> String?
|
||||||
|
) -> AttributedString {
|
||||||
|
// Determine if this message was sent by self
|
||||||
|
let isSelf = isSelfMessage(
|
||||||
|
message,
|
||||||
|
nickname: nickname,
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
myNostrPubkey: myNostrPubkey,
|
||||||
|
activeChannel: activeChannel
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
let isDark = colorScheme == .dark
|
||||||
|
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {
|
||||||
|
return cachedText
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not cached, format the message
|
||||||
|
var result = AttributedString()
|
||||||
|
|
||||||
|
let baseColor: Color = isSelf ? .orange : colorPalette.peerColor(
|
||||||
|
for: message,
|
||||||
|
isDark: isDark,
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
myNostrPubkey: myNostrPubkey,
|
||||||
|
nostrKeyMapping: nostrKeyMapping,
|
||||||
|
allPeers: allPeers,
|
||||||
|
geohashPeople: geohashPeople.map { (id: $0.id, seed: "nostr:" + $0.id) },
|
||||||
|
getNoiseKeyForShortID: getNoiseKeyForShortID
|
||||||
|
)
|
||||||
|
|
||||||
|
if message.sender != "system" {
|
||||||
|
// Sender (at the beginning) with light-gray suffix styling if present
|
||||||
|
let (baseName, suffix) = message.sender.splitSuffix()
|
||||||
|
var senderStyle = AttributeContainer()
|
||||||
|
senderStyle.foregroundColor = baseColor
|
||||||
|
let fontWeight: Font.Weight = isSelf ? .bold : .medium
|
||||||
|
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced)
|
||||||
|
|
||||||
|
// Make sender clickable: encode senderPeerID into a custom URL
|
||||||
|
if let spid = message.senderPeerID?.id,
|
||||||
|
let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
|
||||||
|
senderStyle.link = url
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format: <@name#suffix>
|
||||||
|
result.append(AttributedString("<@").mergingAttributes(senderStyle))
|
||||||
|
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
|
||||||
|
if !suffix.isEmpty {
|
||||||
|
var suffixStyle = senderStyle
|
||||||
|
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
||||||
|
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||||||
|
}
|
||||||
|
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||||||
|
|
||||||
|
// Process content with syntax highlighting
|
||||||
|
let content = message.content
|
||||||
|
let nsContent = content as NSString
|
||||||
|
let nsLen = nsContent.length
|
||||||
|
|
||||||
|
// Check for Cashu presence early to decide rendering strategy
|
||||||
|
let containsCashuEarly = Regexes.quickCashuPresence.numberOfMatches(
|
||||||
|
in: content,
|
||||||
|
options: [],
|
||||||
|
range: NSRange(location: 0, length: nsLen)
|
||||||
|
) > 0
|
||||||
|
|
||||||
|
// For extremely long content, render as plain text (unless has Cashu)
|
||||||
|
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
||||||
|
var plainStyle = AttributeContainer()
|
||||||
|
plainStyle.foregroundColor = baseColor
|
||||||
|
plainStyle.font = isSelf
|
||||||
|
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||||
|
: .bitchatSystem(size: 14, design: .monospaced)
|
||||||
|
result.append(AttributedString(content).mergingAttributes(plainStyle))
|
||||||
|
} else {
|
||||||
|
// Full syntax highlighting
|
||||||
|
result.append(formatContent(
|
||||||
|
content,
|
||||||
|
nsContent: nsContent,
|
||||||
|
nsLen: nsLen,
|
||||||
|
message: message,
|
||||||
|
baseColor: baseColor,
|
||||||
|
isSelf: isSelf,
|
||||||
|
isDark: isDark,
|
||||||
|
nickname: nickname,
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
myNostrPubkey: myNostrPubkey,
|
||||||
|
activeChannel: activeChannel
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add timestamp
|
||||||
|
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||||||
|
var timestampStyle = AttributeContainer()
|
||||||
|
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
|
||||||
|
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
||||||
|
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||||
|
} else {
|
||||||
|
// System message
|
||||||
|
var contentStyle = AttributeContainer()
|
||||||
|
contentStyle.foregroundColor = Color.gray
|
||||||
|
let content = AttributedString("* \(message.content) *")
|
||||||
|
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
|
||||||
|
result.append(content.mergingAttributes(contentStyle))
|
||||||
|
|
||||||
|
// Add timestamp
|
||||||
|
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||||||
|
var timestampStyle = AttributeContainer()
|
||||||
|
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
|
||||||
|
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
||||||
|
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the formatted text
|
||||||
|
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simpler message formatter (used in legacy contexts)
|
||||||
|
func formatMessage(
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
colorScheme: ColorScheme,
|
||||||
|
nickname: String
|
||||||
|
) -> AttributedString {
|
||||||
|
var result = AttributedString()
|
||||||
|
|
||||||
|
let isDark = colorScheme == .dark
|
||||||
|
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
|
|
||||||
|
if message.sender == "system" {
|
||||||
|
let content = AttributedString("* \(message.content) *")
|
||||||
|
var contentStyle = AttributeContainer()
|
||||||
|
contentStyle.foregroundColor = Color.gray
|
||||||
|
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
|
||||||
|
result.append(content.mergingAttributes(contentStyle))
|
||||||
|
|
||||||
|
// Add timestamp
|
||||||
|
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||||||
|
var timestampStyle = AttributeContainer()
|
||||||
|
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
|
||||||
|
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
||||||
|
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||||
|
} else {
|
||||||
|
let sender = AttributedString("<@\(message.sender)> ")
|
||||||
|
var senderStyle = AttributeContainer()
|
||||||
|
senderStyle.foregroundColor = primaryColor
|
||||||
|
let fontWeight: Font.Weight = message.sender == nickname ? .bold : .medium
|
||||||
|
senderStyle.font = .bitchatSystem(size: 12, weight: fontWeight, design: .monospaced)
|
||||||
|
result.append(sender.mergingAttributes(senderStyle))
|
||||||
|
|
||||||
|
// Process content to highlight mentions
|
||||||
|
let contentText = message.content
|
||||||
|
let pattern = "@([\\p{L}0-9_]+)"
|
||||||
|
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
||||||
|
let nsContent = contentText as NSString
|
||||||
|
let nsLen = nsContent.length
|
||||||
|
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
|
||||||
|
|
||||||
|
var processedContent = AttributedString()
|
||||||
|
var lastEndIndex = contentText.startIndex
|
||||||
|
|
||||||
|
for match in matches {
|
||||||
|
if let range = Range(match.range(at: 0), in: contentText) {
|
||||||
|
// Add text before mention
|
||||||
|
if lastEndIndex < range.lowerBound {
|
||||||
|
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
||||||
|
if !beforeText.isEmpty {
|
||||||
|
var normalStyle = AttributeContainer()
|
||||||
|
normalStyle.font = .bitchatSystem(size: 14, design: .monospaced)
|
||||||
|
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
||||||
|
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the mention with highlight
|
||||||
|
let mentionText = String(contentText[range])
|
||||||
|
var mentionStyle = AttributeContainer()
|
||||||
|
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
|
||||||
|
mentionStyle.foregroundColor = Color.orange
|
||||||
|
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
|
||||||
|
|
||||||
|
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add remaining text
|
||||||
|
if lastEndIndex < contentText.endIndex {
|
||||||
|
let remainingText = String(contentText[lastEndIndex...])
|
||||||
|
var normalStyle = AttributeContainer()
|
||||||
|
normalStyle.font = .bitchatSystem(size: 14, design: .monospaced)
|
||||||
|
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
||||||
|
processedContent.append(AttributedString(remainingText).mergingAttributes(normalStyle))
|
||||||
|
}
|
||||||
|
|
||||||
|
result.append(processedContent)
|
||||||
|
|
||||||
|
if message.isRelay, let originalSender = message.originalSender {
|
||||||
|
let relay = AttributedString(" (via \(originalSender))")
|
||||||
|
var relayStyle = AttributeContainer()
|
||||||
|
relayStyle.foregroundColor = primaryColor.opacity(0.7)
|
||||||
|
relayStyle.font = .bitchatSystem(size: 11, design: .monospaced)
|
||||||
|
result.append(relay.mergingAttributes(relayStyle))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add timestamp
|
||||||
|
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||||||
|
var timestampStyle = AttributeContainer()
|
||||||
|
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
|
||||||
|
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
||||||
|
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private func isSelfMessage(
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
nickname: String,
|
||||||
|
myPeerID: String,
|
||||||
|
myNostrPubkey: String?,
|
||||||
|
activeChannel: ChannelID
|
||||||
|
) -> Bool {
|
||||||
|
if let spid = message.senderPeerID?.id {
|
||||||
|
// In geohash channels, compare against our per-geohash nostr short ID
|
||||||
|
if case .location = activeChannel, spid.hasPrefix("nostr:"),
|
||||||
|
let myGeo = myNostrPubkey {
|
||||||
|
return spid == "nostr:\(myGeo.prefix(TransportConfig.nostrShortKeyDisplayLength))"
|
||||||
|
}
|
||||||
|
return spid == myPeerID
|
||||||
|
}
|
||||||
|
// Fallback by nickname
|
||||||
|
if message.sender == nickname { return true }
|
||||||
|
if message.sender.hasPrefix(nickname + "#") { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatContent(
|
||||||
|
_ content: String,
|
||||||
|
nsContent: NSString,
|
||||||
|
nsLen: Int,
|
||||||
|
message: BitchatMessage,
|
||||||
|
baseColor: Color,
|
||||||
|
isSelf: Bool,
|
||||||
|
isDark: Bool,
|
||||||
|
nickname: String,
|
||||||
|
myPeerID: String,
|
||||||
|
myNostrPubkey: String?,
|
||||||
|
activeChannel: ChannelID
|
||||||
|
) -> AttributedString {
|
||||||
|
// Extract all matches
|
||||||
|
let hasMentionsHint = content.contains("@")
|
||||||
|
let hasHashtagsHint = content.contains("#")
|
||||||
|
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
|
||||||
|
let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
|
||||||
|
let hasCashuHint = content.lowercased().contains("cashu")
|
||||||
|
|
||||||
|
let hashtagMatches = hasHashtagsHint ? Regexes.hashtag.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||||
|
let mentionMatches = hasMentionsHint ? Regexes.mention.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||||
|
let urlMatches = hasURLHint ? (Regexes.linkDetector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []) : []
|
||||||
|
let cashuMatches = hasCashuHint ? Regexes.cashu.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||||
|
let lightningMatches = hasLightningHint ? Regexes.lightningScheme.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||||
|
let bolt11Matches = hasLightningHint ? Regexes.bolt11.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||||
|
let lnurlMatches = hasLightningHint ? Regexes.lnurl.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||||
|
|
||||||
|
// Combine and sort matches, excluding hashtags/URLs overlapping mentions
|
||||||
|
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
|
||||||
|
|
||||||
|
func overlapsMention(_ r: NSRange) -> Bool {
|
||||||
|
for mr in mentionRanges {
|
||||||
|
if NSIntersectionRange(r, mr).length > 0 { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachedToMention(_ r: NSRange) -> Bool {
|
||||||
|
if let nsRange = Range(r, in: content), nsRange.lowerBound > content.startIndex {
|
||||||
|
var i = content.index(before: nsRange.lowerBound)
|
||||||
|
while true {
|
||||||
|
let ch = content[i]
|
||||||
|
if ch.isWhitespace || ch.isNewline { break }
|
||||||
|
if ch == "@" { return true }
|
||||||
|
if i == content.startIndex { break }
|
||||||
|
i = content.index(before: i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isStandaloneHashtag(_ r: NSRange) -> Bool {
|
||||||
|
guard let nsRange = Range(r, in: content) else { return false }
|
||||||
|
if nsRange.lowerBound == content.startIndex { return true }
|
||||||
|
let prev = content.index(before: nsRange.lowerBound)
|
||||||
|
return content[prev].isWhitespace || content[prev].isNewline
|
||||||
|
}
|
||||||
|
|
||||||
|
var allMatches: [(range: NSRange, type: String)] = []
|
||||||
|
for match in hashtagMatches where !overlapsMention(match.range(at: 0)) && !attachedToMention(match.range(at: 0)) && isStandaloneHashtag(match.range(at: 0)) {
|
||||||
|
allMatches.append((match.range(at: 0), "hashtag"))
|
||||||
|
}
|
||||||
|
for match in mentionMatches {
|
||||||
|
allMatches.append((match.range(at: 0), "mention"))
|
||||||
|
}
|
||||||
|
for match in urlMatches where !overlapsMention(match.range) {
|
||||||
|
allMatches.append((match.range, "url"))
|
||||||
|
}
|
||||||
|
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
|
||||||
|
allMatches.append((match.range(at: 0), "cashu"))
|
||||||
|
}
|
||||||
|
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
|
||||||
|
allMatches.append((match.range(at: 0), "lightning"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude overlaps with lightning/url for bolt11/lnurl
|
||||||
|
let occupied: [NSRange] = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) }
|
||||||
|
func overlapsOccupied(_ r: NSRange) -> Bool {
|
||||||
|
for or in occupied {
|
||||||
|
if NSIntersectionRange(r, or).length > 0 { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
||||||
|
allMatches.append((match.range(at: 0), "bolt11"))
|
||||||
|
}
|
||||||
|
for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
||||||
|
allMatches.append((match.range(at: 0), "lnurl"))
|
||||||
|
}
|
||||||
|
allMatches.sort { $0.range.location < $1.range.location }
|
||||||
|
|
||||||
|
// Build content with styling
|
||||||
|
var processedContent = AttributedString()
|
||||||
|
var lastEnd = content.startIndex
|
||||||
|
let isMentioned = message.mentions?.contains(nickname) ?? false
|
||||||
|
|
||||||
|
for (range, type) in allMatches {
|
||||||
|
if let nsRange = Range(range, in: content) {
|
||||||
|
// Add text before match
|
||||||
|
if lastEnd < nsRange.lowerBound {
|
||||||
|
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
|
||||||
|
if !beforeText.isEmpty {
|
||||||
|
var beforeStyle = AttributeContainer()
|
||||||
|
beforeStyle.foregroundColor = baseColor
|
||||||
|
beforeStyle.font = isSelf
|
||||||
|
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||||
|
: .bitchatSystem(size: 14, design: .monospaced)
|
||||||
|
if isMentioned {
|
||||||
|
beforeStyle.font = beforeStyle.font?.bold()
|
||||||
|
}
|
||||||
|
processedContent.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add styled match
|
||||||
|
let matchText = String(content[nsRange])
|
||||||
|
processedContent.append(formatMatch(
|
||||||
|
matchText,
|
||||||
|
type: type,
|
||||||
|
baseColor: baseColor,
|
||||||
|
isSelf: isSelf,
|
||||||
|
isDark: isDark,
|
||||||
|
nickname: nickname,
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
myNostrPubkey: myNostrPubkey,
|
||||||
|
activeChannel: activeChannel
|
||||||
|
))
|
||||||
|
|
||||||
|
lastEnd = nsRange.upperBound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add remaining text after last match
|
||||||
|
if lastEnd < content.endIndex {
|
||||||
|
let remainingText = String(content[lastEnd...])
|
||||||
|
var remainingStyle = AttributeContainer()
|
||||||
|
remainingStyle.foregroundColor = baseColor
|
||||||
|
remainingStyle.font = isSelf
|
||||||
|
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||||
|
: .bitchatSystem(size: 14, design: .monospaced)
|
||||||
|
if isMentioned {
|
||||||
|
remainingStyle.font = remainingStyle.font?.bold()
|
||||||
|
}
|
||||||
|
processedContent.append(AttributedString(remainingText).mergingAttributes(remainingStyle))
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedContent
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatMatch(
|
||||||
|
_ matchText: String,
|
||||||
|
type: String,
|
||||||
|
baseColor: Color,
|
||||||
|
isSelf: Bool,
|
||||||
|
isDark: Bool,
|
||||||
|
nickname: String,
|
||||||
|
myPeerID: String,
|
||||||
|
myNostrPubkey: String?,
|
||||||
|
activeChannel: ChannelID
|
||||||
|
) -> AttributedString {
|
||||||
|
switch type {
|
||||||
|
case "mention":
|
||||||
|
return formatMention(
|
||||||
|
matchText,
|
||||||
|
baseColor: baseColor,
|
||||||
|
isSelf: isSelf,
|
||||||
|
nickname: nickname,
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
myNostrPubkey: myNostrPubkey,
|
||||||
|
activeChannel: activeChannel
|
||||||
|
)
|
||||||
|
case "hashtag":
|
||||||
|
return formatHashtag(matchText, isDark: isDark, baseColor: baseColor, activeChannel: activeChannel)
|
||||||
|
case "url":
|
||||||
|
return formatURL(matchText, baseColor: baseColor, isSelf: isSelf)
|
||||||
|
case "cashu", "bolt11", "lnurl", "lightning":
|
||||||
|
return formatPayment(matchText, type: type, baseColor: baseColor, isSelf: isSelf)
|
||||||
|
default:
|
||||||
|
return AttributedString(matchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatMention(
|
||||||
|
_ matchText: String,
|
||||||
|
baseColor: Color,
|
||||||
|
isSelf: Bool,
|
||||||
|
nickname: String,
|
||||||
|
myPeerID: String,
|
||||||
|
myNostrPubkey: String?,
|
||||||
|
activeChannel: ChannelID
|
||||||
|
) -> AttributedString {
|
||||||
|
// Split optional '#abcd' suffix and color suffix light grey
|
||||||
|
let (mBase, mSuffix) = matchText.splitSuffix()
|
||||||
|
|
||||||
|
// Determine if this mention targets me
|
||||||
|
let mySuffix: String? = {
|
||||||
|
if case .location = activeChannel, let myGeo = myNostrPubkey {
|
||||||
|
return String(myGeo.suffix(4))
|
||||||
|
}
|
||||||
|
return String(myPeerID.prefix(4))
|
||||||
|
}()
|
||||||
|
|
||||||
|
let isMentionToMe: Bool = {
|
||||||
|
if mBase == nickname {
|
||||||
|
if let suf = mySuffix, !mSuffix.isEmpty {
|
||||||
|
return mSuffix == "#\(suf)"
|
||||||
|
}
|
||||||
|
return mSuffix.isEmpty
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
|
||||||
|
var mentionStyle = AttributeContainer()
|
||||||
|
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
|
||||||
|
mentionStyle.foregroundColor = isMentionToMe ? .orange : baseColor
|
||||||
|
|
||||||
|
var result = AttributedString()
|
||||||
|
result.append(AttributedString(mBase).mergingAttributes(mentionStyle))
|
||||||
|
|
||||||
|
if !mSuffix.isEmpty {
|
||||||
|
var suffixStyle = mentionStyle
|
||||||
|
suffixStyle.foregroundColor = (isMentionToMe ? Color.orange : baseColor).opacity(0.5)
|
||||||
|
result.append(AttributedString(mSuffix).mergingAttributes(suffixStyle))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatHashtag(
|
||||||
|
_ matchText: String,
|
||||||
|
isDark: Bool,
|
||||||
|
baseColor: Color,
|
||||||
|
activeChannel: ChannelID
|
||||||
|
) -> AttributedString {
|
||||||
|
var hashtagStyle = AttributeContainer()
|
||||||
|
hashtagStyle.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
||||||
|
|
||||||
|
// Determine if this hashtag represents the active channel
|
||||||
|
let isActiveChannel: Bool = {
|
||||||
|
if matchText.count > 1 {
|
||||||
|
let tag = String(matchText.dropFirst()) // Remove '#'
|
||||||
|
switch activeChannel {
|
||||||
|
case .mesh:
|
||||||
|
return tag.lowercased() == "mesh"
|
||||||
|
case .location(let ch):
|
||||||
|
return tag.lowercased() == ch.geohash.lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
|
||||||
|
if isActiveChannel {
|
||||||
|
// Highlight active channel hashtag in green
|
||||||
|
hashtagStyle.foregroundColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
|
hashtagStyle.font = .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||||
|
} else {
|
||||||
|
// Link to geohash if valid
|
||||||
|
if matchText.count > 1 {
|
||||||
|
let tag = String(matchText.dropFirst())
|
||||||
|
if tag.count >= 2, tag.count <= 12,
|
||||||
|
tag.allSatisfy({ "0123456789bcdefghjkmnpqrstuvwxyz".contains($0) }) {
|
||||||
|
if let url = URL(string: "bitchat://geohash/\(tag)") {
|
||||||
|
hashtagStyle.link = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hashtagStyle.foregroundColor = baseColor.opacity(0.8)
|
||||||
|
}
|
||||||
|
|
||||||
|
return AttributedString(matchText).mergingAttributes(hashtagStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatURL(_ matchText: String, baseColor: Color, isSelf: Bool) -> AttributedString {
|
||||||
|
var urlStyle = AttributeContainer()
|
||||||
|
if let url = URL(string: matchText) {
|
||||||
|
urlStyle.link = url
|
||||||
|
}
|
||||||
|
urlStyle.foregroundColor = baseColor
|
||||||
|
urlStyle.font = isSelf
|
||||||
|
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||||
|
: .bitchatSystem(size: 14, design: .monospaced)
|
||||||
|
urlStyle.underlineStyle = .single
|
||||||
|
return AttributedString(matchText).mergingAttributes(urlStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatPayment(
|
||||||
|
_ matchText: String,
|
||||||
|
type: String,
|
||||||
|
baseColor: Color,
|
||||||
|
isSelf: Bool
|
||||||
|
) -> AttributedString {
|
||||||
|
var paymentStyle = AttributeContainer()
|
||||||
|
paymentStyle.foregroundColor = baseColor
|
||||||
|
paymentStyle.font = isSelf
|
||||||
|
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||||
|
: .bitchatSystem(size: 14, design: .monospaced)
|
||||||
|
|
||||||
|
// Make payment tokens tappable
|
||||||
|
if type == "cashu", let url = URL(string: "cashu:\(matchText)") {
|
||||||
|
paymentStyle.link = url
|
||||||
|
} else if type == "lightning" || type == "bolt11" || type == "lnurl" {
|
||||||
|
if let url = URL(string: matchText.lowercased().hasPrefix("lightning:") ? matchText : "lightning:\(matchText)") {
|
||||||
|
paymentStyle.link = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AttributedString(matchText).mergingAttributes(paymentStyle)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,12 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Clean up Combine subscriptions
|
||||||
|
cancellables.removeAll()
|
||||||
|
SecureLogger.debug("NetworkActivationService deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
guard !started else { return }
|
guard !started else { return }
|
||||||
started = true
|
started = true
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ final class NostrTransport: Transport {
|
|||||||
private var isSendingReadAcks = false
|
private var isSendingReadAcks = false
|
||||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
private let idBridge: NostrIdentityBridge
|
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
init(keychain: KeychainManagerProtocol) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.idBridge = idBridge
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Transport Protocol Conformance
|
// MARK: - Transport Protocol Conformance
|
||||||
@@ -67,7 +65,7 @@ final class NostrTransport: Transport {
|
|||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
// Convert recipient npub -> hex (x-only)
|
// Convert recipient npub -> hex (x-only)
|
||||||
let recipientHex: String
|
let recipientHex: String
|
||||||
@@ -104,7 +102,7 @@ final class NostrTransport: Transport {
|
|||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
// Convert recipient npub -> hex
|
// Convert recipient npub -> hex
|
||||||
@@ -131,7 +129,7 @@ final class NostrTransport: Transport {
|
|||||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
let recipientHex: String
|
let recipientHex: String
|
||||||
do {
|
do {
|
||||||
@@ -214,7 +212,7 @@ extension NostrTransport {
|
|||||||
let item = readQueue.removeFirst()
|
let item = readQueue.removeFirst()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
||||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||||
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
// Convert recipient npub -> hex
|
// Convert recipient npub -> hex
|
||||||
let recipientHex: String
|
let recipientHex: String
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import SwiftUI
|
|||||||
|
|
||||||
/// Manages all private chat functionality
|
/// Manages all private chat functionality
|
||||||
final class PrivateChatManager: ObservableObject {
|
final class PrivateChatManager: ObservableObject {
|
||||||
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
|
@Published var privateChats: [String: [BitchatMessage]] = [:]
|
||||||
@Published var selectedPeer: PeerID? = nil
|
@Published var selectedPeer: String? = nil
|
||||||
@Published var unreadMessages: Set<PeerID> = []
|
@Published var unreadMessages: Set<String> = []
|
||||||
|
|
||||||
private var selectedPeerFingerprint: String? = nil
|
private var selectedPeerFingerprint: String? = nil
|
||||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||||
@@ -27,15 +27,19 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
SecureLogger.debug("PrivateChatManager deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
// Cap for messages stored per private chat
|
// Cap for messages stored per private chat
|
||||||
private let privateChatCap = TransportConfig.privateChatCap
|
private let privateChatCap = TransportConfig.privateChatCap
|
||||||
|
|
||||||
/// Start a private chat with a peer
|
/// Start a private chat with a peer
|
||||||
func startChat(with peerID: PeerID) {
|
func startChat(with peerID: String) {
|
||||||
selectedPeer = peerID
|
selectedPeer = peerID
|
||||||
|
|
||||||
// Store fingerprint for persistence across reconnections
|
// Store fingerprint for persistence across reconnections
|
||||||
if let fingerprint = meshService?.getFingerprint(for: peerID) {
|
if let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||||
selectedPeerFingerprint = fingerprint
|
selectedPeerFingerprint = fingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +59,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove duplicate messages by ID and keep chronological order
|
/// Remove duplicate messages by ID and keep chronological order
|
||||||
func sanitizeChat(for peerID: PeerID) {
|
func sanitizeChat(for peerID: String) {
|
||||||
guard let arr = privateChats[peerID] else { return }
|
guard let arr = privateChats[peerID] else { return }
|
||||||
if arr.count <= 1 {
|
if arr.count <= 1 {
|
||||||
return
|
return
|
||||||
@@ -79,7 +83,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Mark messages from a peer as read
|
/// Mark messages from a peer as read
|
||||||
func markAsRead(from peerID: PeerID) {
|
func markAsRead(from peerID: String) {
|
||||||
unreadMessages.remove(peerID)
|
unreadMessages.remove(peerID)
|
||||||
|
|
||||||
// Send read receipts for unread messages that haven't been sent yet
|
// Send read receipts for unread messages that haven't been sent yet
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
/// Centralized progress bus for Bluetooth file transfers.
|
|
||||||
/// Emits Combine events consumed by ChatViewModel to update UI progress indicators.
|
|
||||||
final class TransferProgressManager {
|
|
||||||
static let shared = TransferProgressManager()
|
|
||||||
|
|
||||||
enum Event {
|
|
||||||
case started(id: String, totalFragments: Int)
|
|
||||||
case updated(id: String, sentFragments: Int, totalFragments: Int)
|
|
||||||
case completed(id: String, totalFragments: Int)
|
|
||||||
case cancelled(id: String, sentFragments: Int, totalFragments: Int)
|
|
||||||
}
|
|
||||||
|
|
||||||
private let subject = PassthroughSubject<Event, Never>()
|
|
||||||
private let queue = DispatchQueue(label: "com.bitchat.transfer-progress", attributes: .concurrent)
|
|
||||||
private var states: [String: (sent: Int, total: Int)] = [:]
|
|
||||||
|
|
||||||
var publisher: AnyPublisher<Event, Never> {
|
|
||||||
subject.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
func start(id: String, totalFragments: Int) {
|
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.states[id] = (sent: 0, total: totalFragments)
|
|
||||||
self.subject.send(.started(id: id, totalFragments: totalFragments))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordFragmentSent(id: String) {
|
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
|
||||||
guard let self = self, var state = self.states[id] else { return }
|
|
||||||
state.sent = min(state.sent + 1, state.total)
|
|
||||||
self.states[id] = state
|
|
||||||
self.subject.send(.updated(id: id, sentFragments: state.sent, totalFragments: state.total))
|
|
||||||
if state.sent >= state.total {
|
|
||||||
self.states.removeValue(forKey: id)
|
|
||||||
self.subject.send(.completed(id: id, totalFragments: state.total))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancel(id: String) {
|
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
|
||||||
guard let self = self, let state = self.states.removeValue(forKey: id) else { return }
|
|
||||||
self.subject.send(.cancelled(id: id, sentFragments: state.sent, totalFragments: state.total))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset(id: String) {
|
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
|
||||||
self?.states.removeValue(forKey: id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func snapshot(id: String) -> (sent: Int, total: Int)? {
|
|
||||||
var result: (sent: Int, total: Int)?
|
|
||||||
queue.sync {
|
|
||||||
result = states[id]
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -50,9 +50,6 @@ protocol Transport: AnyObject {
|
|||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||||
func sendBroadcastAnnounce()
|
func sendBroadcastAnnounce()
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
|
||||||
func cancelTransfer(_ transferId: String)
|
|
||||||
|
|
||||||
// QR verification (optional for transports)
|
// QR verification (optional for transports)
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
@@ -62,9 +59,6 @@ protocol Transport: AnyObject {
|
|||||||
extension Transport {
|
extension Transport {
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
|
||||||
func cancelTransfer(_ transferId: String) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol TransportPeerEventsDelegate: AnyObject {
|
protocol TransportPeerEventsDelegate: AnyObject {
|
||||||
|
|||||||
@@ -30,11 +30,7 @@ enum TransportConfig {
|
|||||||
static let bleDynamicRSSIThresholdDefault: Int = -90
|
static let bleDynamicRSSIThresholdDefault: Int = -90
|
||||||
static let bleConnectionCandidatesMax: Int = 100
|
static let bleConnectionCandidatesMax: Int = 100
|
||||||
static let blePendingWriteBufferCapBytes: Int = 1_000_000
|
static let blePendingWriteBufferCapBytes: Int = 1_000_000
|
||||||
static let bleNotificationAssemblerHardCapBytes: Int = 8 * 1024 * 1024
|
static let blePendingNotificationsCapCount: Int = 20
|
||||||
static let bleAssemblerStallResetMs: Int = 250
|
|
||||||
static let blePendingNotificationsCapCount: Int = 128
|
|
||||||
static let bleNotificationRetryDelayMs: Int = 25
|
|
||||||
static let bleNotificationRetryMaxAttempts: Int = 80
|
|
||||||
|
|
||||||
// Nostr
|
// Nostr
|
||||||
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
||||||
@@ -45,12 +41,6 @@ enum TransportConfig {
|
|||||||
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
|
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
|
||||||
static let uiProcessedNostrEventsCap: Int = 2000
|
static let uiProcessedNostrEventsCap: Int = 2000
|
||||||
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
|
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
|
||||||
|
|
||||||
// UI rate limiters (token buckets)
|
|
||||||
static let uiSenderRateBucketCapacity: Double = 5
|
|
||||||
static let uiSenderRateBucketRefillPerSec: Double = 1.0
|
|
||||||
static let uiContentRateBucketCapacity: Double = 3
|
|
||||||
static let uiContentRateBucketRefillPerSec: Double = 0.5
|
|
||||||
|
|
||||||
// UI sleeps/delays
|
// UI sleeps/delays
|
||||||
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
||||||
private var fingerprintCache: [PeerID: String] = [:]
|
private var fingerprintCache: [PeerID: String] = [:]
|
||||||
private let meshService: Transport
|
private let meshService: Transport
|
||||||
private let idBridge: NostrIdentityBridge
|
|
||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
weak var messageRouter: MessageRouter?
|
weak var messageRouter: MessageRouter?
|
||||||
private let favoritesService = FavoritesPersistenceService.shared
|
private let favoritesService = FavoritesPersistenceService.shared
|
||||||
@@ -35,13 +34,8 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(
|
init(meshService: Transport, identityManager: SecureIdentityStateManagerProtocol) {
|
||||||
meshService: Transport,
|
|
||||||
idBridge: NostrIdentityBridge,
|
|
||||||
identityManager: SecureIdentityStateManagerProtocol
|
|
||||||
) {
|
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
self.idBridge = idBridge
|
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
|
|
||||||
// Subscribe to changes from both services
|
// Subscribe to changes from both services
|
||||||
@@ -52,7 +46,17 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
updatePeers()
|
updatePeers()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Clean up NotificationCenter observers
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
|
||||||
|
// Clean up Combine subscriptions
|
||||||
|
cancellables.removeAll()
|
||||||
|
|
||||||
|
SecureLogger.debug("UnifiedPeerService deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Setup
|
// MARK: - Setup
|
||||||
|
|
||||||
private func setupSubscriptions() {
|
private func setupSubscriptions() {
|
||||||
@@ -291,7 +295,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
var peerNostrKey = peer.nostrPublicKey
|
var peerNostrKey = peer.nostrPublicKey
|
||||||
if peerNostrKey == nil {
|
if peerNostrKey == nil {
|
||||||
// Try to get from NostrIdentityBridge association
|
// Try to get from NostrIdentityBridge association
|
||||||
peerNostrKey = idBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add favorite
|
// Add favorite
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ final class GossipSyncManager {
|
|||||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||||
var gcsTargetFpr: Double = 0.01 // 1%
|
var gcsTargetFpr: Double = 0.01 // 1%
|
||||||
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
|
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
|
||||||
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
|
||||||
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
|
||||||
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private let myPeerID: PeerID
|
private let myPeerID: PeerID
|
||||||
@@ -30,7 +27,6 @@ final class GossipSyncManager {
|
|||||||
// Timer
|
// Timer
|
||||||
private var periodicTimer: DispatchSourceTimer?
|
private var periodicTimer: DispatchSourceTimer?
|
||||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||||
private var lastStalePeerCleanup: Date = .distantPast
|
|
||||||
|
|
||||||
init(myPeerID: PeerID, config: Config = Config()) {
|
init(myPeerID: PeerID, config: Config = Config()) {
|
||||||
self.myPeerID = myPeerID
|
self.myPeerID = myPeerID
|
||||||
@@ -40,10 +36,10 @@ final class GossipSyncManager {
|
|||||||
func start() {
|
func start() {
|
||||||
stop()
|
stop()
|
||||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||||
let interval = max(0.1, config.maintenanceIntervalSeconds)
|
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
|
||||||
timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1))
|
|
||||||
timer.setEventHandler { [weak self] in
|
timer.setEventHandler { [weak self] in
|
||||||
self?.performPeriodicMaintenance()
|
self?.cleanupExpiredMessages()
|
||||||
|
self?.sendRequestSync()
|
||||||
}
|
}
|
||||||
timer.resume()
|
timer.resume()
|
||||||
periodicTimer = timer
|
periodicTimer = timer
|
||||||
@@ -77,15 +73,6 @@ final class GossipSyncManager {
|
|||||||
return packet.timestamp >= cutoffMs
|
return packet.timestamp >= cutoffMs
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isAnnouncementFresh(_ packet: BitchatPacket) -> Bool {
|
|
||||||
guard config.stalePeerTimeoutSeconds > 0 else { return true }
|
|
||||||
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
||||||
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
|
|
||||||
guard nowMs >= timeoutMs else { return true }
|
|
||||||
let cutoffMs = nowMs - timeoutMs
|
|
||||||
return packet.timestamp >= cutoffMs
|
|
||||||
}
|
|
||||||
|
|
||||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
let mt = MessageType(rawValue: packet.type)
|
let mt = MessageType(rawValue: packet.type)
|
||||||
let isBroadcastRecipient: Bool = {
|
let isBroadcastRecipient: Bool = {
|
||||||
@@ -99,14 +86,6 @@ final class GossipSyncManager {
|
|||||||
// Reject expired packets to prevent ghost peers and old messages
|
// Reject expired packets to prevent ghost peers and old messages
|
||||||
guard isPacketFresh(packet) else { return }
|
guard isPacketFresh(packet) else { return }
|
||||||
|
|
||||||
if isAnnounce {
|
|
||||||
guard isAnnouncementFresh(packet) else {
|
|
||||||
let sender = packet.senderID.hexEncodedString().lowercased()
|
|
||||||
removeState(forNormalizedPeerID: sender)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
|
|
||||||
if isBroadcastMessage {
|
if isBroadcastMessage {
|
||||||
@@ -121,7 +100,7 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if isAnnounce {
|
} else if isAnnounce {
|
||||||
let sender = packet.senderID.hexEncodedString().lowercased()
|
let sender = packet.senderID.hexEncodedString()
|
||||||
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,34 +230,6 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func performPeriodicMaintenance(now: Date = Date()) {
|
|
||||||
cleanupExpiredMessages()
|
|
||||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
|
||||||
sendRequestSync()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
|
||||||
guard now.timeIntervalSince(lastStalePeerCleanup) >= config.stalePeerCleanupIntervalSeconds else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lastStalePeerCleanup = now
|
|
||||||
cleanupStaleAnnouncements(now: now)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cleanupStaleAnnouncements(now: Date) {
|
|
||||||
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
|
|
||||||
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
|
||||||
guard nowMs >= timeoutMs else { return }
|
|
||||||
let cutoff = nowMs - timeoutMs
|
|
||||||
let stalePeerIDs = latestAnnouncementByPeer.compactMap { (peerHex, pair) -> String? in
|
|
||||||
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil
|
|
||||||
}
|
|
||||||
guard !stalePeerIDs.isEmpty else { return }
|
|
||||||
for peerKey in stalePeerIDs {
|
|
||||||
removeState(forNormalizedPeerID: peerKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explicit removal hook for LEAVE/stale peer
|
// Explicit removal hook for LEAVE/stale peer
|
||||||
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
queue.async { [weak self] in
|
queue.async { [weak self] in
|
||||||
@@ -288,11 +239,8 @@ final class GossipSyncManager {
|
|||||||
|
|
||||||
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
let normalizedPeerID = peerID.id.lowercased()
|
let normalizedPeerID = peerID.id.lowercased()
|
||||||
removeState(forNormalizedPeerID: normalizedPeerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeState(forNormalizedPeerID normalizedPeerID: String) {
|
|
||||||
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||||
|
|
||||||
// Remove messages from this peer
|
// Remove messages from this peer
|
||||||
// Collect IDs to remove first to avoid concurrent modification
|
// Collect IDs to remove first to avoid concurrent modification
|
||||||
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
|
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
|
||||||
@@ -306,25 +254,3 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
extension GossipSyncManager {
|
|
||||||
func _performMaintenanceSynchronously(now: Date = Date()) {
|
|
||||||
queue.sync {
|
|
||||||
performPeriodicMaintenance(now: now)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
|
||||||
queue.sync {
|
|
||||||
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func _messageCount(for peerID: PeerID) -> Int {
|
|
||||||
queue.sync {
|
|
||||||
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios.
|
|
||||||
enum FileTransferLimits {
|
|
||||||
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
|
||||||
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
|
||||||
/// Voice notes stay small for low-latency relays.
|
|
||||||
static let maxVoiceNoteBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
|
||||||
/// Compressed images after downscaling should comfortably fit under this budget.
|
|
||||||
static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
|
||||||
|
|
||||||
static func isValidPayload(_ size: Int) -> Bool {
|
|
||||||
size <= maxPayloadBytes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+342
-1739
File diff suppressed because it is too large
Load Diff
@@ -91,7 +91,6 @@ struct TextMessageView: View {
|
|||||||
.environmentObject(
|
.environmentObject(
|
||||||
ChatViewModel(
|
ChatViewModel(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: NostrIdentityBridge(),
|
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
+773
-1302
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,7 @@ struct GeohashPeopleList: View {
|
|||||||
} else {
|
} else {
|
||||||
let myHex: String? = {
|
let myHex: String? = {
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||||
let id = try? viewModel.idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||||
return id.publicKeyHex.lowercased()
|
return id.publicKeyHex.lowercased()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import UIKit
|
|
||||||
private typealias PlatformImage = UIImage
|
|
||||||
#else
|
|
||||||
import AppKit
|
|
||||||
private typealias PlatformImage = NSImage
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct BlockRevealImageView: View {
|
|
||||||
private let url: URL
|
|
||||||
private let revealProgress: Double?
|
|
||||||
private let isSending: Bool
|
|
||||||
private let onCancel: (() -> Void)?
|
|
||||||
private let initiallyBlurred: Bool
|
|
||||||
private let onOpen: (() -> Void)?
|
|
||||||
private let onDelete: (() -> Void)?
|
|
||||||
|
|
||||||
@State private var platformImage: PlatformImage?
|
|
||||||
@State private var aspectRatio: CGFloat = 1
|
|
||||||
@State private var isBlurred: Bool = false
|
|
||||||
|
|
||||||
init(
|
|
||||||
url: URL,
|
|
||||||
revealProgress: Double?,
|
|
||||||
isSending: Bool,
|
|
||||||
onCancel: (() -> Void)?,
|
|
||||||
initiallyBlurred: Bool = false,
|
|
||||||
onOpen: (() -> Void)? = nil,
|
|
||||||
onDelete: (() -> Void)? = nil
|
|
||||||
) {
|
|
||||||
self.url = url
|
|
||||||
self.revealProgress = revealProgress
|
|
||||||
self.isSending = isSending
|
|
||||||
self.onCancel = onCancel
|
|
||||||
self.initiallyBlurred = initiallyBlurred
|
|
||||||
self.onOpen = onOpen
|
|
||||||
self.onDelete = onDelete
|
|
||||||
}
|
|
||||||
|
|
||||||
private var fraction: Double {
|
|
||||||
guard let revealProgress = revealProgress else { return 1 }
|
|
||||||
return max(0, min(1, revealProgress))
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack(alignment: .topTrailing) {
|
|
||||||
if let image = platformImage {
|
|
||||||
Image(platformImage: image)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(aspectRatio, contentMode: .fit)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
||||||
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
|
|
||||||
)
|
|
||||||
.mask(
|
|
||||||
BlockRevealMask(
|
|
||||||
fraction: fraction,
|
|
||||||
columns: 24,
|
|
||||||
rows: 16
|
|
||||||
)
|
|
||||||
.animation(.easeOut(duration: 0.2), value: fraction)
|
|
||||||
)
|
|
||||||
.blur(radius: isBlurred ? 20 : 0)
|
|
||||||
.overlay {
|
|
||||||
if isBlurred {
|
|
||||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
||||||
.fill(Color.black.opacity(0.35))
|
|
||||||
.overlay(
|
|
||||||
Image(systemName: "eye.slash.fill")
|
|
||||||
.font(.bitchatSystem(size: 24, weight: .semibold))
|
|
||||||
.foregroundColor(.white.opacity(0.85))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
||||||
.fill(Color.gray.opacity(0.2))
|
|
||||||
.frame(height: 200)
|
|
||||||
.overlay(
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(.circular)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let onCancel = onCancel, isSending {
|
|
||||||
Button(action: onCancel) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.bitchatSystem(size: 12, weight: .bold))
|
|
||||||
.padding(8)
|
|
||||||
.background(Circle().fill(Color.black.opacity(0.7)))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.padding(8)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
isBlurred = initiallyBlurred
|
|
||||||
loadImage()
|
|
||||||
}
|
|
||||||
.onChange(of: url) { _ in
|
|
||||||
isBlurred = initiallyBlurred
|
|
||||||
loadImage()
|
|
||||||
}
|
|
||||||
.gesture(mainGesture)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadImage() {
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
|
||||||
#if os(iOS)
|
|
||||||
guard let image = UIImage(contentsOfFile: url.path) else { return }
|
|
||||||
#else
|
|
||||||
guard let image = NSImage(contentsOf: url) else { return }
|
|
||||||
#endif
|
|
||||||
let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.platformImage = image
|
|
||||||
self.aspectRatio = ratio
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var mainGesture: some Gesture {
|
|
||||||
let doubleTap = TapGesture(count: 2).onEnded {
|
|
||||||
guard !isSending else { return }
|
|
||||||
onDelete?()
|
|
||||||
}
|
|
||||||
let singleTap = TapGesture().onEnded {
|
|
||||||
guard !isSending else { return }
|
|
||||||
if isBlurred {
|
|
||||||
withAnimation(.easeOut(duration: 0.2)) {
|
|
||||||
isBlurred = false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onOpen?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in
|
|
||||||
guard !isSending else { return }
|
|
||||||
let horizontal = value.translation.width
|
|
||||||
let vertical = value.translation.height
|
|
||||||
guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return }
|
|
||||||
if !isBlurred {
|
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
|
||||||
isBlurred = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return doubleTap.exclusively(before: singleTap).simultaneously(with: swipe)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct BlockRevealMask: Shape {
|
|
||||||
let fraction: Double
|
|
||||||
let columns: Int
|
|
||||||
let rows: Int
|
|
||||||
|
|
||||||
func path(in rect: CGRect) -> Path {
|
|
||||||
var path = Path()
|
|
||||||
guard fraction > 0, columns > 0, rows > 0 else { return path }
|
|
||||||
let totalBlocks = columns * rows
|
|
||||||
let revealCount = max(0, min(totalBlocks, Int(ceil(fraction * Double(totalBlocks)))))
|
|
||||||
guard revealCount > 0 else { return path }
|
|
||||||
let blockWidth = rect.width / CGFloat(columns)
|
|
||||||
let blockHeight = rect.height / CGFloat(rows)
|
|
||||||
var remaining = revealCount
|
|
||||||
for row in 0..<rows {
|
|
||||||
for column in 0..<columns {
|
|
||||||
if remaining <= 0 { return path }
|
|
||||||
let x = CGFloat(column) * blockWidth
|
|
||||||
let y = CGFloat(row) * blockHeight
|
|
||||||
path.addRect(CGRect(x: x, y: y, width: blockWidth, height: blockHeight))
|
|
||||||
remaining -= 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension Image {
|
|
||||||
init(platformImage: PlatformImage) {
|
|
||||||
#if os(iOS)
|
|
||||||
self.init(uiImage: platformImage)
|
|
||||||
#else
|
|
||||||
self.init(nsImage: platformImage)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct FileAttachmentView: View {
|
|
||||||
private let url: URL
|
|
||||||
private let isSending: Bool
|
|
||||||
private let progress: Double?
|
|
||||||
private let onCancel: (() -> Void)?
|
|
||||||
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
|
||||||
#if os(iOS)
|
|
||||||
@State private var showExporter = false
|
|
||||||
#endif
|
|
||||||
|
|
||||||
init(url: URL, isSending: Bool, progress: Double?, onCancel: (() -> Void)?) {
|
|
||||||
self.url = url
|
|
||||||
self.isSending = isSending
|
|
||||||
self.progress = progress
|
|
||||||
self.onCancel = onCancel
|
|
||||||
}
|
|
||||||
|
|
||||||
private var fileName: String {
|
|
||||||
url.lastPathComponent
|
|
||||||
}
|
|
||||||
|
|
||||||
private var normalizedProgress: Double? {
|
|
||||||
guard let progress = progress else { return nil }
|
|
||||||
return max(0, min(1, progress))
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(alignment: .center, spacing: 12) {
|
|
||||||
Image(systemName: "doc.fill")
|
|
||||||
.foregroundColor(Color.blue)
|
|
||||||
.font(.bitchatSystem(size: 24))
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(fileName)
|
|
||||||
.font(.bitchatSystem(size: 14, weight: .medium))
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
.lineLimit(2)
|
|
||||||
Text(url.lastPathComponent)
|
|
||||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
if let progress = normalizedProgress {
|
|
||||||
ProgressView(value: progress)
|
|
||||||
.progressViewStyle(.linear)
|
|
||||||
.tint(Color.blue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: openFile) {
|
|
||||||
Text("open", comment: "Button to open attached file")
|
|
||||||
.font(.bitchatSystem(size: 13, weight: .semibold))
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(
|
|
||||||
Capsule().fill(Color.blue.opacity(0.15))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
if let onCancel = onCancel, isSending {
|
|
||||||
Button(action: onCancel) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.bitchatSystem(size: 11, weight: .bold))
|
|
||||||
.frame(width: 26, height: 26)
|
|
||||||
.background(Circle().fill(Color.red.opacity(0.9)))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 14)
|
|
||||||
.fill(colorScheme == .dark ? Color.black.opacity(0.6) : Color.white)
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 14)
|
|
||||||
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
|
|
||||||
)
|
|
||||||
#if os(iOS)
|
|
||||||
.sheet(isPresented: $showExporter) {
|
|
||||||
FileExportController(url: url)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openFile() {
|
|
||||||
#if os(iOS)
|
|
||||||
showExporter = true
|
|
||||||
#else
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import UniformTypeIdentifiers
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
private struct FileExportController: UIViewControllerRepresentable {
|
|
||||||
let url: URL
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
|
||||||
let controller = UIDocumentPickerViewController(forExporting: [url])
|
|
||||||
controller.shouldShowFileExtensions = true
|
|
||||||
return controller
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
import AppKit
|
|
||||||
#endif
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import AVFoundation
|
|
||||||
|
|
||||||
struct VoiceNoteView: View {
|
|
||||||
private let url: URL
|
|
||||||
private let isSending: Bool
|
|
||||||
private let sendProgress: Double?
|
|
||||||
private let onCancel: (() -> Void)?
|
|
||||||
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
|
||||||
@StateObject private var playback: VoiceNotePlaybackController
|
|
||||||
@State private var waveform: [Float] = []
|
|
||||||
|
|
||||||
init(url: URL, isSending: Bool, sendProgress: Double?, onCancel: (() -> Void)?) {
|
|
||||||
self.url = url
|
|
||||||
self.isSending = isSending
|
|
||||||
self.sendProgress = sendProgress
|
|
||||||
self.onCancel = onCancel
|
|
||||||
_playback = StateObject(wrappedValue: VoiceNotePlaybackController(url: url))
|
|
||||||
}
|
|
||||||
|
|
||||||
private var samples: [Float] {
|
|
||||||
if waveform.isEmpty {
|
|
||||||
return Array(repeating: 0.25, count: 64)
|
|
||||||
}
|
|
||||||
return waveform
|
|
||||||
}
|
|
||||||
|
|
||||||
private var backgroundColor: Color {
|
|
||||||
colorScheme == .dark ? Color.black.opacity(0.6) : Color.white
|
|
||||||
}
|
|
||||||
|
|
||||||
private var borderColor: Color {
|
|
||||||
colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var durationText: String {
|
|
||||||
let duration = playback.duration
|
|
||||||
guard duration.isFinite, duration > 0 else { return "--:--" }
|
|
||||||
let minutes = Int(duration) / 60
|
|
||||||
let seconds = Int(duration) % 60
|
|
||||||
return String(format: "%02d:%02d", minutes, seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var currentText: String {
|
|
||||||
let current = playback.currentTime
|
|
||||||
guard current.isFinite, current > 0 else { return "00:00" }
|
|
||||||
let minutes = Int(current) / 60
|
|
||||||
let seconds = Int(current) % 60
|
|
||||||
return String(format: "%02d:%02d", minutes, seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var playbackLabel: String {
|
|
||||||
playback.isPlaying ? currentText + "/" + durationText : durationText
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button(action: playback.togglePlayback) {
|
|
||||||
Image(systemName: playback.isPlaying ? "pause.fill" : "play.fill")
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
.background(Circle().fill(Color.green))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
WaveformView(
|
|
||||||
samples: samples,
|
|
||||||
playbackProgress: playback.progress,
|
|
||||||
sendProgress: sendProgress,
|
|
||||||
onSeek: { fraction in
|
|
||||||
playback.seek(to: fraction)
|
|
||||||
},
|
|
||||||
isInteractive: playback.isPlaying
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(playbackLabel)
|
|
||||||
.font(.bitchatSystem(size: 13, design: .monospaced))
|
|
||||||
.foregroundColor(Color.secondary)
|
|
||||||
|
|
||||||
if let onCancel = onCancel, isSending {
|
|
||||||
Button(action: onCancel) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.bitchatSystem(size: 12, weight: .bold))
|
|
||||||
.frame(width: 28, height: 28)
|
|
||||||
.background(Circle().fill(Color.red.opacity(0.9)))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 14)
|
|
||||||
.fill(backgroundColor)
|
|
||||||
.shadow(color: Color.black.opacity(colorScheme == .dark ? 0.3 : 0.1), radius: 6, x: 0, y: 2)
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 14)
|
|
||||||
.stroke(borderColor, lineWidth: 1)
|
|
||||||
)
|
|
||||||
.task {
|
|
||||||
// Defer loading to let UI settle after view appears
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
|
|
||||||
playback.loadDuration()
|
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
WaveformCache.shared.waveform(for: url, completion: { bins in
|
|
||||||
waveform = bins
|
|
||||||
continuation.resume()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: url) { newValue in
|
|
||||||
WaveformCache.shared.waveform(for: newValue, completion: { bins in
|
|
||||||
self.waveform = bins
|
|
||||||
})
|
|
||||||
playback.replaceURL(newValue)
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
playback.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct WaveformView: View {
|
|
||||||
let samples: [Float]
|
|
||||||
let playbackProgress: Double
|
|
||||||
let sendProgress: Double?
|
|
||||||
let onSeek: ((Double) -> Void)?
|
|
||||||
let isInteractive: Bool
|
|
||||||
|
|
||||||
private var clampedPlayback: Double {
|
|
||||||
max(0, min(1, playbackProgress))
|
|
||||||
}
|
|
||||||
|
|
||||||
private var clampedSend: Double? {
|
|
||||||
guard let sendProgress = sendProgress else { return nil }
|
|
||||||
return max(0, min(1, sendProgress))
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
GeometryReader { geometry in
|
|
||||||
ZStack {
|
|
||||||
Canvas { context, size in
|
|
||||||
guard !samples.isEmpty else { return }
|
|
||||||
let width = max(size.width, 1)
|
|
||||||
let height = max(size.height, 1)
|
|
||||||
let barWidth = max(width / CGFloat(samples.count), 1)
|
|
||||||
for (index, sample) in samples.enumerated() {
|
|
||||||
let normalized = max(0, min(sample, 1))
|
|
||||||
let barHeight = CGFloat(normalized) * height
|
|
||||||
let originX = CGFloat(index) * barWidth
|
|
||||||
let rect = CGRect(
|
|
||||||
x: originX,
|
|
||||||
y: (height - barHeight) / 2,
|
|
||||||
width: max(barWidth * 0.7, 1),
|
|
||||||
height: barHeight
|
|
||||||
)
|
|
||||||
let binPosition = Double(index) / Double(samples.count)
|
|
||||||
let color: Color
|
|
||||||
if binPosition <= clampedPlayback {
|
|
||||||
color = Color.green
|
|
||||||
} else if let send = clampedSend, binPosition <= send {
|
|
||||||
color = Color.blue
|
|
||||||
} else {
|
|
||||||
color = Color.gray.opacity(0.35)
|
|
||||||
}
|
|
||||||
context.fill(Path(rect), with: .color(color))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
|
||||||
|
|
||||||
if isInteractive, let onSeek = onSeek {
|
|
||||||
Color.clear
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.gesture(
|
|
||||||
DragGesture(minimumDistance: 0)
|
|
||||||
.onEnded { value in
|
|
||||||
guard geometry.size.width > 0 else { return }
|
|
||||||
let fraction = max(0, min(1, value.location.x / geometry.size.width))
|
|
||||||
onSeek(fraction)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(height: 48)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -292,7 +292,7 @@ struct VerificationSheetView: View {
|
|||||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||||
|
|
||||||
private func myQRString() -> String {
|
private func myQRString() -> String {
|
||||||
let npub = try? viewModel.idBridge.getCurrentNostrIdentity()?.npub
|
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||||
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
|
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,7 +373,7 @@ struct VerificationSheetView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Optional: Remove verification for selected peer (if verified)
|
// Optional: Remove verification for selected peer (if verified)
|
||||||
if let pid = viewModel.selectedPrivateChatPeer?.id,
|
if let pid = viewModel.selectedPrivateChatPeer,
|
||||||
let fp = viewModel.getFingerprint(for: pid),
|
let fp = viewModel.getFingerprint(for: pid),
|
||||||
viewModel.verifiedFingerprints.contains(fp) {
|
viewModel.verifiedFingerprints.contains(fp) {
|
||||||
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
||||||
|
|||||||
@@ -10,19 +10,11 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>com.apple.security.device.bluetooth</key>
|
<key>com.apple.security.device.bluetooth</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.device.microphone</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.personal-information.location</key>
|
<key>com.apple.security.personal-information.location</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.server</key>
|
<key>com.apple.security.network.server</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.files.user-selected.read-only</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.files.user-selected.read-write</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.assets.pictures.read-only</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -108,13 +108,6 @@
|
|||||||
"value": "공유된 링크",
|
"value": "공유된 링크",
|
||||||
"comment": "Fallback title when saving a shared link"
|
"comment": "Fallback title when saving a shared link"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"tr": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "paylaşılan bağlantı",
|
|
||||||
"comment": "Fallback title when saving a shared link"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -225,13 +218,6 @@
|
|||||||
"value": "링크를 인코딩하는 데 실패했습니다",
|
"value": "링크를 인코딩하는 데 실패했습니다",
|
||||||
"comment": "Shown when the share payload cannot be encoded"
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"tr": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "bağlantı kodlanamadı",
|
|
||||||
"comment": "Shown when the share payload cannot be encoded"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -342,13 +328,6 @@
|
|||||||
"value": "공유할 수 있는 내용이 없습니다",
|
"value": "공유할 수 있는 내용이 없습니다",
|
||||||
"comment": "Shown when provided content cannot be shared"
|
"comment": "Shown when provided content cannot be shared"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"tr": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "paylaşılabilir içerik yok",
|
|
||||||
"comment": "Shown when provided content cannot be shared"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -459,13 +438,6 @@
|
|||||||
"value": "공유할 내용이 없습니다",
|
"value": "공유할 내용이 없습니다",
|
||||||
"comment": "Shown when the share extension receives no content"
|
"comment": "Shown when the share extension receives no content"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"tr": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "paylaşılacak bir şey yok",
|
|
||||||
"comment": "Shown when the share extension receives no content"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -576,13 +548,6 @@
|
|||||||
"value": "✓ bitchat으로 링크를 공유했습니다",
|
"value": "✓ bitchat으로 링크를 공유했습니다",
|
||||||
"comment": "Confirmation after successfully sharing a link"
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"tr": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "✓ bitchat'e bağlantı paylaşıldı",
|
|
||||||
"comment": "Confirmation after successfully sharing a link"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -693,16 +658,9 @@
|
|||||||
"value": "✓ bitchat으로 텍스트를 공유했습니다",
|
"value": "✓ bitchat으로 텍스트를 공유했습니다",
|
||||||
"comment": "Confirmation after successfully sharing text"
|
"comment": "Confirmation after successfully sharing text"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"tr": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "✓ bitchat'e metin paylaşıldı",
|
|
||||||
"comment": "Confirmation after successfully sharing text"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "1.0"
|
"version": "1.0"
|
||||||
}
|
}
|
||||||
@@ -15,21 +15,15 @@ struct FragmentationTests {
|
|||||||
|
|
||||||
private let mockKeychain: MockKeychain
|
private let mockKeychain: MockKeychain
|
||||||
private let mockIdentityManager: MockIdentityManager
|
private let mockIdentityManager: MockIdentityManager
|
||||||
private let idBridge: NostrIdentityBridge
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
mockKeychain = MockKeychain()
|
mockKeychain = MockKeychain()
|
||||||
mockIdentityManager = MockIdentityManager(mockKeychain)
|
mockIdentityManager = MockIdentityManager(mockKeychain)
|
||||||
idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Reassembly from fragments delivers a public message")
|
@Test("Reassembly from fragments delivers a public message")
|
||||||
func reassemblyFromFragmentsDeliversPublicMessage() async throws {
|
func reassemblyFromFragmentsDeliversPublicMessage() async throws {
|
||||||
let ble = BLEService(
|
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
||||||
keychain: mockKeychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: mockIdentityManager
|
|
||||||
)
|
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
@@ -61,11 +55,7 @@ struct FragmentationTests {
|
|||||||
|
|
||||||
@Test("Duplicate fragment does not break reassembly")
|
@Test("Duplicate fragment does not break reassembly")
|
||||||
func duplicateFragmentDoesNotBreakReassembly() async throws {
|
func duplicateFragmentDoesNotBreakReassembly() async throws {
|
||||||
let ble = BLEService(
|
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
||||||
keychain: mockKeychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: mockIdentityManager
|
|
||||||
)
|
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
@@ -95,11 +85,7 @@ struct FragmentationTests {
|
|||||||
|
|
||||||
@Test("Invalid fragment header is ignored")
|
@Test("Invalid fragment header is ignored")
|
||||||
func invalidFragmentHeaderIsIgnored() async throws {
|
func invalidFragmentHeaderIsIgnored() async throws {
|
||||||
let ble = BLEService(
|
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
||||||
keychain: mockKeychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: mockIdentityManager
|
|
||||||
)
|
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
|
|||||||
@@ -46,90 +46,6 @@ final class GossipSyncManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(lastPacket.type, MessageType.requestSync.rawValue)
|
XCTAssertEqual(lastPacket.type, MessageType.requestSync.rawValue)
|
||||||
XCTAssertNotNil(RequestSyncPacket.decode(from: lastPacket.payload))
|
XCTAssertNotNil(RequestSyncPacket.decode(from: lastPacket.payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testStaleAnnouncementsArePurgedWithMessages() {
|
|
||||||
var config = GossipSyncManager.Config()
|
|
||||||
config.stalePeerCleanupIntervalSeconds = 0
|
|
||||||
config.stalePeerTimeoutSeconds = 5
|
|
||||||
|
|
||||||
let manager = GossipSyncManager(myPeerID: "0102030405060708", config: config)
|
|
||||||
let peerHex = "0011223344556677"
|
|
||||||
let senderData = Data(hexString: peerHex) ?? Data()
|
|
||||||
let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
||||||
|
|
||||||
let announcePacket = BitchatPacket(
|
|
||||||
type: MessageType.announce.rawValue,
|
|
||||||
senderID: senderData,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: initialTimestampMs,
|
|
||||||
payload: Data(),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 1
|
|
||||||
)
|
|
||||||
|
|
||||||
let messagePacket = BitchatPacket(
|
|
||||||
type: MessageType.message.rawValue,
|
|
||||||
senderID: senderData,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: initialTimestampMs,
|
|
||||||
payload: Data([0x01]),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 1
|
|
||||||
)
|
|
||||||
|
|
||||||
manager.onPublicPacketSeen(announcePacket)
|
|
||||||
manager.onPublicPacketSeen(messagePacket)
|
|
||||||
|
|
||||||
// Flush queue without triggering stale cleanup yet
|
|
||||||
manager._performMaintenanceSynchronously(now: Date())
|
|
||||||
XCTAssertTrue(manager._hasAnnouncement(for: PeerID(str: peerHex)))
|
|
||||||
XCTAssertEqual(manager._messageCount(for: PeerID(str: peerHex)), 1)
|
|
||||||
|
|
||||||
// Run cleanup past the timeout
|
|
||||||
let future = Date().addingTimeInterval(config.stalePeerTimeoutSeconds + 1)
|
|
||||||
manager._performMaintenanceSynchronously(now: future)
|
|
||||||
XCTAssertFalse(manager._hasAnnouncement(for: PeerID(str: peerHex)))
|
|
||||||
XCTAssertEqual(manager._messageCount(for: PeerID(str: peerHex)), 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testIgnoresAnnounceOlderThanStaleTimeout() {
|
|
||||||
var config = GossipSyncManager.Config()
|
|
||||||
config.stalePeerTimeoutSeconds = 5
|
|
||||||
config.maxMessageAgeSeconds = 100
|
|
||||||
|
|
||||||
let manager = GossipSyncManager(myPeerID: "0102030405060708", config: config)
|
|
||||||
let peerHex = "8899aabbccddeeff"
|
|
||||||
let senderData = Data(hexString: peerHex) ?? Data()
|
|
||||||
let staleTimestampMs = UInt64(Date().addingTimeInterval(-(config.stalePeerTimeoutSeconds + 1)).timeIntervalSince1970 * 1000)
|
|
||||||
|
|
||||||
let freshMessage = BitchatPacket(
|
|
||||||
type: MessageType.message.rawValue,
|
|
||||||
senderID: senderData,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
||||||
payload: Data([0xAA]),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 1
|
|
||||||
)
|
|
||||||
manager.onPublicPacketSeen(freshMessage)
|
|
||||||
|
|
||||||
let announcePacket = BitchatPacket(
|
|
||||||
type: MessageType.announce.rawValue,
|
|
||||||
senderID: senderData,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: staleTimestampMs,
|
|
||||||
payload: Data(),
|
|
||||||
signature: nil,
|
|
||||||
ttl: 1
|
|
||||||
)
|
|
||||||
|
|
||||||
manager.onPublicPacketSeen(announcePacket)
|
|
||||||
|
|
||||||
manager._performMaintenanceSynchronously()
|
|
||||||
|
|
||||||
XCTAssertFalse(manager._hasAnnouncement(for: PeerID(str: peerHex)))
|
|
||||||
XCTAssertEqual(manager._messageCount(for: PeerID(str: peerHex)), 0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class RecordingDelegate: GossipSyncManager.Delegate {
|
private final class RecordingDelegate: GossipSyncManager.Delegate {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import CryptoKit
|
|||||||
|
|
||||||
final class IntegrationTests: XCTestCase {
|
final class IntegrationTests: XCTestCase {
|
||||||
|
|
||||||
var nodes: [String: MockBLEService] = [:]
|
var nodes: [String: MockBluetoothMeshService] = [:]
|
||||||
var noiseManagers: [String: NoiseSessionManager] = [:]
|
var noiseManagers: [String: NoiseSessionManager] = [:]
|
||||||
private var mockKeychain: MockKeychain!
|
private var mockKeychain: MockKeychain!
|
||||||
|
|
||||||
@@ -588,7 +588,7 @@ final class IntegrationTests: XCTestCase {
|
|||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
private func createNode(_ name: String, peerID: PeerID) {
|
private func createNode(_ name: String, peerID: PeerID) {
|
||||||
let node = MockBLEService()
|
let node = MockBluetoothMeshService()
|
||||||
node.myPeerID = peerID
|
node.myPeerID = peerID
|
||||||
node.mockNickname = name
|
node.mockNickname = name
|
||||||
nodes[name] = node
|
nodes[name] = node
|
||||||
|
|||||||
@@ -37,10 +37,9 @@ final class LocationChannelsTests: XCTestCase {
|
|||||||
|
|
||||||
func testPerGeohashIdentityDeterministic() throws {
|
func testPerGeohashIdentityDeterministic() throws {
|
||||||
// Derive twice for same geohash; should be identical
|
// Derive twice for same geohash; should be identical
|
||||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
|
||||||
let gh = "u4pruy"
|
let gh = "u4pruy"
|
||||||
let id1 = try idBridge.deriveIdentity(forGeohash: gh)
|
let id1 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh)
|
||||||
let id2 = try idBridge.deriveIdentity(forGeohash: gh)
|
let id2 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh)
|
||||||
XCTAssertEqual(id1.publicKeyHex, id2.publicKeyHex)
|
XCTAssertEqual(id1.publicKeyHex, id2.publicKeyHex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,15 +181,7 @@ final class MockBLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
|
||||||
// Tests currently ignore file transfer flows; keep stub for protocol conformance.
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
|
||||||
// Tests currently ignore file transfer flows; keep stub for protocol conformance.
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
id: messageID,
|
id: messageID,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// MockBluetoothMeshService.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreBluetooth
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
// Compatibility wrapper for old tests - please use MockBLEService directly
|
||||||
|
typealias MockBluetoothMeshService = MockBLEService
|
||||||
@@ -44,21 +44,3 @@ final class MockKeychain: KeychainManagerProtocol {
|
|||||||
storage["identity_noiseStaticKey"] != nil
|
storage["identity_noiseStaticKey"] != nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class MockKeychainHelper: KeychainHelperProtocol {
|
|
||||||
private typealias Service = String
|
|
||||||
private typealias Key = String
|
|
||||||
private var storage: [Service: [Key: Data]] = [:]
|
|
||||||
|
|
||||||
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
|
||||||
storage[service]?[key] = data
|
|
||||||
}
|
|
||||||
|
|
||||||
func load(key: String, service: String) -> Data? {
|
|
||||||
storage[service]?[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
func delete(key: String, service: String) {
|
|
||||||
storage[service]?.removeValue(forKey: key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -108,67 +108,4 @@ final class NotificationStreamAssemblerTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
XCTAssertEqual(decoded.timestamp, packet.timestamp)
|
XCTAssertEqual(decoded.timestamp, packet.timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAssemblesCompressedLargeFrame() throws {
|
|
||||||
var assembler = NotificationStreamAssembler()
|
|
||||||
|
|
||||||
// Keep the fixture below FileTransferLimits.maxPayloadBytes so encoding succeeds while still exercising compression.
|
|
||||||
let largeContent = Data(repeating: 0x41, count: 600_000)
|
|
||||||
let filePacket = BitchatFilePacket(
|
|
||||||
fileName: "large.bin",
|
|
||||||
fileSize: UInt64(largeContent.count),
|
|
||||||
mimeType: "application/octet-stream",
|
|
||||||
content: largeContent
|
|
||||||
)
|
|
||||||
guard let tlvPayload = filePacket.encode() else {
|
|
||||||
return XCTFail("Failed to encode file packet")
|
|
||||||
}
|
|
||||||
|
|
||||||
let senderID = Data(repeating: 0xAA, count: BinaryProtocol.senderIDSize)
|
|
||||||
let packet = BitchatPacket(
|
|
||||||
type: MessageType.fileTransfer.rawValue,
|
|
||||||
senderID: senderID,
|
|
||||||
recipientID: nil,
|
|
||||||
timestamp: 0x010203040506,
|
|
||||||
payload: tlvPayload,
|
|
||||||
signature: nil,
|
|
||||||
ttl: 3,
|
|
||||||
version: 2
|
|
||||||
)
|
|
||||||
|
|
||||||
guard let frame = packet.toBinaryData(padding: false) else {
|
|
||||||
return XCTFail("Failed to encode packet frame")
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssertLessThan(BinaryProtocol.Offsets.flags, frame.count)
|
|
||||||
let flags = frame[frame.startIndex + BinaryProtocol.Offsets.flags]
|
|
||||||
XCTAssertNotEqual(flags & BinaryProtocol.Flags.isCompressed, 0, "Frame should be compressed for large payloads")
|
|
||||||
|
|
||||||
let splitIndex = min(4096, frame.count / 2)
|
|
||||||
var result = assembler.append(frame.prefix(splitIndex))
|
|
||||||
XCTAssertTrue(result.frames.isEmpty)
|
|
||||||
|
|
||||||
result = assembler.append(frame.suffix(from: splitIndex))
|
|
||||||
XCTAssertEqual(result.frames.count, 1)
|
|
||||||
XCTAssertTrue(result.droppedPrefixes.isEmpty)
|
|
||||||
XCTAssertFalse(result.reset)
|
|
||||||
|
|
||||||
guard let assembled = result.frames.first else {
|
|
||||||
return XCTFail("Missing assembled frame")
|
|
||||||
}
|
|
||||||
XCTAssertEqual(assembled.count, frame.count)
|
|
||||||
|
|
||||||
guard let decodedPacket = BinaryProtocol.decode(assembled) else {
|
|
||||||
return XCTFail("Failed to decode compressed frame")
|
|
||||||
}
|
|
||||||
XCTAssertEqual(decodedPacket.payload.count, tlvPayload.count)
|
|
||||||
|
|
||||||
guard let decodedFile = BitchatFilePacket.decode(decodedPacket.payload) else {
|
|
||||||
return XCTFail("Failed to decode TLV payload")
|
|
||||||
}
|
|
||||||
XCTAssertEqual(decodedFile.fileName, filePacket.fileName)
|
|
||||||
XCTAssertEqual(decodedFile.mimeType, filePacket.mimeType)
|
|
||||||
XCTAssertEqual(decodedFile.content.count, largeContent.count)
|
|
||||||
XCTAssertEqual(decodedFile.content.prefix(32), largeContent.prefix(32))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,11 +90,7 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The encoded size should be smaller than uncompressed due to compression
|
// The encoded size should be smaller than uncompressed due to compression
|
||||||
guard let headerSize = BinaryProtocol.headerSize(for: packet.version) else {
|
let uncompressedSize = BinaryProtocol.headerSize + BinaryProtocol.senderIDSize + largePayload.count
|
||||||
XCTFail("Invalid version")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let uncompressedSize = headerSize + BinaryProtocol.senderIDSize + largePayload.count
|
|
||||||
XCTAssertLessThan(encodedData.count, uncompressedSize)
|
XCTAssertLessThan(encodedData.count, uncompressedSize)
|
||||||
|
|
||||||
// Decode and verify
|
// Decode and verify
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
final class BitchatFilePacketTests: XCTestCase {
|
|
||||||
|
|
||||||
func testRoundTripPreservesFields() throws {
|
|
||||||
let content = Data((0..<4096).map { UInt8($0 % 251) })
|
|
||||||
let packet = BitchatFilePacket(
|
|
||||||
fileName: "sample.jpg",
|
|
||||||
fileSize: UInt64(content.count),
|
|
||||||
mimeType: "image/jpeg",
|
|
||||||
content: content
|
|
||||||
)
|
|
||||||
|
|
||||||
guard let encoded = packet.encode() else {
|
|
||||||
return XCTFail("Failed to encode file packet")
|
|
||||||
}
|
|
||||||
guard let decoded = BitchatFilePacket.decode(encoded) else {
|
|
||||||
return XCTFail("Failed to decode file packet")
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssertEqual(decoded.fileName, packet.fileName)
|
|
||||||
XCTAssertEqual(decoded.fileSize, packet.fileSize)
|
|
||||||
XCTAssertEqual(decoded.mimeType, packet.mimeType)
|
|
||||||
XCTAssertEqual(decoded.content, packet.content)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDecodeFallsBackToContentSizeWhenFileSizeMissing() throws {
|
|
||||||
let content = Data(repeating: 0x7F, count: 1024)
|
|
||||||
let packet = BitchatFilePacket(
|
|
||||||
fileName: nil,
|
|
||||||
fileSize: nil,
|
|
||||||
mimeType: nil,
|
|
||||||
content: content
|
|
||||||
)
|
|
||||||
|
|
||||||
guard let encoded = packet.encode() else {
|
|
||||||
return XCTFail("Failed to encode file packet")
|
|
||||||
}
|
|
||||||
guard let decoded = BitchatFilePacket.decode(encoded) else {
|
|
||||||
return XCTFail("Failed to decode file packet")
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
|
|
||||||
XCTAssertEqual(decoded.content, content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -276,7 +276,7 @@ private extension SecureLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
cacheQueue.sync {
|
cacheQueue.async(flags: .barrier) {
|
||||||
sanitizationCache.setObject(sanitized as NSString, forKey: key)
|
sanitizationCache.setObject(sanitized as NSString, forKey: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user