mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 05:25:19 +00:00
Compare commits
167
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74cf0d89cc | ||
|
|
b4b6aa5ca6 | ||
|
|
1e5a52f39f | ||
|
|
3fc64f6168 | ||
|
|
9964710de2 | ||
|
|
806c420313 | ||
|
|
194dedac43 | ||
|
|
9af46a9ff8 | ||
|
|
da3fcd5a21 | ||
|
|
b282536080 | ||
|
|
e156356c71 | ||
|
|
81a6e18d04 | ||
|
|
4fbbd24021 | ||
|
|
ec54877140 | ||
|
|
293d627c28 | ||
|
|
0c3f84224c | ||
|
|
7323c0b96c | ||
|
|
7bb835ffc9 | ||
|
|
23d63ab4df | ||
|
|
fa3c74f941 | ||
|
|
9bac52051a | ||
|
|
7b9ffe464a | ||
|
|
7b940485d9 | ||
|
|
5a87ee3e62 | ||
|
|
342eabbc00 | ||
|
|
241ce2d52c | ||
|
|
18b56e7393 | ||
|
|
beb04fc887 | ||
|
|
5fcffefa28 | ||
|
|
46ae039587 | ||
|
|
917f7ebe5f | ||
|
|
b47fb736f4 | ||
|
|
ebb5bb6558 | ||
|
|
7cfdcfe174 | ||
|
|
21e0cbc607 | ||
|
|
1b439a543e | ||
|
|
d30a3b14cf | ||
|
|
d03128612d | ||
|
|
ae294aab6d | ||
|
|
07b0e146f2 | ||
|
|
cc5939cb13 | ||
|
|
cada784844 | ||
|
|
b1aeb931bc | ||
|
|
b675738664 | ||
|
|
4091a30f10 | ||
|
|
238311aefb | ||
|
|
6defae71c6 | ||
|
|
b533d9560d | ||
|
|
f41a390a94 | ||
|
|
f83316bd1f | ||
|
|
09818a02ed | ||
|
|
9cd955ae2f | ||
|
|
49b1413d85 | ||
|
|
31be6b83a7 | ||
|
|
1563209797 | ||
|
|
99896bcded | ||
|
|
4b3077169a | ||
|
|
b84c36c6fa | ||
|
|
3eac5858e4 | ||
|
|
10b7c1fd80 | ||
|
|
bf3249aef7 | ||
|
|
95a6ec7315 | ||
|
|
74864472c7 | ||
|
|
9404c03477 | ||
|
|
6faa46a22f | ||
|
|
e02f4327c0 | ||
|
|
ce31d85323 | ||
|
|
b6d8a5b758 | ||
|
|
6630f5a792 | ||
|
|
0f5299a0f5 | ||
|
|
eb3bbfd861 | ||
|
|
a37243e780 | ||
|
|
90b134186b | ||
|
|
3c7e14f49d | ||
|
|
31275856dd | ||
|
|
b6cf44a824 | ||
|
|
b6ae08be60 | ||
|
|
d469704c34 | ||
|
|
c975abf2ff | ||
|
|
aff700a15e | ||
|
|
869d766f8d | ||
|
|
1b4f120014 | ||
|
|
bc312e4aef | ||
|
|
6e7509f2be | ||
|
|
ca06f4d51d | ||
|
|
04f57e8713 | ||
|
|
59c3c4e236 | ||
|
|
e887e04f40 | ||
|
|
151b68a497 | ||
|
|
b4a3ee5777 | ||
|
|
8d19d6d62c | ||
|
|
84fd92ef4b | ||
|
|
f71bd506fd | ||
|
|
ddd7ef5668 | ||
|
|
548a20e77d | ||
|
|
6e231d10c5 | ||
|
|
4c9f6e689e | ||
|
|
7e73b65240 | ||
|
|
f5e5f7b98e | ||
|
|
b15d92ebb5 | ||
|
|
6efe9d02fb | ||
|
|
5a66f03400 | ||
|
|
a221b22691 | ||
|
|
4b38e9a23c | ||
|
|
2686d9c82c | ||
|
|
832e1f006c | ||
|
|
090c4cd919 | ||
|
|
37c2b3a8c2 | ||
|
|
6f1c879237 | ||
|
|
81f5101390 | ||
|
|
d56674706d | ||
|
|
33bcfa3147 | ||
|
|
3a54160793 | ||
|
|
5d2745eae6 | ||
|
|
5138ce4a33 | ||
|
|
07a4997192 | ||
|
|
2d703f4dd9 | ||
|
|
56b9457fcf | ||
|
|
9e57bce5c3 | ||
|
|
6eef030386 | ||
|
|
9d8754099d | ||
|
|
7fae4e71fb | ||
|
|
76d8b3fa7f | ||
|
|
b8a8b940b7 | ||
|
|
7a5ab52f4c | ||
|
|
d2edb651c1 | ||
|
|
858e878959 | ||
|
|
fa0a15cbcf | ||
|
|
3e680f40bf | ||
|
|
ec3c16176e | ||
|
|
2a8de7e048 | ||
|
|
e9dd30ccbf | ||
|
|
af28faa91d | ||
|
|
b33fe8086d | ||
|
|
5495874b5a | ||
|
|
0fca551966 | ||
|
|
0492480598 | ||
|
|
86f46c8b90 | ||
|
|
7057fe75b0 | ||
|
|
873788b24f | ||
|
|
b6dd31b285 | ||
|
|
ff7e5a2c2c | ||
|
|
708a5e33cd | ||
|
|
f893f0605a | ||
|
|
ce33132a0f | ||
|
|
c538837300 | ||
|
|
6888bfa351 | ||
|
|
2fed4b7c31 | ||
|
|
b956e407ff | ||
|
|
477cc7fa05 | ||
|
|
dffce9d63f | ||
|
|
792eaa3166 | ||
|
|
6ff9b59ff2 | ||
|
|
bc04cd0bde | ||
|
|
3fe417395e | ||
|
|
e35bd57c6e | ||
|
|
5aefb05a8b | ||
|
|
e4cbf382ce | ||
|
|
7609c6eeba | ||
|
|
f37acf7e4b | ||
|
|
cf528b0daf | ||
|
|
209ccb4ade | ||
|
|
1876e85f8b | ||
|
|
109b7d0e03 | ||
|
|
09a319fb0f | ||
|
|
97ca55cc54 | ||
|
|
394836f247 |
@@ -25,47 +25,18 @@ jobs:
|
||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||
|
||||
- name: Configure git
|
||||
- name: Check for changes
|
||||
id: git-check
|
||||
run: |
|
||||
git config user.email "action@github.com"
|
||||
git config user.name "GitHub Action"
|
||||
|
||||
- name: Create update branch if changes
|
||||
id: create_branch
|
||||
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit and push changes
|
||||
if: steps.git-check.outputs.changes == 'true'
|
||||
run: |
|
||||
# exit early if no changes
|
||||
if git diff --quiet --relays/online_relays_gps.csv; then
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# branch name with timestamp
|
||||
BRANCH="update-georelays-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
git checkout -b "$BRANCH"
|
||||
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add relays/online_relays_gps.csv
|
||||
git commit -m "Automated update of relay data - $(date -u --rfc-3339=seconds)"
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Push branch
|
||||
if: steps.create_branch.outputs.changed == 'true'
|
||||
run: |
|
||||
git push --set-upstream origin "${{ steps.create_branch.outputs.branch }}"
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.create_branch.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: Automated update of relay data
|
||||
branch: ${{ steps.create_branch.outputs.branch }}
|
||||
base: main
|
||||
title: Automated update of relay data
|
||||
body: |
|
||||
This PR was created automatically by the scheduled workflow. It updates relays/online_relays_gps.csv from the GeoRelays source.
|
||||
labels: automated, georelays
|
||||
|
||||
- name: No changes
|
||||
if: steps.create_branch.outputs.changed != 'true'
|
||||
run: echo "No changes to relays/online_relays_gps.csv"
|
||||
git commit -m "Automated update of relay data - $(date -u)"
|
||||
git push
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.5.0
|
||||
MARKETING_VERSION = 1.5.1
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
@@ -47,7 +47,7 @@ patch-for-macos: backup
|
||||
# Build the macOS app
|
||||
build: #check generate
|
||||
@echo "Building BitChat for macOS..."
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
|
||||
# Run the macOS app
|
||||
run: build
|
||||
|
||||
+6
-4
@@ -16,7 +16,7 @@ let package = Package(
|
||||
),
|
||||
],
|
||||
dependencies:[
|
||||
.package(path: "localPackages/Tor"),
|
||||
.package(path: "localPackages/Arti"),
|
||||
.package(path: "localPackages/BitLogger"),
|
||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||
],
|
||||
@@ -26,7 +26,7 @@ let package = Package(
|
||||
dependencies: [
|
||||
.product(name: "P256K", package: "swift-secp256k1"),
|
||||
.product(name: "BitLogger", package: "BitLogger"),
|
||||
.product(name: "Tor", package: "Tor")
|
||||
.product(name: "Tor", package: "Arti")
|
||||
],
|
||||
path: "bitchat",
|
||||
exclude: [
|
||||
@@ -34,7 +34,8 @@ let package = Package(
|
||||
"Assets.xcassets",
|
||||
"bitchat.entitlements",
|
||||
"bitchat-macOS.entitlements",
|
||||
"LaunchScreen.storyboard"
|
||||
"LaunchScreen.storyboard",
|
||||
"ViewModels/Extensions/README.md"
|
||||
],
|
||||
resources: [
|
||||
.process("Localizable.xcstrings")
|
||||
@@ -49,7 +50,8 @@ let package = Package(
|
||||
"README.md"
|
||||
],
|
||||
resources: [
|
||||
.process("Localization")
|
||||
.process("Localization"),
|
||||
.process("Noise")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -8,9 +8,6 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
|
||||
|
||||
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
||||
|
||||
> [!WARNING]
|
||||
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||
|
||||
## License
|
||||
|
||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
Generated
+39
-7
@@ -14,7 +14,6 @@
|
||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
|
||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
|
||||
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */; };
|
||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||
/* End PBXBuildFile section */
|
||||
@@ -95,6 +94,24 @@
|
||||
);
|
||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||
};
|
||||
C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
Localization/PrimaryLocalizationKeys.json,
|
||||
README.md,
|
||||
);
|
||||
target = 47FF23248747DD7CB666CB91 /* bitchatTests_macOS */;
|
||||
};
|
||||
C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
Localization/PrimaryLocalizationKeys.json,
|
||||
README.md,
|
||||
);
|
||||
target = 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -118,6 +135,10 @@
|
||||
};
|
||||
A6E32D412E762EAE0032EA8A /* bitchatTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */,
|
||||
C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */,
|
||||
);
|
||||
path = bitchatTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -140,7 +161,6 @@
|
||||
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
files = (
|
||||
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
|
||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||
@@ -213,6 +233,7 @@
|
||||
buildConfigurationList = 1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget "bitchatTests_macOS" */;
|
||||
buildPhases = (
|
||||
5C22AA7B9ACC5A861445C769 /* Sources */,
|
||||
C5E027A42ECCDFD700BD6012 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -245,6 +266,7 @@
|
||||
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
|
||||
buildPhases = (
|
||||
865C8403EF02C089369A9FCB /* Sources */,
|
||||
C5E027A72ECCDFE200BD6012 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -321,7 +343,7 @@
|
||||
packageReferences = (
|
||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 90;
|
||||
projectDirPath = "";
|
||||
@@ -343,6 +365,16 @@
|
||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
|
||||
);
|
||||
};
|
||||
C5E027A42ECCDFD700BD6012 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
files = (
|
||||
);
|
||||
};
|
||||
C5E027A72ECCDFE200BD6012 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
files = (
|
||||
);
|
||||
};
|
||||
CD6E8F32BC38357473954F97 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
files = (
|
||||
@@ -879,9 +911,9 @@
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/BitLogger;
|
||||
};
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/Tor;
|
||||
relativePath = localPackages/Arti;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
@@ -890,8 +922,8 @@
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.21.1;
|
||||
kind = exactVersion;
|
||||
version = 0.21.1;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
@@ -53,15 +53,20 @@ struct BitchatApp: App {
|
||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||
let nickname = chatViewModel.nickname
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
||||
}
|
||||
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
|
||||
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
||||
NetworkActivationService.shared.start()
|
||||
|
||||
// Start presence service (will wait for Tor readiness)
|
||||
GeohashPresenceService.shared.start()
|
||||
|
||||
// Check for shared content
|
||||
checkForSharedContent()
|
||||
}
|
||||
@@ -248,10 +253,15 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
// Get peer ID from userInfo
|
||||
if let peerID = userInfo["peerID"] as? String {
|
||||
// Don't show notification if the private chat is already open
|
||||
if chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
|
||||
completionHandler([])
|
||||
return
|
||||
// Access main-actor-isolated property via Task
|
||||
Task { @MainActor in
|
||||
if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
|
||||
completionHandler([])
|
||||
} else {
|
||||
completionHandler([.banner, .sound])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
// Suppress geohash activity notification if we're already in that geohash channel
|
||||
@@ -269,8 +279,3 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var nilIfEmpty: String? {
|
||||
self.isEmpty ? nil : self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,11 +58,20 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
guard session.recordPermission == .granted else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#if targetEnvironment(simulator)
|
||||
// allowBluetoothHFP is not available on iOS Simulator
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
||||
)
|
||||
#else
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||
)
|
||||
#endif
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
|
||||
@@ -22,8 +22,9 @@ struct BitchatPacket: Codable {
|
||||
var signature: Data?
|
||||
var ttl: UInt8
|
||||
var route: [Data]?
|
||||
var isRSR: Bool
|
||||
|
||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil) {
|
||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {
|
||||
self.version = version
|
||||
self.type = type
|
||||
self.senderID = senderID
|
||||
@@ -33,10 +34,11 @@ struct BitchatPacket: Codable {
|
||||
self.signature = signature
|
||||
self.ttl = ttl
|
||||
self.route = route
|
||||
self.isRSR = isRSR
|
||||
}
|
||||
|
||||
// Convenience initializer for new binary format
|
||||
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
|
||||
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data, isRSR: Bool = false) {
|
||||
self.version = 1
|
||||
self.type = type
|
||||
// Convert hex string peer ID to binary data (8 bytes)
|
||||
@@ -56,6 +58,7 @@ struct BitchatPacket: Codable {
|
||||
self.signature = nil
|
||||
self.ttl = ttl
|
||||
self.route = nil
|
||||
self.isRSR = isRSR
|
||||
}
|
||||
|
||||
var data: Data? {
|
||||
@@ -85,7 +88,8 @@ struct BitchatPacket: Codable {
|
||||
signature: nil, // Remove signature for signing
|
||||
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||
version: version,
|
||||
route: route
|
||||
route: route,
|
||||
isRSR: false // RSR flag is mutable and not part of the signature
|
||||
)
|
||||
return BinaryProtocol.encode(unsignedPacket)
|
||||
}
|
||||
|
||||
@@ -9,12 +9,16 @@ struct RequestSyncPacket {
|
||||
let m: UInt32
|
||||
let data: Data
|
||||
let types: SyncTypeFlags?
|
||||
let sinceTimestamp: UInt64?
|
||||
let fragmentIdFilter: String?
|
||||
|
||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil) {
|
||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
||||
self.p = p
|
||||
self.m = m
|
||||
self.data = data
|
||||
self.types = types
|
||||
self.sinceTimestamp = sinceTimestamp
|
||||
self.fragmentIdFilter = fragmentIdFilter
|
||||
}
|
||||
|
||||
func encode() -> Data {
|
||||
@@ -36,15 +40,24 @@ struct RequestSyncPacket {
|
||||
if let typesData = types?.toData() {
|
||||
putTLV(0x04, typesData)
|
||||
}
|
||||
if let ts = sinceTimestamp {
|
||||
var tsBE = ts.bigEndian
|
||||
putTLV(0x05, withUnsafeBytes(of: &tsBE) { Data($0) })
|
||||
}
|
||||
if let fid = fragmentIdFilter, let fidData = fid.data(using: .utf8) {
|
||||
putTLV(0x06, fidData)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
|
||||
var off = 0
|
||||
var p: Int? = nil
|
||||
var m: UInt32? = nil
|
||||
var payload: Data? = nil
|
||||
var types: SyncTypeFlags? = nil
|
||||
var sinceTimestamp: UInt64? = nil
|
||||
var fragmentIdFilter: String? = nil
|
||||
|
||||
while off + 3 <= data.count {
|
||||
let t = Int(data[off]); off += 1
|
||||
@@ -68,12 +81,22 @@ struct RequestSyncPacket {
|
||||
if let decoded = SyncTypeFlags.decode(v) {
|
||||
types = decoded
|
||||
}
|
||||
case 0x05:
|
||||
if v.count == 8 {
|
||||
var ts: UInt64 = 0
|
||||
for b in v { ts = (ts << 8) | UInt64(b) }
|
||||
sinceTimestamp = ts
|
||||
}
|
||||
case 0x06:
|
||||
if let fid = String(data: v, encoding: .utf8) {
|
||||
fragmentIdFilter = fid
|
||||
}
|
||||
default:
|
||||
break // forward compatible; ignore unknown TLVs
|
||||
}
|
||||
}
|
||||
|
||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
|
||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,19 +165,23 @@ final class NoiseCipherState {
|
||||
// MARK: - Sliding Window Replay Protection
|
||||
|
||||
/// Check if nonce is valid for replay protection
|
||||
/// BCH-01-010: Use safe arithmetic to prevent integer overflow
|
||||
private func isValidNonce(_ receivedNonce: UInt64) -> Bool {
|
||||
if receivedNonce + UInt64(Self.REPLAY_WINDOW_SIZE) <= highestReceivedNonce {
|
||||
// Safe overflow check: instead of (receivedNonce + WINDOW_SIZE <= highest)
|
||||
// use (highest >= WINDOW_SIZE && receivedNonce <= highest - WINDOW_SIZE)
|
||||
let windowSize = UInt64(Self.REPLAY_WINDOW_SIZE)
|
||||
if highestReceivedNonce >= windowSize && receivedNonce <= highestReceivedNonce - windowSize {
|
||||
return false // Too old, outside window
|
||||
}
|
||||
|
||||
|
||||
if receivedNonce > highestReceivedNonce {
|
||||
return true // Always accept newer nonces
|
||||
}
|
||||
|
||||
|
||||
let offset = Int(highestReceivedNonce - receivedNonce)
|
||||
let byteIndex = offset / 8
|
||||
let bitIndex = offset % 8
|
||||
|
||||
|
||||
return (replayWindow[byteIndex] & (1 << bitIndex)) == 0 // Not yet seen
|
||||
}
|
||||
|
||||
@@ -222,7 +226,7 @@ final class NoiseCipherState {
|
||||
guard combinedPayload.count >= Self.NONCE_SIZE_BYTES else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Extract 4-byte nonce (big-endian)
|
||||
let nonceData = combinedPayload.prefix(Self.NONCE_SIZE_BYTES)
|
||||
let extractedNonce = nonceData.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> UInt64 in
|
||||
@@ -233,18 +237,18 @@ final class NoiseCipherState {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// Extract ciphertext (remaining bytes)
|
||||
let ciphertext = combinedPayload.dropFirst(Self.NONCE_SIZE_BYTES)
|
||||
|
||||
|
||||
return (nonce: extractedNonce, ciphertext: Data(ciphertext))
|
||||
}
|
||||
|
||||
|
||||
/// Convert nonce to 4-byte array (big-endian)
|
||||
private func nonceToBytes(_ nonce: UInt64) -> Data {
|
||||
var bytes = Data(count: Self.NONCE_SIZE_BYTES)
|
||||
withUnsafeBytes(of: nonce.bigEndian) { ptr in
|
||||
// Copy only the last 4 bytes from the 8-byte UInt64
|
||||
// Copy only the last 4 bytes from the 8-byte UInt64
|
||||
let sourceBytes = ptr.bindMemory(to: UInt8.self)
|
||||
bytes.replaceSubrange(0..<Self.NONCE_SIZE_BYTES, with: sourceBytes.suffix(Self.NONCE_SIZE_BYTES))
|
||||
}
|
||||
@@ -273,7 +277,7 @@ final class NoiseCipherState {
|
||||
let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData)
|
||||
// increment local nonce
|
||||
nonce += 1
|
||||
|
||||
|
||||
// Create combined payload: <nonce><ciphertext>
|
||||
let combinedPayload: Data
|
||||
if (useExtractedNonce) {
|
||||
@@ -287,7 +291,7 @@ final class NoiseCipherState {
|
||||
if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
|
||||
SecureLogger.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption)
|
||||
}
|
||||
|
||||
|
||||
return combinedPayload
|
||||
}
|
||||
|
||||
@@ -316,7 +320,7 @@ final class NoiseCipherState {
|
||||
SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected")
|
||||
throw NoiseError.replayDetected
|
||||
}
|
||||
|
||||
|
||||
// Split ciphertext and tag
|
||||
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
||||
tag = actualCiphertext.suffix(16)
|
||||
@@ -347,16 +351,20 @@ final class NoiseCipherState {
|
||||
|
||||
do {
|
||||
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
|
||||
|
||||
|
||||
// BCH-01-010: Atomic nonce state update
|
||||
// Both replay window marking and nonce increment must complete together
|
||||
// to prevent state desynchronization. We perform both after successful
|
||||
// decryption only, ensuring state consistency on any failure path.
|
||||
if useExtractedNonce {
|
||||
// Mark nonce as seen after successful decryption
|
||||
markNonceAsSeen(decryptionNonce)
|
||||
}
|
||||
nonce += 1
|
||||
|
||||
return plaintext
|
||||
} catch {
|
||||
// Decryption failed - nonce state remains unchanged (atomic rollback)
|
||||
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
||||
// Log authentication failures with nonce info
|
||||
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
|
||||
throw error
|
||||
}
|
||||
@@ -451,17 +459,40 @@ final class NoiseSymmetricState {
|
||||
}
|
||||
}
|
||||
|
||||
func split() -> (NoiseCipherState, NoiseCipherState) {
|
||||
func split(useExtractedNonce: Bool) -> (NoiseCipherState, NoiseCipherState) {
|
||||
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
|
||||
let tempKey1 = SymmetricKey(data: output[0])
|
||||
let tempKey2 = SymmetricKey(data: output[1])
|
||||
|
||||
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: true)
|
||||
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: true)
|
||||
|
||||
|
||||
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
|
||||
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
|
||||
|
||||
// BCH-01-010: Clear symmetric state after split per Noise spec
|
||||
// The chaining key and hash should not be retained after handshake completes
|
||||
clearSensitiveData()
|
||||
|
||||
return (c1, c2)
|
||||
}
|
||||
|
||||
|
||||
/// BCH-01-010: Securely clear sensitive cryptographic state
|
||||
/// Called after split() to clear chaining key and hash per Noise spec
|
||||
func clearSensitiveData() {
|
||||
// Clear chaining key by overwriting with zeros
|
||||
let chainingKeyCount = chainingKey.count
|
||||
chainingKey = Data(repeating: 0, count: chainingKeyCount)
|
||||
|
||||
// Clear hash by overwriting with zeros
|
||||
let hashCount = hash.count
|
||||
hash = Data(repeating: 0, count: hashCount)
|
||||
|
||||
// Clear the internal cipher state
|
||||
cipherState.clearSensitiveData()
|
||||
}
|
||||
|
||||
deinit {
|
||||
clearSensitiveData()
|
||||
}
|
||||
|
||||
// HKDF implementation
|
||||
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
|
||||
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
|
||||
@@ -507,16 +538,24 @@ final class NoiseHandshakeState {
|
||||
private var messagePatterns: [[NoiseMessagePattern]] = []
|
||||
private var currentPattern = 0
|
||||
|
||||
// Test support: predetermined ephemeral keys for test vectors
|
||||
private var predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey?
|
||||
private var prologueData: Data
|
||||
|
||||
init(
|
||||
role: NoiseRole,
|
||||
pattern: NoisePattern,
|
||||
keychain: KeychainManagerProtocol,
|
||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
|
||||
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
|
||||
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil,
|
||||
prologue: Data = Data(),
|
||||
predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey? = nil
|
||||
) {
|
||||
self.role = role
|
||||
self.pattern = pattern
|
||||
self.keychain = keychain
|
||||
self.prologueData = prologue
|
||||
self.predeterminedEphemeralKey = predeterminedEphemeralKey
|
||||
|
||||
// Initialize static keys
|
||||
if let localKey = localStaticKey {
|
||||
@@ -537,8 +576,8 @@ final class NoiseHandshakeState {
|
||||
}
|
||||
|
||||
private func mixPreMessageKeys() {
|
||||
// Mix prologue (empty for XX pattern normally)
|
||||
symmetricState.mixHash(Data()) // Empty prologue for XX pattern
|
||||
// Mix prologue
|
||||
symmetricState.mixHash(self.prologueData)
|
||||
// For XX pattern, no pre-message keys
|
||||
// For IK/NK patterns, we'd mix the responder's static key here
|
||||
switch pattern {
|
||||
@@ -556,15 +595,20 @@ final class NoiseHandshakeState {
|
||||
guard currentPattern < messagePatterns.count else {
|
||||
throw NoiseError.handshakeComplete
|
||||
}
|
||||
|
||||
|
||||
var messageBuffer = Data()
|
||||
let patterns = messagePatterns[currentPattern]
|
||||
|
||||
for pattern in patterns {
|
||||
switch pattern {
|
||||
case .e:
|
||||
// Generate ephemeral key
|
||||
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
|
||||
// Generate ephemeral key (or use predetermined key for tests)
|
||||
if let predetermined = predeterminedEphemeralKey {
|
||||
localEphemeralPrivate = predetermined
|
||||
predeterminedEphemeralKey = nil
|
||||
} else {
|
||||
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
|
||||
}
|
||||
localEphemeralPublic = localEphemeralPrivate!.publicKey
|
||||
messageBuffer.append(localEphemeralPublic!.rawRepresentation)
|
||||
symmetricState.mixHash(localEphemeralPublic!.rawRepresentation)
|
||||
@@ -597,14 +641,20 @@ final class NoiseHandshakeState {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
} else {
|
||||
guard let localStatic = localStaticPrivate,
|
||||
let remoteEphemeral = remoteEphemeralPublic else {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
}
|
||||
|
||||
case .se:
|
||||
@@ -615,14 +665,20 @@ final class NoiseHandshakeState {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
} else {
|
||||
guard let localEphemeral = localEphemeralPrivate,
|
||||
let remoteStatic = remoteStaticPublic else {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
}
|
||||
|
||||
case .ss:
|
||||
@@ -652,7 +708,7 @@ final class NoiseHandshakeState {
|
||||
guard currentPattern < messagePatterns.count else {
|
||||
throw NoiseError.handshakeComplete
|
||||
}
|
||||
|
||||
|
||||
var buffer = message
|
||||
let patterns = messagePatterns[currentPattern]
|
||||
|
||||
@@ -711,8 +767,11 @@ final class NoiseHandshakeState {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
|
||||
case .es:
|
||||
if role == .initiator {
|
||||
guard let localEphemeral = localEphemeralPrivate,
|
||||
@@ -765,8 +824,11 @@ final class NoiseHandshakeState {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
|
||||
case .e, .s:
|
||||
break
|
||||
}
|
||||
@@ -776,16 +838,20 @@ final class NoiseHandshakeState {
|
||||
return currentPattern >= messagePatterns.count
|
||||
}
|
||||
|
||||
func getTransportCiphers() throws -> (send: NoiseCipherState, receive: NoiseCipherState) {
|
||||
func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState, handshakeHash: Data) {
|
||||
guard isHandshakeComplete() else {
|
||||
throw NoiseError.handshakeNotComplete
|
||||
}
|
||||
|
||||
let (c1, c2) = symmetricState.split()
|
||||
|
||||
|
||||
// BCH-01-010: Capture handshake hash BEFORE split() clears symmetric state
|
||||
let finalHandshakeHash = symmetricState.getHandshakeHash()
|
||||
|
||||
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
|
||||
|
||||
// Initiator uses c1 for sending, c2 for receiving
|
||||
// Responder uses c2 for sending, c1 for receiving
|
||||
return role == .initiator ? (c1, c2) : (c2, c1)
|
||||
let ciphers = role == .initiator ? (c1, c2) : (c2, c1)
|
||||
return (send: ciphers.0, receive: ciphers.1, handshakeHash: finalHandshakeHash)
|
||||
}
|
||||
|
||||
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
|
||||
@@ -846,22 +912,47 @@ enum NoiseError: Error {
|
||||
case nonceExceeded
|
||||
}
|
||||
|
||||
// MARK: - Constant-Time Operations
|
||||
|
||||
/// BCH-01-010: Constant-time comparison to prevent timing side-channel attacks
|
||||
/// This function compares two Data objects in constant time, preventing
|
||||
/// information leakage via timing analysis.
|
||||
private func constantTimeCompare(_ a: Data, _ b: Data) -> Bool {
|
||||
guard a.count == b.count else { return false }
|
||||
|
||||
var result: UInt8 = 0
|
||||
for i in 0..<a.count {
|
||||
result |= a[a.startIndex.advanced(by: i)] ^ b[b.startIndex.advanced(by: i)]
|
||||
}
|
||||
return result == 0
|
||||
}
|
||||
|
||||
/// BCH-01-010: Constant-time check if all bytes are zero
|
||||
private func constantTimeIsZero(_ data: Data) -> Bool {
|
||||
var result: UInt8 = 0
|
||||
for byte in data {
|
||||
result |= byte
|
||||
}
|
||||
return result == 0
|
||||
}
|
||||
|
||||
// MARK: - Key Validation
|
||||
|
||||
extension NoiseHandshakeState {
|
||||
/// Validate a Curve25519 public key
|
||||
/// Checks for weak/invalid keys that could compromise security
|
||||
/// BCH-01-010: Uses constant-time operations to prevent timing side-channels
|
||||
static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
||||
// Check key length
|
||||
guard keyData.count == 32 else {
|
||||
throw NoiseError.invalidPublicKey
|
||||
}
|
||||
|
||||
// Check for all-zero key (point at infinity)
|
||||
if keyData.allSatisfy({ $0 == 0 }) {
|
||||
|
||||
// BCH-01-010: Constant-time check for all-zero key (point at infinity)
|
||||
if constantTimeIsZero(keyData) {
|
||||
throw NoiseError.invalidPublicKey
|
||||
}
|
||||
|
||||
|
||||
// Check for low-order points that could enable small subgroup attacks
|
||||
// These are the known bad points for Curve25519
|
||||
let lowOrderPoints: [Data] = [
|
||||
@@ -882,13 +973,21 @@ extension NoiseHandshakeState {
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
|
||||
]
|
||||
|
||||
// Check against known bad points
|
||||
if lowOrderPoints.contains(keyData) {
|
||||
|
||||
// BCH-01-010: Constant-time check against known bad points
|
||||
// We check all points and accumulate matches to avoid early exit timing leaks
|
||||
var foundBadPoint = false
|
||||
for badPoint in lowOrderPoints {
|
||||
if constantTimeCompare(keyData, badPoint) {
|
||||
foundBadPoint = true
|
||||
}
|
||||
}
|
||||
|
||||
if foundBadPoint {
|
||||
SecureLogger.warning("Low-order point detected", category: .security)
|
||||
throw NoiseError.invalidPublicKey
|
||||
}
|
||||
|
||||
|
||||
// Try to create the key - CryptoKit will validate curve points internally
|
||||
do {
|
||||
let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData)
|
||||
|
||||
@@ -102,23 +102,23 @@ class NoiseSession {
|
||||
|
||||
// Check if handshake is complete
|
||||
if handshake.isHandshakeComplete() {
|
||||
// Get transport ciphers
|
||||
let (send, receive) = try handshake.getTransportCiphers()
|
||||
// Get transport ciphers and handshake hash (hash captured before split clears state)
|
||||
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
||||
sendCipher = send
|
||||
receiveCipher = receive
|
||||
|
||||
|
||||
// Store remote static key
|
||||
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
||||
|
||||
|
||||
// Store handshake hash for channel binding
|
||||
handshakeHash = handshake.getHandshakeHash()
|
||||
|
||||
handshakeHash = hash
|
||||
|
||||
state = .established
|
||||
handshakeState = nil // Clear handshake state
|
||||
|
||||
|
||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
|
||||
|
||||
return nil
|
||||
} else {
|
||||
// Generate response
|
||||
@@ -128,20 +128,20 @@ class NoiseSession {
|
||||
|
||||
// Check if handshake is complete after writing
|
||||
if handshake.isHandshakeComplete() {
|
||||
// Get transport ciphers
|
||||
let (send, receive) = try handshake.getTransportCiphers()
|
||||
// Get transport ciphers and handshake hash (hash captured before split clears state)
|
||||
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
||||
sendCipher = send
|
||||
receiveCipher = receive
|
||||
|
||||
|
||||
// Store remote static key
|
||||
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
||||
|
||||
|
||||
// Store handshake hash for channel binding
|
||||
handshakeHash = handshake.getHandshakeHash()
|
||||
|
||||
handshakeHash = hash
|
||||
|
||||
state = .established
|
||||
handshakeState = nil // Clear handshake state
|
||||
|
||||
|
||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,9 @@ final class NostrIdentityBridge {
|
||||
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
||||
private let cacheLock = NSLock()
|
||||
|
||||
private let keychain: KeychainHelperProtocol
|
||||
private let keychain: KeychainManagerProtocol
|
||||
|
||||
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ struct NostrProtocol {
|
||||
case seal = 13 // NIP-17 sealed event
|
||||
case giftWrap = 1059 // NIP-59 gift wrap
|
||||
case ephemeralEvent = 20000
|
||||
case geohashPresence = 20001
|
||||
}
|
||||
|
||||
/// Create a NIP-17 private message
|
||||
@@ -125,6 +126,24 @@ struct NostrProtocol {
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a geohash presence heartbeat (kind 20001)
|
||||
/// Must contain empty content and NO nickname tag
|
||||
static func createGeohashPresenceEvent(
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let tags = [["g", geohash]]
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: tags,
|
||||
content: ""
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||
static func createGeohashTextNote(
|
||||
content: String,
|
||||
|
||||
@@ -69,7 +69,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
private var messageQueue: [PendingSend] = []
|
||||
private let messageQueueLock = NSLock()
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
private var networkService: NetworkActivationService { NetworkActivationService.shared }
|
||||
private var shouldUseTor: Bool { networkService.userTorEnabled }
|
||||
|
||||
@@ -79,8 +78,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
|
||||
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
|
||||
|
||||
// Reconnection timer
|
||||
private var reconnectionTimer: Timer?
|
||||
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
||||
private var connectionGeneration: Int = 0
|
||||
|
||||
@@ -887,10 +884,10 @@ struct NostrFilter: Encodable {
|
||||
return filter
|
||||
}
|
||||
|
||||
// For location channels: geohash-scoped ephemeral events (kind 20000)
|
||||
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
|
||||
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [20000]
|
||||
filter.kinds = [20000, 20001]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["g": [geohash]]
|
||||
filter.limit = limit
|
||||
|
||||
@@ -5,16 +5,27 @@ import CryptoKit
|
||||
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
|
||||
/// as per XChaCha20 construction.
|
||||
enum XChaCha20Poly1305Compat {
|
||||
|
||||
/// Errors that can occur during XChaCha20-Poly1305 operations
|
||||
enum Error: Swift.Error {
|
||||
case invalidKeyLength(expected: Int, got: Int)
|
||||
case invalidNonceLength(expected: Int, got: Int)
|
||||
}
|
||||
|
||||
struct SealBox {
|
||||
let ciphertext: Data
|
||||
let tag: Data
|
||||
}
|
||||
|
||||
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
|
||||
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||
guard key.count == 32 else {
|
||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
||||
}
|
||||
guard nonce24.count == 24 else {
|
||||
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
|
||||
}
|
||||
|
||||
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
|
||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||
let chachaKey = SymmetricKey(data: subkey)
|
||||
let nonce = try ChaChaPoly.Nonce(data: nonce12)
|
||||
@@ -23,10 +34,14 @@ enum XChaCha20Poly1305Compat {
|
||||
}
|
||||
|
||||
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
|
||||
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||
guard key.count == 32 else {
|
||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
||||
}
|
||||
guard nonce24.count == 24 else {
|
||||
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
|
||||
}
|
||||
|
||||
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
|
||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||
let chachaKey = SymmetricKey(data: subkey)
|
||||
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
|
||||
@@ -43,10 +58,14 @@ enum XChaCha20Poly1305Compat {
|
||||
return out
|
||||
}
|
||||
|
||||
private static func hchacha20(key: Data, nonce16: Data) -> Data {
|
||||
private static func hchacha20(key: Data, nonce16: Data) throws -> Data {
|
||||
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
|
||||
precondition(key.count == 32)
|
||||
precondition(nonce16.count == 16)
|
||||
guard key.count == 32 else {
|
||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
||||
}
|
||||
guard nonce16.count == 16 else {
|
||||
throw Error.invalidNonceLength(expected: 16, got: nonce16.count)
|
||||
}
|
||||
|
||||
// Constants "expand 32-byte k"
|
||||
var state: [UInt32] = [
|
||||
|
||||
@@ -23,20 +23,42 @@ extension Data {
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// Initialize Data from a hex string.
|
||||
/// - Parameter hexString: A hex string, optionally prefixed with "0x" or "0X".
|
||||
/// Whitespace is trimmed. Must have even length after prefix removal.
|
||||
/// - Returns: nil if the string has odd length or contains invalid hex characters.
|
||||
init?(hexString: String) {
|
||||
let len = hexString.count / 2
|
||||
var hex = hexString.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
// Remove optional 0x prefix
|
||||
if hex.hasPrefix("0x") || hex.hasPrefix("0X") {
|
||||
hex = String(hex.dropFirst(2))
|
||||
}
|
||||
|
||||
// Reject odd-length strings
|
||||
guard hex.count % 2 == 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject empty strings
|
||||
guard !hex.isEmpty else {
|
||||
self = Data()
|
||||
return
|
||||
}
|
||||
|
||||
let len = hex.count / 2
|
||||
var data = Data(capacity: len)
|
||||
var index = hexString.startIndex
|
||||
|
||||
var index = hex.startIndex
|
||||
|
||||
for _ in 0..<len {
|
||||
let nextIndex = hexString.index(index, offsetBy: 2)
|
||||
guard let byte = UInt8(String(hexString[index..<nextIndex]), radix: 16) else {
|
||||
let nextIndex = hex.index(index, offsetBy: 2)
|
||||
guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
|
||||
return nil
|
||||
}
|
||||
data.append(byte)
|
||||
index = nextIndex
|
||||
}
|
||||
|
||||
|
||||
self = data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ struct BinaryProtocol {
|
||||
static let hasSignature: UInt8 = 0x02
|
||||
static let isCompressed: UInt8 = 0x04
|
||||
static let hasRoute: UInt8 = 0x08
|
||||
static let isRSR: UInt8 = 0x10
|
||||
}
|
||||
|
||||
// Encode BitchatPacket to binary format
|
||||
@@ -161,7 +162,9 @@ struct BinaryProtocol {
|
||||
}
|
||||
|
||||
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||
let originalRoute = packet.route ?? []
|
||||
|
||||
// Route is only supported for v2+ packets (per SOURCE_ROUTING.md spec)
|
||||
let originalRoute = (version >= 2) ? (packet.route ?? []) : []
|
||||
if originalRoute.contains(where: { $0.isEmpty }) { return nil }
|
||||
let sanitizedRoute: [Data] = originalRoute.map { hop in
|
||||
if hop.count == senderIDSize { return hop }
|
||||
@@ -175,13 +178,14 @@ struct BinaryProtocol {
|
||||
let hasRoute = !sanitizedRoute.isEmpty
|
||||
let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0
|
||||
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
||||
let payloadDataSize = routeLength + payload.count + originalSizeFieldBytes
|
||||
// payloadLength in header is payload-only (does NOT include route bytes)
|
||||
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 estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + routeLength
|
||||
let estimatedPayload = payloadDataSize
|
||||
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
|
||||
var data = Data()
|
||||
@@ -199,9 +203,11 @@ struct BinaryProtocol {
|
||||
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
||||
if packet.signature != nil { flags |= Flags.hasSignature }
|
||||
if isCompressed { flags |= Flags.isCompressed }
|
||||
if hasRoute { flags |= Flags.hasRoute }
|
||||
// HAS_ROUTE is only valid for v2+ packets
|
||||
if hasRoute && version >= 2 { flags |= Flags.hasRoute }
|
||||
if packet.isRSR { flags |= Flags.isRSR }
|
||||
data.append(flags)
|
||||
|
||||
|
||||
if version == 2 {
|
||||
let length = UInt32(payloadDataSize)
|
||||
for shift in stride(from: 24, through: 0, by: -8) {
|
||||
@@ -323,7 +329,10 @@ struct BinaryProtocol {
|
||||
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||
|
||||
// HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1
|
||||
let hasRoute = (version >= 2) && (flags & Flags.hasRoute) != 0
|
||||
let isRSR = (flags & Flags.isRSR) != 0
|
||||
|
||||
let payloadLength: Int
|
||||
if version == 2 {
|
||||
guard let len = read32() else { return nil }
|
||||
@@ -343,27 +352,24 @@ struct BinaryProtocol {
|
||||
if recipientID == nil { return nil }
|
||||
}
|
||||
|
||||
// Route (optional, v2+ only): route bytes are NOT included in payloadLength
|
||||
var route: [Data]? = nil
|
||||
var remainingPayloadBytes = payloadLength
|
||||
|
||||
if (flags & Flags.hasRoute) != 0 {
|
||||
guard remainingPayloadBytes >= 1, let routeCount = read8() else { return nil }
|
||||
remainingPayloadBytes -= 1
|
||||
if hasRoute {
|
||||
guard let routeCount = read8() else { return nil }
|
||||
if routeCount > 0 {
|
||||
var hops: [Data] = []
|
||||
for _ in 0..<Int(routeCount) {
|
||||
guard remainingPayloadBytes >= senderIDSize,
|
||||
let hop = readData(senderIDSize) else { return nil }
|
||||
remainingPayloadBytes -= senderIDSize
|
||||
guard let hop = readData(senderIDSize) else { return nil }
|
||||
hops.append(hop)
|
||||
}
|
||||
route = hops
|
||||
}
|
||||
}
|
||||
|
||||
// Payload: payloadLength is exactly the payload size (+ compression preamble if compressed)
|
||||
let payload: Data
|
||||
if isCompressed {
|
||||
guard remainingPayloadBytes >= lengthFieldBytes else { return nil }
|
||||
guard payloadLength >= lengthFieldBytes else { return nil }
|
||||
let originalSize: Int
|
||||
if version == 2 {
|
||||
guard let rawSize = read32() else { return nil }
|
||||
@@ -372,11 +378,9 @@ struct BinaryProtocol {
|
||||
guard let rawSize = read16() else { return nil }
|
||||
originalSize = Int(rawSize)
|
||||
}
|
||||
remainingPayloadBytes -= lengthFieldBytes
|
||||
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
||||
let compressedSize = remainingPayloadBytes
|
||||
let compressedSize = payloadLength - lengthFieldBytes
|
||||
guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }
|
||||
remainingPayloadBytes = 0
|
||||
|
||||
let compressionRatio = Double(originalSize) / Double(compressedSize)
|
||||
guard compressionRatio <= 50_000.0 else {
|
||||
@@ -388,9 +392,7 @@ struct BinaryProtocol {
|
||||
decompressed.count == originalSize else { return nil }
|
||||
payload = decompressed
|
||||
} else {
|
||||
guard remainingPayloadBytes >= 0,
|
||||
let rawPayload = readData(remainingPayloadBytes) else { return nil }
|
||||
remainingPayloadBytes = 0
|
||||
guard let rawPayload = readData(payloadLength) else { return nil }
|
||||
payload = rawPayload
|
||||
}
|
||||
|
||||
@@ -411,7 +413,8 @@ struct BinaryProtocol {
|
||||
signature: signature,
|
||||
ttl: ttl,
|
||||
version: version,
|
||||
route: route
|
||||
route: route,
|
||||
isRSR: isRSR
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,15 +15,52 @@ enum CommandResult {
|
||||
case handled // Command handled, no message needed
|
||||
}
|
||||
|
||||
/// Simple struct for geo participant info used by CommandProcessor
|
||||
struct CommandGeoParticipant {
|
||||
let id: String // pubkey hex (lowercased)
|
||||
let displayName: String
|
||||
}
|
||||
|
||||
/// Protocol defining what CommandProcessor needs from its context.
|
||||
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
|
||||
@MainActor
|
||||
protocol CommandContextProvider: AnyObject {
|
||||
// MARK: - State Properties
|
||||
var nickname: String { get }
|
||||
var selectedPrivateChatPeer: PeerID? { get }
|
||||
var blockedUsers: Set<String> { get }
|
||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
||||
var idBridge: NostrIdentityBridge { get }
|
||||
|
||||
// MARK: - Peer Lookup
|
||||
func getPeerIDForNickname(_ nickname: String) -> PeerID?
|
||||
func getVisibleGeoParticipants() -> [CommandGeoParticipant]
|
||||
func nostrPubkeyForDisplayName(_ displayName: String) -> String?
|
||||
|
||||
// MARK: - Chat Actions
|
||||
func startPrivateChat(with peerID: PeerID)
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID)
|
||||
func clearCurrentPublicTimeline()
|
||||
func sendPublicRaw(_ content: String)
|
||||
|
||||
// MARK: - System Messages
|
||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
||||
func addPublicSystemMessage(_ content: String)
|
||||
|
||||
// MARK: - Favorites
|
||||
func toggleFavorite(peerID: PeerID)
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||
}
|
||||
|
||||
/// Processes chat commands in a focused, efficient way
|
||||
@MainActor
|
||||
final class CommandProcessor {
|
||||
weak var chatViewModel: ChatViewModel?
|
||||
weak var contextProvider: CommandContextProvider?
|
||||
weak var meshService: Transport?
|
||||
private let identityManager: SecureIdentityStateManagerProtocol
|
||||
|
||||
init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||
self.chatViewModel = chatViewModel
|
||||
|
||||
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||
self.contextProvider = contextProvider
|
||||
self.meshService = meshService
|
||||
self.identityManager = identityManager
|
||||
}
|
||||
@@ -42,7 +79,7 @@ final class CommandProcessor {
|
||||
case .location: return true
|
||||
}
|
||||
}()
|
||||
let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true
|
||||
let inGeoDM = contextProvider?.selectedPrivateChatPeer?.isGeoDM == true
|
||||
|
||||
switch cmd {
|
||||
case "/m", "/msg":
|
||||
@@ -81,15 +118,15 @@ final class CommandProcessor {
|
||||
let targetName = String(parts[0])
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname) else {
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
|
||||
return .error(message: "'\(nickname)' not found")
|
||||
}
|
||||
|
||||
chatViewModel?.startPrivateChat(with: peerID)
|
||||
|
||||
|
||||
contextProvider?.startPrivateChat(with: peerID)
|
||||
|
||||
if parts.count > 1 {
|
||||
let message = String(parts[1])
|
||||
chatViewModel?.sendPrivateMessage(message, to: peerID)
|
||||
contextProvider?.sendPrivateMessage(message, to: peerID)
|
||||
}
|
||||
|
||||
return .success(message: "started private chat with \(nickname)")
|
||||
@@ -100,9 +137,9 @@ final class CommandProcessor {
|
||||
switch LocationChannelManager.shared.selectedChannel {
|
||||
case .location(let ch):
|
||||
// Geohash context: show visible geohash participants (exclude self)
|
||||
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
||||
let myHex = (try? chatViewModel?.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||
let people = vm.visibleGeohashPeople().filter { person in
|
||||
guard let vm = contextProvider else { return .success(message: "nobody around") }
|
||||
let myHex = (try? vm.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||
let people = vm.getVisibleGeoParticipants().filter { person in
|
||||
if let me = myHex { return person.id.lowercased() != me }
|
||||
return true
|
||||
}
|
||||
@@ -120,10 +157,10 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleClear() -> CommandResult {
|
||||
if let peerID = chatViewModel?.selectedPrivateChatPeer {
|
||||
chatViewModel?.privateChats[peerID]?.removeAll()
|
||||
if let peerID = contextProvider?.selectedPrivateChatPeer {
|
||||
contextProvider?.privateChats[peerID]?.removeAll()
|
||||
} else {
|
||||
chatViewModel?.clearCurrentPublicTimeline()
|
||||
contextProvider?.clearCurrentPublicTimeline()
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
@@ -136,14 +173,14 @@ final class CommandProcessor {
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let myNickname = chatViewModel?.nickname else {
|
||||
guard let targetPeerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let myNickname = contextProvider?.nickname else {
|
||||
return .error(message: "cannot \(command) \(nickname): not found")
|
||||
}
|
||||
|
||||
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
|
||||
|
||||
if chatViewModel?.selectedPrivateChatPeer != nil {
|
||||
if contextProvider?.selectedPrivateChatPeer != nil {
|
||||
// In private chat
|
||||
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
||||
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||
@@ -159,13 +196,13 @@ final class CommandProcessor {
|
||||
}
|
||||
}()
|
||||
let localText = "\(emoji) you \(pastAction) \(nickname)\(suffix)"
|
||||
chatViewModel?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
|
||||
contextProvider?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
|
||||
}
|
||||
} else {
|
||||
// In public chat: send to active public channel (mesh or geohash)
|
||||
chatViewModel?.sendPublicRaw(emoteContent)
|
||||
contextProvider?.sendPublicRaw(emoteContent)
|
||||
let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)"
|
||||
chatViewModel?.addPublicSystemMessage(publicEcho)
|
||||
contextProvider?.addPublicSystemMessage(publicEcho)
|
||||
}
|
||||
|
||||
return .handled
|
||||
@@ -176,7 +213,7 @@ final class CommandProcessor {
|
||||
|
||||
if targetName.isEmpty {
|
||||
// List blocked users (mesh) and geohash (Nostr) blocks
|
||||
let meshBlocked = chatViewModel?.blockedUsers ?? []
|
||||
let meshBlocked = contextProvider?.blockedUsers ?? []
|
||||
var blockedNicknames: [String] = []
|
||||
if let peers = meshService?.getPeerNicknames() {
|
||||
for (peerID, nickname) in peers {
|
||||
@@ -190,8 +227,8 @@ final class CommandProcessor {
|
||||
// Geohash blocked names (prefer visible display names; fallback to #suffix)
|
||||
let geoBlocked = Array(identityManager.getBlockedNostrPubkeys())
|
||||
var geoNames: [String] = []
|
||||
if let vm = chatViewModel {
|
||||
let visible = vm.visibleGeohashPeople()
|
||||
if let vm = contextProvider {
|
||||
let visible = vm.getVisibleGeoParticipants()
|
||||
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
|
||||
for pk in geoBlocked {
|
||||
if let name = visibleIndex[pk.lowercased()] {
|
||||
@@ -210,7 +247,7 @@ final class CommandProcessor {
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
if identityManager.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is already blocked")
|
||||
@@ -235,7 +272,7 @@ final class CommandProcessor {
|
||||
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
|
||||
}
|
||||
// Mesh lookup failed; try geohash (Nostr) participant by display name
|
||||
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
||||
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||
return .success(message: "\(nickname) is already blocked")
|
||||
}
|
||||
@@ -254,7 +291,7 @@ final class CommandProcessor {
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is not blocked")
|
||||
@@ -263,7 +300,7 @@ final class CommandProcessor {
|
||||
return .success(message: "unblocked \(nickname)")
|
||||
}
|
||||
// Try geohash unblock
|
||||
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
||||
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
|
||||
if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||
return .success(message: "\(nickname) is not blocked")
|
||||
}
|
||||
@@ -281,7 +318,7 @@ final class CommandProcessor {
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let noisePublicKey = Data(hexString: peerID.id) else {
|
||||
return .error(message: "can't find peer: \(nickname)")
|
||||
}
|
||||
@@ -294,15 +331,15 @@ final class CommandProcessor {
|
||||
peerNickname: nickname
|
||||
)
|
||||
|
||||
chatViewModel?.toggleFavorite(peerID: peerID)
|
||||
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||
|
||||
return .success(message: "added \(nickname) to favorites")
|
||||
} else {
|
||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
||||
|
||||
chatViewModel?.toggleFavorite(peerID: peerID)
|
||||
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
||||
|
||||
return .success(message: "removed \(nickname) from favorites")
|
||||
}
|
||||
|
||||
@@ -26,17 +26,14 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
|
||||
private static let storageKey = "chat.bitchat.favorites"
|
||||
private static let keychainService = "chat.bitchat.favorites"
|
||||
private let keychain: KeychainHelperProtocol
|
||||
private let keychain: KeychainManagerProtocol
|
||||
|
||||
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
||||
@Published private(set) var mutualFavorites: Set<Data> = []
|
||||
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
static let shared = FavoritesPersistenceService()
|
||||
|
||||
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||
|
||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
||||
self.keychain = keychain
|
||||
loadFavorites()
|
||||
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
#if os(iOS) || os(macOS)
|
||||
import CoreLocation
|
||||
#endif
|
||||
|
||||
/// Stores a user-maintained list of bookmarked geohash channels.
|
||||
/// - Persistence: UserDefaults (JSON string array)
|
||||
/// - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
|
||||
final class GeohashBookmarksStore: ObservableObject {
|
||||
static let shared = GeohashBookmarksStore()
|
||||
|
||||
@Published private(set) var bookmarks: [String] = []
|
||||
@Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name
|
||||
|
||||
private let storeKey = "locationChannel.bookmarks"
|
||||
private let namesStoreKey = "locationChannel.bookmarkNames"
|
||||
private var membership: Set<String> = []
|
||||
#if os(iOS) || os(macOS)
|
||||
private let geocoder = CLGeocoder()
|
||||
private var resolving: Set<String> = []
|
||||
#endif
|
||||
|
||||
private let storage: UserDefaults
|
||||
|
||||
init(storage: UserDefaults = .standard) {
|
||||
self.storage = storage
|
||||
load()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
func isBookmarked(_ geohash: String) -> Bool {
|
||||
return membership.contains(Self.normalize(geohash))
|
||||
}
|
||||
|
||||
func toggle(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
if membership.contains(gh) {
|
||||
remove(gh)
|
||||
} else {
|
||||
add(gh)
|
||||
}
|
||||
}
|
||||
|
||||
func add(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard !gh.isEmpty else { return }
|
||||
guard !membership.contains(gh) else { return }
|
||||
bookmarks.insert(gh, at: 0)
|
||||
membership.insert(gh)
|
||||
persist()
|
||||
// Resolve and persist a friendly name once when added
|
||||
resolveNameIfNeeded(for: gh)
|
||||
}
|
||||
|
||||
func remove(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard membership.contains(gh) else { return }
|
||||
if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) }
|
||||
membership.remove(gh)
|
||||
// Clean up stored name to avoid stale cache growth
|
||||
if bookmarkNames.removeValue(forKey: gh) != nil {
|
||||
persistNames()
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
private func load() {
|
||||
guard let data = storage.data(forKey: storeKey) else { return }
|
||||
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
||||
var seen = Set<String>()
|
||||
var list: [String] = []
|
||||
for raw in arr {
|
||||
let gh = Self.normalize(raw)
|
||||
guard !gh.isEmpty else { continue }
|
||||
if !seen.contains(gh) {
|
||||
seen.insert(gh)
|
||||
list.append(gh)
|
||||
}
|
||||
}
|
||||
bookmarks = list
|
||||
membership = seen
|
||||
}
|
||||
// Load any saved names
|
||||
if let namesData = storage.data(forKey: namesStoreKey),
|
||||
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
||||
bookmarkNames = dict
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||
storage.set(data, forKey: storeKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistNames() {
|
||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
||||
storage.set(data, forKey: namesStoreKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private static func normalize(_ s: String) -> String {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
return s
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.filter { allowed.contains($0) }
|
||||
}
|
||||
|
||||
// MARK: - Name Resolution
|
||||
/// Attempt to resolve and persist a friendly place name for a bookmarked geohash.
|
||||
func resolveNameIfNeeded(for geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard !gh.isEmpty else { return }
|
||||
if bookmarkNames[gh] != nil { return }
|
||||
#if os(iOS) || os(macOS)
|
||||
if resolving.contains(gh) { return }
|
||||
resolving.insert(gh)
|
||||
// For very coarse geohashes, sample multiple points to capture multiple admin areas
|
||||
if gh.count <= 2 {
|
||||
let b = Geohash.decodeBounds(gh)
|
||||
let pts: [CLLocation] = [
|
||||
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), // center
|
||||
CLLocation(latitude: b.latMin, longitude: b.lonMin),
|
||||
CLLocation(latitude: b.latMin, longitude: b.lonMax),
|
||||
CLLocation(latitude: b.latMax, longitude: b.lonMin),
|
||||
CLLocation(latitude: b.latMax, longitude: b.lonMax)
|
||||
]
|
||||
resolveCompositeAdminName(geohash: gh, points: pts)
|
||||
} else {
|
||||
let center = Geohash.decodeCenter(gh)
|
||||
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
|
||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||
guard let self = self else { return }
|
||||
defer { self.resolving.remove(gh) }
|
||||
if let pm = placemarks?.first {
|
||||
let name = Self.nameForGeohashLength(gh.count, from: pm)
|
||||
if let name = name, !name.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.bookmarkNames[gh] = name
|
||||
self.persistNames()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
||||
var uniqueAdmins = OrderedSet<String>()
|
||||
var idx = 0
|
||||
func step() {
|
||||
if idx >= points.count {
|
||||
// Compose up to 2 names joined by ' and '
|
||||
let finalName: String? = {
|
||||
let names = uniqueAdmins.array
|
||||
if names.count >= 2 { return names[0] + " and " + names[1] }
|
||||
return names.first
|
||||
}()
|
||||
if let finalName = finalName, !finalName.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.bookmarkNames[gh] = finalName
|
||||
self.persistNames()
|
||||
}
|
||||
}
|
||||
self.resolving.remove(gh)
|
||||
return
|
||||
}
|
||||
let loc = points[idx]
|
||||
idx += 1
|
||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||
guard self != nil else { return }
|
||||
if let pm = placemarks?.first {
|
||||
if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||
uniqueAdmins.insert(admin)
|
||||
} else if let country = pm.country, !country.isEmpty {
|
||||
uniqueAdmins.insert(country)
|
||||
}
|
||||
}
|
||||
// Proceed to next point
|
||||
step()
|
||||
}
|
||||
}
|
||||
step()
|
||||
}
|
||||
|
||||
// Minimal ordered-set for stable joining
|
||||
private struct OrderedSet<Element: Hashable> {
|
||||
private var set: Set<Element> = []
|
||||
private(set) var array: [Element] = []
|
||||
mutating func insert(_ element: Element) {
|
||||
if set.insert(element).inserted { array.append(element) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
|
||||
switch len {
|
||||
case 0...2:
|
||||
// Prefer administrative area if available at this coarse level
|
||||
return pm.administrativeArea ?? pm.country
|
||||
case 3...4:
|
||||
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
|
||||
case 5:
|
||||
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
|
||||
case 6...7:
|
||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
|
||||
default:
|
||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//
|
||||
// GeohashParticipantTracker.swift
|
||||
// bitchat
|
||||
//
|
||||
// Tracks participants in geohash-based location channels.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents a participant in a geohash channel
|
||||
public struct GeoPerson: Identifiable, Equatable, Sendable {
|
||||
public let id: String // pubkey hex (lowercased)
|
||||
public let displayName: String
|
||||
public let lastSeen: Date
|
||||
|
||||
public init(id: String, displayName: String, lastSeen: Date) {
|
||||
self.id = id
|
||||
self.displayName = displayName
|
||||
self.lastSeen = lastSeen
|
||||
}
|
||||
}
|
||||
|
||||
/// Protocol for resolving display names and checking block status
|
||||
@MainActor
|
||||
public protocol GeohashParticipantContext: AnyObject {
|
||||
/// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4")
|
||||
func displayNameForPubkey(_ pubkeyHex: String) -> String
|
||||
/// Returns true if the pubkey is blocked
|
||||
func isBlocked(_ pubkeyHexLowercased: String) -> Bool
|
||||
}
|
||||
|
||||
/// Tracks participants across multiple geohash channels
|
||||
@MainActor
|
||||
public final class GeohashParticipantTracker: ObservableObject {
|
||||
|
||||
/// Activity cutoff duration (defaults to 5 minutes)
|
||||
public let activityCutoff: TimeInterval
|
||||
|
||||
/// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]]
|
||||
private var participants: [String: [String: Date]] = [:]
|
||||
|
||||
/// Currently visible people for the active geohash
|
||||
@Published public private(set) var visiblePeople: [GeoPerson] = []
|
||||
|
||||
/// The currently active geohash (if any)
|
||||
private var activeGeohash: String?
|
||||
|
||||
/// Context for display name resolution and block checking
|
||||
private weak var context: GeohashParticipantContext?
|
||||
|
||||
/// Timer for periodic refresh
|
||||
private var refreshTimer: Timer?
|
||||
|
||||
public init(activityCutoff: TimeInterval = -300) { // default 5 minutes
|
||||
self.activityCutoff = activityCutoff
|
||||
}
|
||||
|
||||
/// Configure with a context provider
|
||||
public func configure(context: GeohashParticipantContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// Set the currently active geohash
|
||||
public func setActiveGeohash(_ geohash: String?) {
|
||||
activeGeohash = geohash
|
||||
if geohash == nil {
|
||||
visiblePeople = []
|
||||
} else {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
/// Record activity from a participant in the current active geohash
|
||||
public func recordParticipant(pubkeyHex: String) {
|
||||
guard let gh = activeGeohash else { return }
|
||||
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
|
||||
}
|
||||
|
||||
/// Record activity from a participant in a specific geohash
|
||||
public func recordParticipant(pubkeyHex: String, geohash: String) {
|
||||
let key = pubkeyHex.lowercased()
|
||||
var map = participants[geohash] ?? [:]
|
||||
map[key] = Date()
|
||||
participants[geohash] = map
|
||||
|
||||
// Always notify observers that state has changed so counts in UI update
|
||||
objectWillChange.send()
|
||||
|
||||
// Only refresh visible list if this geohash is currently active
|
||||
if activeGeohash == geohash {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a participant from all geohashes (used when blocking)
|
||||
public func removeParticipant(pubkeyHex: String) {
|
||||
let key = pubkeyHex.lowercased()
|
||||
for (gh, var map) in participants {
|
||||
map.removeValue(forKey: key)
|
||||
participants[gh] = map
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
/// Get participant count for a specific geohash
|
||||
public func participantCount(for geohash: String) -> Int {
|
||||
let cutoff = Date().addingTimeInterval(activityCutoff)
|
||||
let map = participants[geohash] ?? [:]
|
||||
return map.values.filter { $0 >= cutoff }.count
|
||||
}
|
||||
|
||||
/// Get the visible people list for the active geohash (read-only query)
|
||||
public func getVisiblePeople() -> [GeoPerson] {
|
||||
guard let gh = activeGeohash, let context = context else { return [] }
|
||||
let cutoff = Date().addingTimeInterval(activityCutoff)
|
||||
let map = (participants[gh] ?? [:])
|
||||
.filter { $0.value >= cutoff }
|
||||
.filter { !context.isBlocked($0.key) }
|
||||
|
||||
return map
|
||||
.map { (pub, seen) in
|
||||
GeoPerson(id: pub, displayName: context.displayNameForPubkey(pub), lastSeen: seen)
|
||||
}
|
||||
.sorted { $0.lastSeen > $1.lastSeen }
|
||||
}
|
||||
|
||||
/// Refresh the visible people list
|
||||
public func refresh() {
|
||||
visiblePeople = getVisiblePeople()
|
||||
}
|
||||
|
||||
/// Start the periodic refresh timer
|
||||
public func startRefreshTimer(interval: TimeInterval = 30.0) {
|
||||
stopRefreshTimer()
|
||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the periodic refresh timer
|
||||
public func stopRefreshTimer() {
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
}
|
||||
|
||||
/// Clear all participant data
|
||||
public func clear() {
|
||||
participants.removeAll()
|
||||
visiblePeople = []
|
||||
}
|
||||
|
||||
/// Clear participant data for a specific geohash
|
||||
public func clear(geohash: String) {
|
||||
participants.removeValue(forKey: geohash)
|
||||
if activeGeohash == geohash {
|
||||
visiblePeople = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//
|
||||
// GeohashPresenceService.swift
|
||||
// bitchat
|
||||
//
|
||||
// Manages the broadcasting of ephemeral presence heartbeats (Kind 20001)
|
||||
// to geohash location channels.
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import BitLogger
|
||||
import Tor
|
||||
|
||||
/// Service that coordinates the broadcasting of presence heartbeats.
|
||||
///
|
||||
/// Behavior:
|
||||
/// - Monitors location changes via LocationStateManager
|
||||
/// - Broadcasts Kind 20001 events to low-precision geohash channels
|
||||
/// - Uses randomized timing (40-80s loop) and decorrelated bursts
|
||||
/// - Respects privacy by NOT broadcasting to Neighborhood/Block/Building levels
|
||||
@MainActor
|
||||
final class GeohashPresenceService: ObservableObject {
|
||||
static let shared = GeohashPresenceService()
|
||||
|
||||
private var subscriptions = Set<AnyCancellable>()
|
||||
private var heartbeatTimer: Timer?
|
||||
private let idBridge = NostrIdentityBridge()
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
// Loop interval range in seconds
|
||||
private let loopMinInterval: TimeInterval = 40.0
|
||||
private let loopMaxInterval: TimeInterval = 80.0
|
||||
|
||||
// Per-broadcast decorrelation delay range in seconds
|
||||
private let burstMinDelay: TimeInterval = 2.0
|
||||
private let burstMaxDelay: TimeInterval = 5.0
|
||||
|
||||
// Privacy: Only broadcast to these levels
|
||||
private let allowedPrecisions: Set<Int> = [
|
||||
GeohashChannelLevel.region.precision, // 2
|
||||
GeohashChannelLevel.province.precision, // 4
|
||||
GeohashChannelLevel.city.precision // 5
|
||||
]
|
||||
|
||||
private init() {
|
||||
setupObservers()
|
||||
}
|
||||
|
||||
/// Start the service (safe to call multiple times)
|
||||
func start() {
|
||||
SecureLogger.info("Presence: service starting...", category: .session)
|
||||
scheduleNextHeartbeat()
|
||||
}
|
||||
|
||||
private func setupObservers() {
|
||||
// Monitor location channel changes
|
||||
LocationStateManager.shared.$availableChannels
|
||||
.dropFirst()
|
||||
.sink { [weak self] _ in
|
||||
self?.handleLocationChange()
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
// Monitor Tor readiness to kick off heartbeat if it was stalled
|
||||
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||
.sink { [weak self] _ in
|
||||
self?.handleConnectivityChange()
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func handleLocationChange() {
|
||||
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
||||
// to announce presence in the new zone, then reset the loop.
|
||||
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
||||
heartbeatTimer?.invalidate()
|
||||
|
||||
// Small delay to allow location state to settle
|
||||
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.performHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleConnectivityChange() {
|
||||
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
||||
// If we were waiting for network, do it now
|
||||
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
||||
scheduleNextHeartbeat()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleNextHeartbeat() {
|
||||
heartbeatTimer?.invalidate()
|
||||
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
||||
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.performHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func performHeartbeat() {
|
||||
// Always schedule next loop first ensures continuity even if this one fails/skips
|
||||
defer { scheduleNextHeartbeat() }
|
||||
|
||||
// 1. Check preconditions
|
||||
guard TorManager.shared.isReady else {
|
||||
SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// App must be active (or at least we shouldn't broadcast if in background, usually)
|
||||
if !TorManager.shared.isForeground() {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Get channels
|
||||
let channels = LocationStateManager.shared.availableChannels
|
||||
guard !channels.isEmpty else { return }
|
||||
|
||||
// 3. Filter and broadcast
|
||||
// We use Task + sleep for decorrelation to allow the main runloop to proceed
|
||||
for channel in channels {
|
||||
// Check privacy restriction
|
||||
if !self.allowedPrecisions.contains(channel.geohash.count) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Launch independent task for each channel's delay
|
||||
Task { @MainActor in
|
||||
// Random delay for decorrelation
|
||||
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
|
||||
self.broadcastPresence(for: channel.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func broadcastPresence(for geohash: String) {
|
||||
do {
|
||||
guard let identity = try? idBridge.deriveIdentity(forGeohash: geohash) else {
|
||||
return
|
||||
}
|
||||
|
||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
||||
geohash: geohash,
|
||||
senderIdentity: identity
|
||||
)
|
||||
|
||||
// Send via RelayManager
|
||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
||||
toGeohash: geohash,
|
||||
count: TransportConfig.nostrGeoRelayCount
|
||||
)
|
||||
|
||||
if !targetRelays.isEmpty {
|
||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Presence: failed to create event for \(geohash): \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,16 +10,71 @@ import BitLogger
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
// MARK: - Keychain Error Types
|
||||
// BCH-01-009: Proper error classification to distinguish expected states from critical failures
|
||||
|
||||
/// Result of a keychain read operation with proper error classification
|
||||
enum KeychainReadResult {
|
||||
case success(Data)
|
||||
case itemNotFound // Expected: key doesn't exist yet
|
||||
case accessDenied // Critical: app lacks keychain access
|
||||
case deviceLocked // Recoverable: device is locked
|
||||
case authenticationFailed // Recoverable: biometric/passcode failed
|
||||
case otherError(OSStatus) // Unexpected error
|
||||
|
||||
var isRecoverableError: Bool {
|
||||
switch self {
|
||||
case .deviceLocked, .authenticationFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a keychain save operation with proper error classification
|
||||
enum KeychainSaveResult {
|
||||
case success
|
||||
case duplicateItem // Can retry with update
|
||||
case accessDenied // Critical: app lacks keychain access
|
||||
case deviceLocked // Recoverable: device is locked
|
||||
case storageFull // Critical: no space available
|
||||
case otherError(OSStatus)
|
||||
|
||||
var isRecoverableError: Bool {
|
||||
switch self {
|
||||
case .duplicateItem, .deviceLocked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol KeychainManagerProtocol {
|
||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
|
||||
func getIdentityKey(forKey key: String) -> Data?
|
||||
func deleteIdentityKey(forKey key: String) -> Bool
|
||||
func deleteAllKeychainData() -> Bool
|
||||
|
||||
|
||||
func secureClear(_ data: inout Data)
|
||||
func secureClear(_ string: inout String)
|
||||
|
||||
|
||||
func verifyIdentityKeyExists() -> Bool
|
||||
|
||||
// BCH-01-009: Methods with proper error classification
|
||||
/// Get identity key with detailed result for error handling
|
||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult
|
||||
/// Save identity key with detailed result for error handling
|
||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult
|
||||
|
||||
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
||||
/// Save data with a custom service name
|
||||
func save(key: String, data: Data, service: String, accessible: CFString?)
|
||||
/// Load data from a custom service
|
||||
func load(key: String, service: String) -> Data?
|
||||
/// Delete data from a custom service
|
||||
func delete(key: String, service: String)
|
||||
}
|
||||
|
||||
final class KeychainManager: KeychainManagerProtocol {
|
||||
@@ -46,7 +101,181 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// MARK: - BCH-01-009: Methods with Proper Error Classification
|
||||
|
||||
/// Get identity key with detailed result for proper error handling
|
||||
/// Distinguishes between missing keys (expected) and critical failures
|
||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
||||
let fullKey = "identity_\(key)"
|
||||
return retrieveDataWithResult(forKey: fullKey)
|
||||
}
|
||||
|
||||
/// Save identity key with detailed result and retry logic for transient errors
|
||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
||||
let fullKey = "identity_\(key)"
|
||||
return saveDataWithResult(keyData, forKey: fullKey)
|
||||
}
|
||||
|
||||
/// Internal method to save data with detailed result and retry for transient errors
|
||||
private func saveDataWithResult(_ data: Data, forKey key: String, retryCount: Int = 2) -> KeychainSaveResult {
|
||||
// Delete any existing item first to ensure clean state
|
||||
_ = delete(forKey: key)
|
||||
|
||||
// Build base query
|
||||
var base: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
|
||||
kSecAttrLabel as String: "bitchat-\(key)"
|
||||
]
|
||||
#if os(macOS)
|
||||
base[kSecAttrSynchronizable as String] = false
|
||||
#endif
|
||||
|
||||
func attempt(addAccessGroup: Bool) -> OSStatus {
|
||||
var query = base
|
||||
if addAccessGroup { query[kSecAttrAccessGroup as String] = appGroup }
|
||||
return SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
var status = attempt(addAccessGroup: true)
|
||||
if status == -34018 { // Missing entitlement, retry without access group
|
||||
status = attempt(addAccessGroup: false)
|
||||
}
|
||||
#else
|
||||
let status = attempt(addAccessGroup: false)
|
||||
#endif
|
||||
|
||||
// Classify the result
|
||||
let result = classifySaveStatus(status)
|
||||
|
||||
// Log all outcomes consistently
|
||||
switch result {
|
||||
case .success:
|
||||
SecureLogger.debug("Keychain save succeeded for key: \(key)", category: .keychain)
|
||||
case .duplicateItem:
|
||||
SecureLogger.warning("Keychain save found duplicate for key: \(key)", category: .keychain)
|
||||
case .accessDenied:
|
||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
||||
context: "Keychain access denied for key: \(key)", category: .keychain)
|
||||
case .deviceLocked:
|
||||
SecureLogger.warning("Device locked during keychain save for key: \(key)", category: .keychain)
|
||||
case .storageFull:
|
||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
||||
context: "Keychain storage full for key: \(key)", category: .keychain)
|
||||
case .otherError(let code):
|
||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(code)),
|
||||
context: "Keychain save failed for key: \(key)", category: .keychain)
|
||||
}
|
||||
|
||||
// Retry transient errors with exponential backoff
|
||||
if result.isRecoverableError && retryCount > 0 {
|
||||
let delayMs = UInt32((3 - retryCount) * 100) // 100ms, 200ms backoff
|
||||
usleep(delayMs * 1000)
|
||||
SecureLogger.debug("Retrying keychain save for key: \(key), attempts remaining: \(retryCount)", category: .keychain)
|
||||
return saveDataWithResult(data, forKey: key, retryCount: retryCount - 1)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Internal method to retrieve data with detailed result
|
||||
private func retrieveDataWithResult(forKey key: String) -> KeychainReadResult {
|
||||
let base: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecAttrService as String: service,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
func attempt(withAccessGroup: Bool) -> OSStatus {
|
||||
var q = base
|
||||
if withAccessGroup { q[kSecAttrAccessGroup as String] = appGroup }
|
||||
return SecItemCopyMatching(q as CFDictionary, &result)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
var status = attempt(withAccessGroup: true)
|
||||
if status == -34018 { status = attempt(withAccessGroup: false) }
|
||||
#else
|
||||
let status = attempt(withAccessGroup: false)
|
||||
#endif
|
||||
|
||||
// Classify the result
|
||||
let readResult = classifyReadStatus(status, data: result as? Data)
|
||||
|
||||
// Log all outcomes consistently
|
||||
switch readResult {
|
||||
case .success:
|
||||
SecureLogger.debug("Keychain read succeeded for key: \(key)", category: .keychain)
|
||||
case .itemNotFound:
|
||||
// Expected case - no logging needed for missing keys
|
||||
break
|
||||
case .accessDenied:
|
||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
||||
context: "Keychain access denied for key: \(key)", category: .keychain)
|
||||
case .deviceLocked:
|
||||
SecureLogger.warning("Device locked during keychain read for key: \(key)", category: .keychain)
|
||||
case .authenticationFailed:
|
||||
SecureLogger.warning("Authentication failed for keychain read of key: \(key)", category: .keychain)
|
||||
case .otherError(let code):
|
||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(code)),
|
||||
context: "Keychain read failed for key: \(key)", category: .keychain)
|
||||
}
|
||||
|
||||
return readResult
|
||||
}
|
||||
|
||||
/// Classify keychain read status into meaningful categories
|
||||
private func classifyReadStatus(_ status: OSStatus, data: Data?) -> KeychainReadResult {
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
if let data = data {
|
||||
return .success(data)
|
||||
}
|
||||
return .otherError(status)
|
||||
case errSecItemNotFound:
|
||||
return .itemNotFound
|
||||
case errSecInteractionNotAllowed:
|
||||
// Device is locked or in a state that doesn't allow keychain access
|
||||
return .deviceLocked
|
||||
case errSecAuthFailed:
|
||||
return .authenticationFailed
|
||||
case -34018: // errSecMissingEntitlement
|
||||
return .accessDenied
|
||||
case errSecNotAvailable:
|
||||
return .accessDenied
|
||||
default:
|
||||
return .otherError(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify keychain save status into meaningful categories
|
||||
private func classifySaveStatus(_ status: OSStatus) -> KeychainSaveResult {
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
return .success
|
||||
case errSecDuplicateItem:
|
||||
return .duplicateItem
|
||||
case errSecInteractionNotAllowed:
|
||||
return .deviceLocked
|
||||
case -34018: // errSecMissingEntitlement
|
||||
return .accessDenied
|
||||
case errSecNotAvailable:
|
||||
return .accessDenied
|
||||
case errSecDiskFull:
|
||||
return .storageFull
|
||||
default:
|
||||
return .otherError(status)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generic Operations
|
||||
|
||||
private func save(_ value: String, forKey key: String) -> Bool {
|
||||
@@ -309,9 +538,54 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
}
|
||||
|
||||
// MARK: - Debug
|
||||
|
||||
|
||||
func verifyIdentityKeyExists() -> Bool {
|
||||
let key = "identity_noiseStaticKey"
|
||||
return retrieveData(forKey: key) != nil
|
||||
}
|
||||
|
||||
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
||||
|
||||
/// Save data with a custom service name
|
||||
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: customService,
|
||||
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)
|
||||
}
|
||||
|
||||
/// Load data from a custom service
|
||||
func load(key: String, service customService: String) -> Data? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: customService,
|
||||
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
|
||||
}
|
||||
|
||||
/// Delete data from a custom service
|
||||
func delete(key: String, service customService: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: customService,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
import CoreLocation
|
||||
|
||||
/// Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
||||
/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor.
|
||||
final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject {
|
||||
static let shared = LocationChannelManager()
|
||||
|
||||
enum PermissionState: Equatable {
|
||||
case notDetermined
|
||||
case denied
|
||||
case restricted
|
||||
case authorized
|
||||
}
|
||||
|
||||
private let cl = CLLocationManager()
|
||||
private let geocoder = CLGeocoder()
|
||||
private var lastLocation: CLLocation?
|
||||
private var refreshTimer: Timer?
|
||||
private let userDefaultsKey = "locationChannel.selected"
|
||||
private let teleportedStoreKey = "locationChannel.teleportedSet"
|
||||
private var isGeocoding: Bool = false
|
||||
|
||||
// Published state for UI bindings
|
||||
@Published private(set) var permissionState: PermissionState = .notDetermined
|
||||
@Published private(set) var availableChannels: [GeohashChannel] = []
|
||||
@Published private(set) var selectedChannel: ChannelID = .mesh
|
||||
// True when the current location channel was selected via manual teleport
|
||||
@Published var teleported: Bool = false
|
||||
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
|
||||
|
||||
// Persisted set of geohashes that were selected via teleport
|
||||
private var teleportedSet: Set<String> = []
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
cl.delegate = self
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously
|
||||
// Load selection
|
||||
if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
||||
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
||||
selectedChannel = channel
|
||||
}
|
||||
// Load persisted teleported set
|
||||
if let data = UserDefaults.standard.data(forKey: teleportedStoreKey),
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
teleportedSet = Set(arr)
|
||||
}
|
||||
// Do not eagerly mark teleported on startup; wait for location to compute regional set.
|
||||
// This avoids showing teleported for in-region channels during cold start.
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
updatePermissionState(from: status)
|
||||
// If we don't have location authorization at startup, fall back to persisted teleport state
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
break // will compute from location
|
||||
case .notDetermined, .restricted, .denied:
|
||||
fallthrough
|
||||
@unknown default:
|
||||
if case .location(let ch) = selectedChannel {
|
||||
teleported = teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
func enableLocationChannels() {
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
switch status {
|
||||
case .notDetermined:
|
||||
cl.requestWhenInUseAuthorization()
|
||||
case .restricted:
|
||||
Task { @MainActor in self.permissionState = .restricted }
|
||||
case .denied:
|
||||
Task { @MainActor in self.permissionState = .denied }
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
Task { @MainActor in self.permissionState = .authorized }
|
||||
requestOneShotLocation()
|
||||
@unknown default:
|
||||
Task { @MainActor in self.permissionState = .restricted }
|
||||
}
|
||||
}
|
||||
|
||||
func refreshChannels() {
|
||||
if permissionState == .authorized {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin continuous, distance-filtered updates while the channel sheet is visible.
|
||||
/// Uses a 21m filter (configurable) to only refresh on meaningful movement.
|
||||
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
||||
guard permissionState == .authorized else { return }
|
||||
// Stop any previous polling timer
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
// Tighten accuracy and distance filter for live view
|
||||
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
|
||||
// Start continuous updates
|
||||
cl.startUpdatingLocation()
|
||||
// Request an immediate fix to populate UI without waiting for movement
|
||||
requestOneShotLocation()
|
||||
}
|
||||
|
||||
/// Stop continuous refreshes when selector UI is dismissed.
|
||||
func endLiveRefresh() {
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
cl.stopUpdatingLocation()
|
||||
// Restore more relaxed defaults for background/idle state
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||
}
|
||||
|
||||
func select(_ channel: ChannelID) {
|
||||
Task { @MainActor in
|
||||
self.selectedChannel = channel
|
||||
if let data = try? JSONEncoder().encode(channel) {
|
||||
UserDefaults.standard.set(data, forKey: self.userDefaultsKey)
|
||||
}
|
||||
// Update teleported flag based on persisted state for immediate UI behavior
|
||||
switch channel {
|
||||
case .mesh:
|
||||
self.teleported = false
|
||||
case .location(let ch):
|
||||
// If this geohash is in our current regional set, do NOT mark teleported.
|
||||
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
if inRegional {
|
||||
self.teleported = false
|
||||
// Clear persisted teleport for this geohash to keep future selections clean
|
||||
if self.teleportedSet.contains(ch.geohash) {
|
||||
self.teleportedSet.remove(ch.geohash)
|
||||
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fall back to persisted mark (set by deep link or manual teleport)
|
||||
self.teleported = self.teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark or unmark a geohash as teleported in persistence and update current flag if relevant
|
||||
func markTeleported(for geohash: String, _ flag: Bool) {
|
||||
if flag { teleportedSet.insert(geohash) } else { teleportedSet.remove(geohash) }
|
||||
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
|
||||
UserDefaults.standard.set(data, forKey: teleportedStoreKey)
|
||||
}
|
||||
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
|
||||
Task { @MainActor in self.teleported = flag }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CoreLocation
|
||||
private func requestOneShotLocation() {
|
||||
cl.requestLocation()
|
||||
}
|
||||
|
||||
// iOS < 14
|
||||
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
||||
updatePermissionState(from: status)
|
||||
if case .authorized = permissionState {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
// iOS 14+ / macOS 11+
|
||||
@available(iOS 14.0, macOS 11.0, *)
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
updatePermissionState(from: manager.authorizationStatus)
|
||||
if case .authorized = permissionState {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let loc = locations.last else { return }
|
||||
lastLocation = loc
|
||||
computeChannels(from: loc.coordinate)
|
||||
reverseGeocodeIfNeeded(location: loc)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
// Surface as denied/restricted if relevant; otherwise keep previous state
|
||||
SecureLogger.error("LocationChannelManager: location error: \(error.localizedDescription)", category: .session)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private func updatePermissionState(from status: CLAuthorizationStatus) {
|
||||
let newState: PermissionState
|
||||
switch status {
|
||||
case .notDetermined: newState = .notDetermined
|
||||
case .restricted: newState = .restricted
|
||||
case .denied: newState = .denied
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
|
||||
@unknown default: newState = .restricted
|
||||
}
|
||||
Task { @MainActor in self.permissionState = newState }
|
||||
}
|
||||
|
||||
private func computeChannels(from coord: CLLocationCoordinate2D) {
|
||||
let levels = GeohashChannelLevel.allCases
|
||||
var result: [GeohashChannel] = []
|
||||
for level in levels {
|
||||
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
|
||||
result.append(GeohashChannel(level: level, geohash: gh))
|
||||
}
|
||||
Task { @MainActor in
|
||||
self.availableChannels = result
|
||||
// Recompute teleported status based on whether the selected geohash is in our regional set
|
||||
switch self.selectedChannel {
|
||||
case .mesh:
|
||||
self.teleported = false
|
||||
case .location(let ch):
|
||||
// Membership check using freshly computed regional channels; avoids precision/rename drift
|
||||
let inRegional = result.contains { $0.geohash == ch.geohash }
|
||||
if inRegional {
|
||||
self.teleported = false
|
||||
// Clear persisted teleport flag if present
|
||||
if self.teleportedSet.contains(ch.geohash) {
|
||||
self.teleportedSet.remove(ch.geohash)
|
||||
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.teleported = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reverseGeocodeIfNeeded(location: CLLocation) {
|
||||
// Always cancel previous to keep latest fresh while user moves
|
||||
geocoder.cancelGeocode()
|
||||
isGeocoding = true
|
||||
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in
|
||||
guard let self = self else { return }
|
||||
self.isGeocoding = false
|
||||
if let pm = placemarks?.first {
|
||||
let names = self.namesByLevel(from: pm)
|
||||
Task { @MainActor in self.locationNames = names }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func namesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
|
||||
var dict: [GeohashChannelLevel: String] = [:]
|
||||
// Region (country)
|
||||
if let country = pm.country, !country.isEmpty {
|
||||
dict[.region] = country
|
||||
}
|
||||
// Province (state/province or county)
|
||||
if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||
dict[.province] = admin
|
||||
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
||||
dict[.province] = subAdmin
|
||||
}
|
||||
// City (locality)
|
||||
if let locality = pm.locality, !locality.isEmpty {
|
||||
dict[.city] = locality
|
||||
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
||||
dict[.city] = subAdmin
|
||||
} else if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||
dict[.city] = admin
|
||||
}
|
||||
// Neighborhood
|
||||
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||
dict[.neighborhood] = subLocality
|
||||
} else if let locality = pm.locality, !locality.isEmpty {
|
||||
dict[.neighborhood] = locality
|
||||
}
|
||||
// Block: reuse neighborhood/locality granularity
|
||||
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||
dict[.block] = subLocality
|
||||
} else if let locality = pm.locality, !locality.isEmpty {
|
||||
dict[.block] = locality
|
||||
}
|
||||
// Building: prefer place name/street/venue when available
|
||||
if let name = pm.name, !name.isEmpty {
|
||||
dict[.building] = name
|
||||
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
|
||||
dict[.building] = thoroughfare
|
||||
}
|
||||
return dict
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,545 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
import CoreLocation
|
||||
|
||||
/// Unified manager for location-based channel state including:
|
||||
/// - CoreLocation permissions and one-shot location retrieval
|
||||
/// - Geohash channel computation from coordinates
|
||||
/// - Channel selection and teleport state
|
||||
/// - Bookmark persistence and friendly name resolution
|
||||
///
|
||||
/// Consolidates LocationChannelManager + GeohashBookmarksStore into a single source of truth.
|
||||
final class LocationStateManager: NSObject, CLLocationManagerDelegate, ObservableObject {
|
||||
static let shared = LocationStateManager()
|
||||
|
||||
// MARK: - Permission State
|
||||
|
||||
enum PermissionState: Equatable {
|
||||
case notDetermined
|
||||
case denied
|
||||
case restricted
|
||||
case authorized
|
||||
}
|
||||
|
||||
// MARK: - Private Properties (CoreLocation)
|
||||
|
||||
private let cl = CLLocationManager()
|
||||
private let geocoder = CLGeocoder()
|
||||
private var lastLocation: CLLocation?
|
||||
private var refreshTimer: Timer?
|
||||
private var isGeocoding: Bool = false
|
||||
|
||||
// MARK: - Persistence Keys
|
||||
|
||||
private let selectedChannelKey = "locationChannel.selected"
|
||||
private let teleportedStoreKey = "locationChannel.teleportedSet"
|
||||
private let bookmarksKey = "locationChannel.bookmarks"
|
||||
private let bookmarkNamesKey = "locationChannel.bookmarkNames"
|
||||
|
||||
// MARK: - Published State (Channel)
|
||||
|
||||
@Published private(set) var permissionState: PermissionState = .notDetermined
|
||||
@Published private(set) var availableChannels: [GeohashChannel] = []
|
||||
@Published private(set) var selectedChannel: ChannelID = .mesh
|
||||
@Published var teleported: Bool = false
|
||||
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
|
||||
|
||||
// MARK: - Published State (Bookmarks)
|
||||
|
||||
@Published private(set) var bookmarks: [String] = []
|
||||
@Published private(set) var bookmarkNames: [String: String] = [:]
|
||||
|
||||
// MARK: - Private State
|
||||
|
||||
private var teleportedSet: Set<String> = []
|
||||
private var bookmarkMembership: Set<String> = []
|
||||
private var resolvingNames: Set<String> = []
|
||||
private let storage: UserDefaults
|
||||
|
||||
/// Returns true if running in test environment
|
||||
private static var isRunningTests: Bool {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
return NSClassFromString("XCTestCase") != nil ||
|
||||
env["XCTestConfigurationFilePath"] != nil ||
|
||||
env["XCTestBundlePath"] != nil ||
|
||||
env["GITHUB_ACTIONS"] != nil ||
|
||||
env["CI"] != nil
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private override init() {
|
||||
self.storage = .standard
|
||||
super.init()
|
||||
|
||||
// Skip CoreLocation setup in test environments
|
||||
guard !Self.isRunningTests else {
|
||||
loadPersistedState()
|
||||
return
|
||||
}
|
||||
|
||||
cl.delegate = self
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||
|
||||
loadPersistedState()
|
||||
initializePermissionState()
|
||||
}
|
||||
|
||||
/// Internal initializer for testing with custom storage
|
||||
init(storage: UserDefaults) {
|
||||
self.storage = storage
|
||||
super.init()
|
||||
loadPersistedState()
|
||||
}
|
||||
|
||||
private func loadPersistedState() {
|
||||
// Load selected channel
|
||||
if let data = storage.data(forKey: selectedChannelKey),
|
||||
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
||||
selectedChannel = channel
|
||||
}
|
||||
|
||||
// Load teleported set
|
||||
if let data = storage.data(forKey: teleportedStoreKey),
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
teleportedSet = Set(arr)
|
||||
}
|
||||
|
||||
// Load bookmarks
|
||||
if let data = storage.data(forKey: bookmarksKey),
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
var seen = Set<String>()
|
||||
var list: [String] = []
|
||||
for raw in arr {
|
||||
let gh = Self.normalizeGeohash(raw)
|
||||
guard !gh.isEmpty, !seen.contains(gh) else { continue }
|
||||
seen.insert(gh)
|
||||
list.append(gh)
|
||||
}
|
||||
bookmarks = list
|
||||
bookmarkMembership = seen
|
||||
}
|
||||
|
||||
// Load bookmark names
|
||||
if let data = storage.data(forKey: bookmarkNamesKey),
|
||||
let dict = try? JSONDecoder().decode([String: String].self, from: data) {
|
||||
bookmarkNames = dict
|
||||
}
|
||||
}
|
||||
|
||||
private func initializePermissionState() {
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
updatePermissionState(from: status)
|
||||
|
||||
// Fall back to persisted teleport state if no location authorization
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
break
|
||||
case .notDetermined, .restricted, .denied:
|
||||
fallthrough
|
||||
@unknown default:
|
||||
if case .location(let ch) = selectedChannel {
|
||||
teleported = teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API (Permissions & Location)
|
||||
|
||||
func enableLocationChannels() {
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
switch status {
|
||||
case .notDetermined:
|
||||
cl.requestWhenInUseAuthorization()
|
||||
case .restricted:
|
||||
Task { @MainActor in self.permissionState = .restricted }
|
||||
case .denied:
|
||||
Task { @MainActor in self.permissionState = .denied }
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
Task { @MainActor in self.permissionState = .authorized }
|
||||
requestOneShotLocation()
|
||||
@unknown default:
|
||||
Task { @MainActor in self.permissionState = .restricted }
|
||||
}
|
||||
}
|
||||
|
||||
func refreshChannels() {
|
||||
if permissionState == .authorized {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
||||
guard permissionState == .authorized else { return }
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
|
||||
cl.startUpdatingLocation()
|
||||
requestOneShotLocation()
|
||||
}
|
||||
|
||||
func endLiveRefresh() {
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
cl.stopUpdatingLocation()
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||
}
|
||||
|
||||
// MARK: - Public API (Channel Selection)
|
||||
|
||||
func select(_ channel: ChannelID) {
|
||||
Task { @MainActor in
|
||||
self.selectedChannel = channel
|
||||
if let data = try? JSONEncoder().encode(channel) {
|
||||
self.storage.set(data, forKey: self.selectedChannelKey)
|
||||
}
|
||||
|
||||
switch channel {
|
||||
case .mesh:
|
||||
self.teleported = false
|
||||
case .location(let ch):
|
||||
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
if inRegional {
|
||||
self.teleported = false
|
||||
if self.teleportedSet.contains(ch.geohash) {
|
||||
self.teleportedSet.remove(ch.geohash)
|
||||
self.persistTeleportedSet()
|
||||
}
|
||||
} else {
|
||||
self.teleported = self.teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func markTeleported(for geohash: String, _ flag: Bool) {
|
||||
if flag {
|
||||
teleportedSet.insert(geohash)
|
||||
} else {
|
||||
teleportedSet.remove(geohash)
|
||||
}
|
||||
persistTeleportedSet()
|
||||
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
|
||||
Task { @MainActor in self.teleported = flag }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API (Bookmarks)
|
||||
|
||||
func isBookmarked(_ geohash: String) -> Bool {
|
||||
bookmarkMembership.contains(Self.normalizeGeohash(geohash))
|
||||
}
|
||||
|
||||
func toggleBookmark(_ geohash: String) {
|
||||
let gh = Self.normalizeGeohash(geohash)
|
||||
if bookmarkMembership.contains(gh) {
|
||||
removeBookmark(gh)
|
||||
} else {
|
||||
addBookmark(gh)
|
||||
}
|
||||
}
|
||||
|
||||
func addBookmark(_ geohash: String) {
|
||||
let gh = Self.normalizeGeohash(geohash)
|
||||
guard !gh.isEmpty, !bookmarkMembership.contains(gh) else { return }
|
||||
bookmarks.insert(gh, at: 0)
|
||||
bookmarkMembership.insert(gh)
|
||||
persistBookmarks()
|
||||
resolveBookmarkNameIfNeeded(for: gh)
|
||||
}
|
||||
|
||||
func removeBookmark(_ geohash: String) {
|
||||
let gh = Self.normalizeGeohash(geohash)
|
||||
guard bookmarkMembership.contains(gh) else { return }
|
||||
if let idx = bookmarks.firstIndex(of: gh) {
|
||||
bookmarks.remove(at: idx)
|
||||
}
|
||||
bookmarkMembership.remove(gh)
|
||||
if bookmarkNames.removeValue(forKey: gh) != nil {
|
||||
persistBookmarkNames()
|
||||
}
|
||||
persistBookmarks()
|
||||
}
|
||||
|
||||
// MARK: - CLLocationManagerDelegate
|
||||
|
||||
private func requestOneShotLocation() {
|
||||
cl.requestLocation()
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
||||
updatePermissionState(from: status)
|
||||
if case .authorized = permissionState {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, macOS 11.0, *)
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
updatePermissionState(from: manager.authorizationStatus)
|
||||
if case .authorized = permissionState {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let loc = locations.last else { return }
|
||||
lastLocation = loc
|
||||
computeChannels(from: loc.coordinate)
|
||||
reverseGeocodeLocation(loc)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
SecureLogger.error("LocationStateManager: location error: \(error.localizedDescription)", category: .session)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers (Permission)
|
||||
|
||||
private func updatePermissionState(from status: CLAuthorizationStatus) {
|
||||
let newState: PermissionState
|
||||
switch status {
|
||||
case .notDetermined: newState = .notDetermined
|
||||
case .restricted: newState = .restricted
|
||||
case .denied: newState = .denied
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
|
||||
@unknown default: newState = .restricted
|
||||
}
|
||||
Task { @MainActor in self.permissionState = newState }
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers (Channel Computation)
|
||||
|
||||
private func computeChannels(from coord: CLLocationCoordinate2D) {
|
||||
let levels = GeohashChannelLevel.allCases
|
||||
var result: [GeohashChannel] = []
|
||||
for level in levels {
|
||||
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
|
||||
result.append(GeohashChannel(level: level, geohash: gh))
|
||||
}
|
||||
Task { @MainActor in
|
||||
self.availableChannels = result
|
||||
switch self.selectedChannel {
|
||||
case .mesh:
|
||||
self.teleported = false
|
||||
case .location(let ch):
|
||||
let inRegional = result.contains { $0.geohash == ch.geohash }
|
||||
if inRegional {
|
||||
self.teleported = false
|
||||
if self.teleportedSet.contains(ch.geohash) {
|
||||
self.teleportedSet.remove(ch.geohash)
|
||||
self.persistTeleportedSet()
|
||||
}
|
||||
} else {
|
||||
self.teleported = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers (Geocoding)
|
||||
|
||||
private func reverseGeocodeLocation(_ location: CLLocation) {
|
||||
geocoder.cancelGeocode()
|
||||
isGeocoding = true
|
||||
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, _ in
|
||||
guard let self = self else { return }
|
||||
self.isGeocoding = false
|
||||
if let pm = placemarks?.first {
|
||||
let names = self.locationNamesByLevel(from: pm)
|
||||
Task { @MainActor in self.locationNames = names }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func locationNamesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
|
||||
var dict: [GeohashChannelLevel: String] = [:]
|
||||
if let country = pm.country, !country.isEmpty {
|
||||
dict[.region] = country
|
||||
}
|
||||
if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||
dict[.province] = admin
|
||||
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
||||
dict[.province] = subAdmin
|
||||
}
|
||||
if let locality = pm.locality, !locality.isEmpty {
|
||||
dict[.city] = locality
|
||||
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
||||
dict[.city] = subAdmin
|
||||
} else if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||
dict[.city] = admin
|
||||
}
|
||||
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||
dict[.neighborhood] = subLocality
|
||||
} else if let locality = pm.locality, !locality.isEmpty {
|
||||
dict[.neighborhood] = locality
|
||||
}
|
||||
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||
dict[.block] = subLocality
|
||||
} else if let locality = pm.locality, !locality.isEmpty {
|
||||
dict[.block] = locality
|
||||
}
|
||||
if let name = pm.name, !name.isEmpty {
|
||||
dict[.building] = name
|
||||
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
|
||||
dict[.building] = thoroughfare
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
func resolveBookmarkNameIfNeeded(for geohash: String) {
|
||||
let gh = Self.normalizeGeohash(geohash)
|
||||
guard !gh.isEmpty, bookmarkNames[gh] == nil, !resolvingNames.contains(gh) else { return }
|
||||
resolvingNames.insert(gh)
|
||||
|
||||
if gh.count <= 2 {
|
||||
let b = Geohash.decodeBounds(gh)
|
||||
let pts: [CLLocation] = [
|
||||
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2),
|
||||
CLLocation(latitude: b.latMin, longitude: b.lonMin),
|
||||
CLLocation(latitude: b.latMin, longitude: b.lonMax),
|
||||
CLLocation(latitude: b.latMax, longitude: b.lonMin),
|
||||
CLLocation(latitude: b.latMax, longitude: b.lonMax)
|
||||
]
|
||||
resolveCompositeAdminName(geohash: gh, points: pts)
|
||||
} else {
|
||||
let center = Geohash.decodeCenter(gh)
|
||||
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
|
||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||
guard let self = self else { return }
|
||||
defer { self.resolvingNames.remove(gh) }
|
||||
if let pm = placemarks?.first,
|
||||
let name = Self.nameForGeohashLength(gh.count, from: pm),
|
||||
!name.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.bookmarkNames[gh] = name
|
||||
self.persistBookmarkNames()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
||||
var uniqueAdmins: [String] = []
|
||||
var seenAdmins = Set<String>()
|
||||
var idx = 0
|
||||
|
||||
func step() {
|
||||
if idx >= points.count {
|
||||
let finalName: String? = {
|
||||
if uniqueAdmins.count >= 2 { return uniqueAdmins[0] + " and " + uniqueAdmins[1] }
|
||||
return uniqueAdmins.first
|
||||
}()
|
||||
if let finalName = finalName, !finalName.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.bookmarkNames[gh] = finalName
|
||||
self.persistBookmarkNames()
|
||||
}
|
||||
}
|
||||
self.resolvingNames.remove(gh)
|
||||
return
|
||||
}
|
||||
let loc = points[idx]
|
||||
idx += 1
|
||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||
guard self != nil else { return }
|
||||
if let pm = placemarks?.first {
|
||||
if let admin = pm.administrativeArea, !admin.isEmpty, !seenAdmins.contains(admin) {
|
||||
seenAdmins.insert(admin)
|
||||
uniqueAdmins.append(admin)
|
||||
} else if let country = pm.country, !country.isEmpty, !seenAdmins.contains(country) {
|
||||
seenAdmins.insert(country)
|
||||
uniqueAdmins.append(country)
|
||||
}
|
||||
}
|
||||
step()
|
||||
}
|
||||
}
|
||||
step()
|
||||
}
|
||||
|
||||
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
|
||||
switch len {
|
||||
case 0...2:
|
||||
return pm.administrativeArea ?? pm.country
|
||||
case 3...4:
|
||||
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
|
||||
case 5:
|
||||
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
|
||||
case 6...7:
|
||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
|
||||
default:
|
||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers (Persistence)
|
||||
|
||||
private func persistTeleportedSet() {
|
||||
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
|
||||
storage.set(data, forKey: teleportedStoreKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistBookmarks() {
|
||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||
storage.set(data, forKey: bookmarksKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistBookmarkNames() {
|
||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
||||
storage.set(data, forKey: bookmarkNamesKey)
|
||||
}
|
||||
}
|
||||
|
||||
private static func normalizeGeohash(_ s: String) -> String {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
return s
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.filter { allowed.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Backward Compatibility Typealiases
|
||||
|
||||
typealias LocationChannelManager = LocationStateManager
|
||||
typealias GeohashBookmarksStore = LocationStateManager
|
||||
|
||||
// MARK: - Backward Compatibility Extensions
|
||||
|
||||
extension LocationStateManager {
|
||||
/// Backward compatibility: toggle bookmark (was GeohashBookmarksStore.toggle)
|
||||
func toggle(_ geohash: String) {
|
||||
toggleBookmark(geohash)
|
||||
}
|
||||
|
||||
/// Backward compatibility: add bookmark (was GeohashBookmarksStore.add)
|
||||
func add(_ geohash: String) {
|
||||
addBookmark(geohash)
|
||||
}
|
||||
|
||||
/// Backward compatibility: remove bookmark (was GeohashBookmarksStore.remove)
|
||||
func remove(_ geohash: String) {
|
||||
removeBookmark(geohash)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -6,104 +6,114 @@ final class MeshTopologyTracker {
|
||||
|
||||
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
|
||||
private let hopSize = 8
|
||||
private var adjacency: [RoutingID: Set<RoutingID>] = [:]
|
||||
// Directed claims: Key claims to see Value (neighbors)
|
||||
private var claims: [RoutingID: Set<RoutingID>] = [:]
|
||||
// Last time we received an update from a node
|
||||
private var lastSeen: [RoutingID: Date] = [:]
|
||||
|
||||
// Maximum age for topology claims to be considered fresh for routing
|
||||
// Routes computed using stale topology can fail when the network has changed
|
||||
private static let routeFreshnessThreshold: TimeInterval = 60 // 60 seconds
|
||||
|
||||
func reset() {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.adjacency.removeAll()
|
||||
self.claims.removeAll()
|
||||
self.lastSeen.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func recordDirectLink(between a: Data?, and b: Data?) {
|
||||
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
|
||||
/// Update the topology with a node's self-reported neighbor list
|
||||
func updateNeighbors(for sourceData: Data?, neighbors: [Data]) {
|
||||
guard let source = sanitize(sourceData) else { return }
|
||||
// Sanitize neighbors and exclude self-loops
|
||||
let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source])
|
||||
|
||||
queue.sync(flags: .barrier) {
|
||||
var setA = self.adjacency[left] ?? []
|
||||
setA.insert(right)
|
||||
self.adjacency[left] = setA
|
||||
|
||||
var setB = self.adjacency[right] ?? []
|
||||
setB.insert(left)
|
||||
self.adjacency[right] = setB
|
||||
}
|
||||
}
|
||||
|
||||
func removeDirectLink(between a: Data?, and b: Data?) {
|
||||
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
|
||||
queue.sync(flags: .barrier) {
|
||||
if var setA = self.adjacency[left] {
|
||||
setA.remove(right)
|
||||
self.adjacency[left] = setA.isEmpty ? nil : setA
|
||||
}
|
||||
if var setB = self.adjacency[right] {
|
||||
setB.remove(left)
|
||||
self.adjacency[right] = setB.isEmpty ? nil : setB
|
||||
}
|
||||
self.claims[source] = validNeighbors
|
||||
self.lastSeen[source] = Date()
|
||||
}
|
||||
}
|
||||
|
||||
func removePeer(_ data: Data?) {
|
||||
guard let peer = sanitize(data) else { return }
|
||||
queue.sync(flags: .barrier) {
|
||||
guard let neighbors = self.adjacency.removeValue(forKey: peer) else { return }
|
||||
for neighbor in neighbors {
|
||||
if var set = self.adjacency[neighbor] {
|
||||
set.remove(peer)
|
||||
self.adjacency[neighbor] = set.isEmpty ? nil : set
|
||||
}
|
||||
}
|
||||
self.claims.removeValue(forKey: peer)
|
||||
self.lastSeen.removeValue(forKey: peer)
|
||||
}
|
||||
}
|
||||
|
||||
func recordRoute(_ hops: [Data]) {
|
||||
let sanitized = hops.compactMap { sanitize($0) }
|
||||
guard sanitized.count >= 2 else { return }
|
||||
|
||||
/// Prune nodes that haven't updated their topology in `age` seconds
|
||||
func prune(olderThan age: TimeInterval) {
|
||||
let deadline = Date().addingTimeInterval(-age)
|
||||
queue.sync(flags: .barrier) {
|
||||
for idx in 0..<(sanitized.count - 1) {
|
||||
let left = sanitized[idx]
|
||||
let right = sanitized[idx + 1]
|
||||
guard left != right else { continue }
|
||||
|
||||
var setA = self.adjacency[left] ?? []
|
||||
setA.insert(right)
|
||||
self.adjacency[left] = setA
|
||||
|
||||
var setB = self.adjacency[right] ?? []
|
||||
setB.insert(left)
|
||||
self.adjacency[right] = setB
|
||||
let stale = self.lastSeen.filter { $0.value < deadline }
|
||||
for (peer, _) in stale {
|
||||
self.claims.removeValue(forKey: peer)
|
||||
self.lastSeen.removeValue(forKey: peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 255) -> [Data]? {
|
||||
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? {
|
||||
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
|
||||
if source == target { return [source] }
|
||||
if source == target { return [] } // Direct connection, no intermediate hops
|
||||
|
||||
let graph = queue.sync { adjacency }
|
||||
guard graph[source] != nil, graph[target] != nil else { return nil }
|
||||
return queue.sync {
|
||||
let now = Date()
|
||||
let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold)
|
||||
|
||||
var visited: Set<RoutingID> = [source]
|
||||
var queuePaths: [[RoutingID]] = [[source]]
|
||||
var index = 0
|
||||
// BFS
|
||||
var visited: Set<RoutingID> = [source]
|
||||
// Queue stores paths: [Start, Hop1, Hop2, ..., Current]
|
||||
var queuePaths: [[RoutingID]] = [[source]]
|
||||
|
||||
while index < queuePaths.count {
|
||||
let path = queuePaths[index]
|
||||
index += 1
|
||||
guard path.count <= maxHops else { continue }
|
||||
guard let last = path.last, let neighbors = graph[last] else { continue }
|
||||
while !queuePaths.isEmpty {
|
||||
let path = queuePaths.removeFirst()
|
||||
// Limit path length (path contains source + maxHops + target) -> maxHops intermediate
|
||||
// If maxHops = 10, max edges = 11, max nodes = 12.
|
||||
if path.count > maxHops + 1 { continue }
|
||||
|
||||
for neighbor in neighbors {
|
||||
if visited.contains(neighbor) { continue }
|
||||
var nextPath = path
|
||||
nextPath.append(neighbor)
|
||||
if neighbor == target { return nextPath }
|
||||
if nextPath.count <= maxHops {
|
||||
guard let last = path.last else { continue }
|
||||
|
||||
// Get neighbors that 'last' claims to see
|
||||
guard let neighbors = claims[last] else { continue }
|
||||
|
||||
// Check if 'last' node's topology info is fresh
|
||||
guard let lastSeenTime = lastSeen[last], lastSeenTime > freshnessDeadline else {
|
||||
continue // Skip stale nodes
|
||||
}
|
||||
|
||||
for neighbor in neighbors {
|
||||
if visited.contains(neighbor) { continue }
|
||||
|
||||
// CONFIRMED EDGE CHECK:
|
||||
// 'last' claims 'neighbor' (checked above)
|
||||
// Does 'neighbor' claim 'last'?
|
||||
guard let neighborClaims = claims[neighbor],
|
||||
neighborClaims.contains(last) else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if 'neighbor' node's topology info is fresh
|
||||
guard let neighborSeenTime = lastSeen[neighbor], neighborSeenTime > freshnessDeadline else {
|
||||
continue // Skip edges to stale nodes
|
||||
}
|
||||
|
||||
var nextPath = path
|
||||
nextPath.append(neighbor)
|
||||
|
||||
if neighbor == target {
|
||||
// Return only intermediate hops
|
||||
// Path: [Source, I1, I2, Target] -> [I1, I2]
|
||||
return Array(nextPath.dropFirst().dropLast())
|
||||
}
|
||||
|
||||
visited.insert(neighbor)
|
||||
queuePaths.append(nextPath)
|
||||
}
|
||||
visited.insert(neighbor)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//
|
||||
// MessageDeduplicationService.swift
|
||||
// bitchat
|
||||
//
|
||||
// Handles message deduplication using LRU caches.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - LRU Deduplication Cache
|
||||
|
||||
/// Generic LRU (Least Recently Used) cache for deduplication.
|
||||
/// Uses an efficient O(1) lookup with periodic compaction.
|
||||
/// Thread-safe via @MainActor - all callers are already on main actor.
|
||||
@MainActor
|
||||
final class LRUDeduplicationCache<Value> {
|
||||
private var map: [String: Value] = [:]
|
||||
private var order: [String] = []
|
||||
private var head: Int = 0
|
||||
private let capacity: Int
|
||||
|
||||
/// Creates a new LRU cache with the specified capacity.
|
||||
/// - Parameter capacity: Maximum number of entries before eviction
|
||||
init(capacity: Int) {
|
||||
precondition(capacity > 0, "LRU cache capacity must be positive")
|
||||
self.capacity = capacity
|
||||
}
|
||||
|
||||
/// Number of active entries in the cache
|
||||
var count: Int {
|
||||
order.count - head
|
||||
}
|
||||
|
||||
/// Checks if a key exists in the cache
|
||||
func contains(_ key: String) -> Bool {
|
||||
map[key] != nil
|
||||
}
|
||||
|
||||
/// Gets the value for a key, or nil if not present
|
||||
func value(for key: String) -> Value? {
|
||||
map[key]
|
||||
}
|
||||
|
||||
/// Records a key-value pair, updating if exists or inserting if new
|
||||
func record(_ key: String, value: Value) {
|
||||
if map[key] == nil {
|
||||
order.append(key)
|
||||
}
|
||||
map[key] = value
|
||||
trimIfNeeded()
|
||||
}
|
||||
|
||||
/// Removes a specific key from the cache
|
||||
func remove(_ key: String) {
|
||||
map.removeValue(forKey: key)
|
||||
// Note: key remains in order array but will be skipped during eviction
|
||||
}
|
||||
|
||||
/// Clears all entries from the cache
|
||||
func clear() {
|
||||
map.removeAll()
|
||||
order.removeAll()
|
||||
head = 0
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func trimIfNeeded() {
|
||||
let activeCount = order.count - head
|
||||
guard activeCount > capacity else { return }
|
||||
|
||||
let overflow = activeCount - capacity
|
||||
for _ in 0..<overflow {
|
||||
guard let victim = popOldest() else { break }
|
||||
map.removeValue(forKey: victim)
|
||||
}
|
||||
}
|
||||
|
||||
private func popOldest() -> String? {
|
||||
// Skip keys that were already removed from map
|
||||
while head < order.count {
|
||||
let key = order[head]
|
||||
head += 1
|
||||
|
||||
// Periodically compact the backing storage
|
||||
if head >= 32 && head * 2 >= order.count {
|
||||
order.removeFirst(head)
|
||||
head = 0
|
||||
}
|
||||
|
||||
// Only return if key is still in map
|
||||
if map[key] != nil {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Normalizer
|
||||
|
||||
/// Normalizes message content for near-duplicate detection.
|
||||
enum ContentNormalizer {
|
||||
|
||||
/// Regex to simplify HTTP URLs by stripping query strings and fragments
|
||||
private static let simplifyHTTPURL: NSRegularExpression = {
|
||||
try! NSRegularExpression(
|
||||
pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?",
|
||||
options: [.caseInsensitive]
|
||||
)
|
||||
}()
|
||||
|
||||
/// Normalizes content for deduplication comparison.
|
||||
/// - Parameters:
|
||||
/// - content: The raw message content
|
||||
/// - prefixLength: Maximum characters to consider (default from TransportConfig)
|
||||
/// - Returns: A hash-based key for comparison
|
||||
static func normalizedKey(
|
||||
_ content: String,
|
||||
prefixLength: Int = TransportConfig.contentKeyPrefixLength
|
||||
) -> String {
|
||||
// Lowercase for case-insensitive comparison
|
||||
let lowered = content.lowercased()
|
||||
let ns = lowered as NSString
|
||||
let range = NSRange(location: 0, length: ns.length)
|
||||
|
||||
// Simplify URLs by stripping query/fragment
|
||||
var simplified = ""
|
||||
var last = 0
|
||||
for match in simplifyHTTPURL.matches(in: lowered, options: [], range: range) {
|
||||
if match.range.location > last {
|
||||
simplified += ns.substring(with: NSRange(location: last, length: match.range.location - last))
|
||||
}
|
||||
let url = ns.substring(with: match.range)
|
||||
if let queryIndex = url.firstIndex(where: { $0 == "?" || $0 == "#" }) {
|
||||
simplified += String(url[..<queryIndex])
|
||||
} else {
|
||||
simplified += url
|
||||
}
|
||||
last = match.range.location + match.range.length
|
||||
}
|
||||
if last < ns.length {
|
||||
simplified += ns.substring(with: NSRange(location: last, length: ns.length - last))
|
||||
}
|
||||
|
||||
// Trim and collapse whitespace
|
||||
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
|
||||
// Take prefix and hash
|
||||
let prefix = String(collapsed.prefix(prefixLength))
|
||||
let hash = prefix.djb2()
|
||||
return String(format: "h:%016llx", hash)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Deduplication Service
|
||||
|
||||
/// Service that manages message deduplication using LRU caches.
|
||||
/// Provides separate caches for content-based dedup and Nostr event ID dedup.
|
||||
/// Thread-safe via @MainActor - all callers are already on main actor.
|
||||
@MainActor
|
||||
final class MessageDeduplicationService {
|
||||
|
||||
/// Cache for content-based near-duplicate detection
|
||||
private let contentCache: LRUDeduplicationCache<Date>
|
||||
|
||||
/// Cache for Nostr event ID deduplication
|
||||
private let nostrEventCache: LRUDeduplicationCache<Bool>
|
||||
|
||||
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
||||
private let nostrAckCache: LRUDeduplicationCache<Bool>
|
||||
|
||||
/// Creates a new deduplication service with specified capacities.
|
||||
/// - Parameters:
|
||||
/// - contentCapacity: Max entries for content cache
|
||||
/// - nostrEventCapacity: Max entries for Nostr event cache
|
||||
init(
|
||||
contentCapacity: Int = TransportConfig.contentLRUCap,
|
||||
nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap
|
||||
) {
|
||||
self.contentCache = LRUDeduplicationCache(capacity: contentCapacity)
|
||||
self.nostrEventCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
|
||||
self.nostrAckCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
|
||||
}
|
||||
|
||||
// MARK: - Content Deduplication
|
||||
|
||||
/// Records content with its timestamp for near-duplicate detection.
|
||||
/// - Parameters:
|
||||
/// - content: The message content
|
||||
/// - timestamp: When the content was received
|
||||
func recordContent(_ content: String, timestamp: Date) {
|
||||
let key = ContentNormalizer.normalizedKey(content)
|
||||
contentCache.record(key, value: timestamp)
|
||||
}
|
||||
|
||||
/// Records a pre-normalized content key with its timestamp.
|
||||
/// - Parameters:
|
||||
/// - key: The normalized content key
|
||||
/// - timestamp: When the content was received
|
||||
func recordContentKey(_ key: String, timestamp: Date) {
|
||||
contentCache.record(key, value: timestamp)
|
||||
}
|
||||
|
||||
/// Gets the timestamp for previously seen content.
|
||||
/// - Parameter content: The message content
|
||||
/// - Returns: The timestamp when first seen, or nil if not seen
|
||||
func contentTimestamp(for content: String) -> Date? {
|
||||
let key = ContentNormalizer.normalizedKey(content)
|
||||
return contentCache.value(for: key)
|
||||
}
|
||||
|
||||
/// Gets the timestamp for a pre-normalized content key.
|
||||
/// - Parameter key: The normalized content key
|
||||
/// - Returns: The timestamp when first seen, or nil if not seen
|
||||
func contentTimestamp(forKey key: String) -> Date? {
|
||||
contentCache.value(for: key)
|
||||
}
|
||||
|
||||
/// Normalizes content to a deduplication key.
|
||||
/// - Parameter content: The raw content
|
||||
/// - Returns: A normalized hash key
|
||||
func normalizedContentKey(_ content: String) -> String {
|
||||
ContentNormalizer.normalizedKey(content)
|
||||
}
|
||||
|
||||
// MARK: - Nostr Event Deduplication
|
||||
|
||||
/// Checks if a Nostr event has already been processed.
|
||||
/// - Parameter eventId: The event ID
|
||||
/// - Returns: true if already processed
|
||||
func hasProcessedNostrEvent(_ eventId: String) -> Bool {
|
||||
nostrEventCache.contains(eventId)
|
||||
}
|
||||
|
||||
/// Records a Nostr event as processed.
|
||||
/// - Parameter eventId: The event ID
|
||||
func recordNostrEvent(_ eventId: String) {
|
||||
nostrEventCache.record(eventId, value: true)
|
||||
}
|
||||
|
||||
// MARK: - Nostr ACK Deduplication
|
||||
|
||||
/// Checks if a Nostr ACK has already been processed.
|
||||
/// - Parameter ackKey: The ACK key in format "messageId:ackType:senderPubkey"
|
||||
/// - Returns: true if already processed
|
||||
func hasProcessedNostrAck(_ ackKey: String) -> Bool {
|
||||
nostrAckCache.contains(ackKey)
|
||||
}
|
||||
|
||||
/// Records a Nostr ACK as processed.
|
||||
/// - Parameter ackKey: The ACK key in format "messageId:ackType:senderPubkey"
|
||||
func recordNostrAck(_ ackKey: String) {
|
||||
nostrAckCache.record(ackKey, value: true)
|
||||
}
|
||||
|
||||
/// Creates an ACK key from components.
|
||||
static func ackKey(messageId: String, ackType: String, senderPubkey: String) -> String {
|
||||
"\(messageId):\(ackType):\(senderPubkey)"
|
||||
}
|
||||
|
||||
// MARK: - Clear
|
||||
|
||||
/// Clears all caches
|
||||
func clearAll() {
|
||||
contentCache.clear()
|
||||
nostrEventCache.clear()
|
||||
nostrAckCache.clear()
|
||||
}
|
||||
|
||||
/// Clears only the Nostr caches (events and ACKs)
|
||||
func clearNostrCaches() {
|
||||
nostrEventCache.clear()
|
||||
nostrAckCache.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
//
|
||||
// MessageFormattingEngine.swift
|
||||
// bitchat
|
||||
//
|
||||
// Handles message text formatting, including mentions, hashtags, URLs, and tokens.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Formatting Context Protocol
|
||||
|
||||
/// Protocol defining the context needed for message formatting.
|
||||
/// Implemented by ChatViewModel to provide runtime state.
|
||||
@MainActor
|
||||
protocol MessageFormattingContext: AnyObject {
|
||||
/// The user's current nickname
|
||||
var nickname: String { get }
|
||||
|
||||
/// Determines if a message was sent by the current user
|
||||
func isSelfMessage(_ message: BitchatMessage) -> Bool
|
||||
|
||||
/// Gets the color for a message's sender
|
||||
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color
|
||||
|
||||
/// Resolves a peer ID to a clickable URL
|
||||
func peerURL(for peerID: PeerID) -> URL?
|
||||
}
|
||||
|
||||
// MARK: - Formatting Engine
|
||||
|
||||
/// Handles rich text formatting for chat messages.
|
||||
/// Extracts mentions, hashtags, URLs, Lightning invoices, and Cashu tokens.
|
||||
final class MessageFormattingEngine {
|
||||
|
||||
// MARK: - Precompiled Regexes
|
||||
|
||||
/// Precompiled regex patterns for message content parsing
|
||||
enum Patterns {
|
||||
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: [])
|
||||
}()
|
||||
|
||||
static let simplifyHTTPURL: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive])
|
||||
}()
|
||||
}
|
||||
|
||||
// MARK: - Match Types
|
||||
|
||||
/// Types of matches found in message content
|
||||
enum MatchType: String {
|
||||
case hashtag
|
||||
case mention
|
||||
case url
|
||||
case cashu
|
||||
case lightning
|
||||
case bolt11
|
||||
case lnurl
|
||||
}
|
||||
|
||||
/// A match found in message content
|
||||
struct ContentMatch {
|
||||
let range: NSRange
|
||||
let type: MatchType
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Formats a message with rich text styling
|
||||
@MainActor
|
||||
static func formatMessage(
|
||||
_ message: BitchatMessage,
|
||||
context: MessageFormattingContext,
|
||||
colorScheme: ColorScheme
|
||||
) -> AttributedString {
|
||||
let isDark = colorScheme == .dark
|
||||
let isSelf = context.isSelfMessage(message)
|
||||
|
||||
// Check cache first
|
||||
if let cached = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {
|
||||
return cached
|
||||
}
|
||||
|
||||
var result = AttributedString()
|
||||
let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark)
|
||||
|
||||
// Format system messages differently
|
||||
if message.sender == "system" {
|
||||
result = formatSystemMessage(message, isDark: isDark)
|
||||
} else {
|
||||
// Format sender header
|
||||
result = formatSenderHeader(
|
||||
message: message,
|
||||
baseColor: baseColor,
|
||||
isSelf: isSelf,
|
||||
context: context
|
||||
)
|
||||
|
||||
// Format content
|
||||
let contentResult = formatContent(
|
||||
message.content,
|
||||
baseColor: baseColor,
|
||||
isSelf: isSelf,
|
||||
isMentioned: message.mentions?.contains(context.nickname) ?? false
|
||||
)
|
||||
result.append(contentResult)
|
||||
|
||||
// Add timestamp
|
||||
result.append(formatTimestamp(message.formattedTimestamp))
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Formats just the message header (sender portion)
|
||||
@MainActor
|
||||
static func formatHeader(
|
||||
_ message: BitchatMessage,
|
||||
context: MessageFormattingContext,
|
||||
colorScheme: ColorScheme
|
||||
) -> AttributedString {
|
||||
let isDark = colorScheme == .dark
|
||||
let isSelf = context.isSelfMessage(message)
|
||||
let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark)
|
||||
|
||||
if message.sender == "system" {
|
||||
var style = AttributeContainer()
|
||||
style.foregroundColor = baseColor
|
||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
||||
return AttributedString(message.sender).mergingAttributes(style)
|
||||
}
|
||||
|
||||
return formatSenderHeader(
|
||||
message: message,
|
||||
baseColor: baseColor,
|
||||
isSelf: isSelf,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
/// Extracts mentions from message content
|
||||
static func extractMentions(from content: String) -> [String] {
|
||||
let nsContent = content as NSString
|
||||
let range = NSRange(location: 0, length: nsContent.length)
|
||||
let matches = Patterns.mention.matches(in: content, options: [], range: range)
|
||||
|
||||
return matches.compactMap { match -> String? in
|
||||
guard match.numberOfRanges > 1 else { return nil }
|
||||
let captureRange = match.range(at: 1)
|
||||
guard let swiftRange = Range(captureRange, in: content) else { return nil }
|
||||
return String(content[swiftRange])
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if content contains a Cashu token
|
||||
static func containsCashuToken(_ content: String) -> Bool {
|
||||
let nsContent = content as NSString
|
||||
let range = NSRange(location: 0, length: nsContent.length)
|
||||
return Patterns.quickCashuPresence.numberOfMatches(in: content, options: [], range: range) > 0
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private static func formatSystemMessage(_ message: BitchatMessage, isDark: Bool) -> AttributedString {
|
||||
var result = AttributedString()
|
||||
|
||||
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))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func formatSenderHeader(
|
||||
message: BitchatMessage,
|
||||
baseColor: Color,
|
||||
isSelf: Bool,
|
||||
context: MessageFormattingContext
|
||||
) -> AttributedString {
|
||||
var result = AttributedString()
|
||||
|
||||
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
|
||||
if let spid = message.senderPeerID, let url = context.peerURL(for: spid) {
|
||||
senderStyle.link = url
|
||||
}
|
||||
|
||||
// Build: "<@baseName#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))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func formatContent(
|
||||
_ content: String,
|
||||
baseColor: Color,
|
||||
isSelf: Bool,
|
||||
isMentioned: Bool
|
||||
) -> AttributedString {
|
||||
// For very long content without special tokens, use plain formatting
|
||||
let containsCashu = containsCashuToken(content)
|
||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
|
||||
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
||||
}
|
||||
|
||||
// Find all matches
|
||||
let matches = findAllMatches(in: content)
|
||||
|
||||
// Build formatted content
|
||||
var result = AttributedString()
|
||||
var lastEnd = content.startIndex
|
||||
|
||||
for match in matches {
|
||||
guard let swiftRange = Range(match.range, in: content) else { continue }
|
||||
|
||||
// Add text before match
|
||||
if lastEnd < swiftRange.lowerBound {
|
||||
let beforeText = String(content[lastEnd..<swiftRange.lowerBound])
|
||||
result.append(formatPlainText(beforeText, baseColor: baseColor, isSelf: isSelf, isMentioned: isMentioned))
|
||||
}
|
||||
|
||||
// Add styled match
|
||||
let matchText = String(content[swiftRange])
|
||||
result.append(formatMatch(matchText, type: match.type, baseColor: baseColor, isSelf: isSelf))
|
||||
|
||||
lastEnd = swiftRange.upperBound
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if lastEnd < content.endIndex {
|
||||
let remainingText = String(content[lastEnd...])
|
||||
result.append(formatPlainText(remainingText, baseColor: baseColor, isSelf: isSelf, isMentioned: isMentioned))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func findAllMatches(in content: String) -> [ContentMatch] {
|
||||
let nsContent = content as NSString
|
||||
let nsLen = nsContent.length
|
||||
let fullRange = NSRange(location: 0, length: nsLen)
|
||||
|
||||
// Quick hints to avoid unnecessary regex work
|
||||
let hasMentions = content.contains("@")
|
||||
let hasHashtags = content.contains("#")
|
||||
let hasURLs = content.contains("://") || content.contains("www.") || content.contains("http")
|
||||
let hasLightning = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
|
||||
let hasCashu = content.lowercased().contains("cashu")
|
||||
|
||||
// Collect matches
|
||||
let mentionMatches = hasMentions ? Patterns.mention.matches(in: content, options: [], range: fullRange) : []
|
||||
let hashtagMatches = hasHashtags ? Patterns.hashtag.matches(in: content, options: [], range: fullRange) : []
|
||||
let urlMatches = hasURLs ? (Patterns.linkDetector?.matches(in: content, options: [], range: fullRange) ?? []) : []
|
||||
let cashuMatches = hasCashu ? Patterns.cashu.matches(in: content, options: [], range: fullRange) : []
|
||||
let lightningMatches = hasLightning ? Patterns.lightningScheme.matches(in: content, options: [], range: fullRange) : []
|
||||
let bolt11Matches = hasLightning ? Patterns.bolt11.matches(in: content, options: [], range: fullRange) : []
|
||||
let lnurlMatches = hasLightning ? Patterns.lnurl.matches(in: content, options: [], range: fullRange) : []
|
||||
|
||||
// Build mention ranges for overlap checking
|
||||
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
|
||||
|
||||
func overlapsMention(_ r: NSRange) -> Bool {
|
||||
mentionRanges.contains { NSIntersectionRange(r, $0).length > 0 }
|
||||
}
|
||||
|
||||
func isStandaloneHashtag(_ r: NSRange) -> Bool {
|
||||
guard let swiftRange = Range(r, in: content) else { return false }
|
||||
if swiftRange.lowerBound == content.startIndex { return true }
|
||||
let prev = content.index(before: swiftRange.lowerBound)
|
||||
return content[prev].isWhitespace || content[prev].isNewline
|
||||
}
|
||||
|
||||
func attachedToMention(_ r: NSRange) -> Bool {
|
||||
guard let swiftRange = Range(r, in: content), swiftRange.lowerBound > content.startIndex else { return false }
|
||||
var i = content.index(before: swiftRange.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
|
||||
}
|
||||
|
||||
var allMatches: [ContentMatch] = []
|
||||
|
||||
// Add hashtags (excluding those attached to mentions)
|
||||
for match in hashtagMatches {
|
||||
let range = match.range(at: 0)
|
||||
if !overlapsMention(range) && !attachedToMention(range) && isStandaloneHashtag(range) {
|
||||
allMatches.append(ContentMatch(range: range, type: .hashtag))
|
||||
}
|
||||
}
|
||||
|
||||
// Add mentions
|
||||
for match in mentionMatches {
|
||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .mention))
|
||||
}
|
||||
|
||||
// Add URLs
|
||||
for match in urlMatches where !overlapsMention(match.range) {
|
||||
allMatches.append(ContentMatch(range: match.range, type: .url))
|
||||
}
|
||||
|
||||
// Add Cashu tokens
|
||||
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
|
||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .cashu))
|
||||
}
|
||||
|
||||
// Add Lightning scheme URLs
|
||||
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
|
||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .lightning))
|
||||
}
|
||||
|
||||
// Add bolt11/lnurl (avoiding overlaps with lightning scheme and URLs)
|
||||
let occupied = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) }
|
||||
func overlapsOccupied(_ r: NSRange) -> Bool {
|
||||
occupied.contains { NSIntersectionRange(r, $0).length > 0 }
|
||||
}
|
||||
|
||||
for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .bolt11))
|
||||
}
|
||||
|
||||
for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .lnurl))
|
||||
}
|
||||
|
||||
// Sort by position
|
||||
return allMatches.sorted { $0.range.location < $1.range.location }
|
||||
}
|
||||
|
||||
private static func formatPlainContent(_ content: String, baseColor: Color, isSelf: Bool) -> AttributedString {
|
||||
var style = AttributeContainer()
|
||||
style.foregroundColor = baseColor
|
||||
style.font = isSelf
|
||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||
: .bitchatSystem(size: 14, design: .monospaced)
|
||||
return AttributedString(content).mergingAttributes(style)
|
||||
}
|
||||
|
||||
private static func formatPlainText(_ text: String, baseColor: Color, isSelf: Bool, isMentioned: Bool) -> AttributedString {
|
||||
guard !text.isEmpty else { return AttributedString() }
|
||||
|
||||
var style = AttributeContainer()
|
||||
style.foregroundColor = baseColor
|
||||
style.font = isSelf
|
||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||
: .bitchatSystem(size: 14, design: .monospaced)
|
||||
|
||||
if isMentioned {
|
||||
style.font = style.font?.bold()
|
||||
}
|
||||
|
||||
return AttributedString(text).mergingAttributes(style)
|
||||
}
|
||||
|
||||
private static func formatMatch(_ text: String, type: MatchType, baseColor: Color, isSelf: Bool) -> AttributedString {
|
||||
var style = AttributeContainer()
|
||||
|
||||
switch type {
|
||||
case .mention:
|
||||
// Split optional '#abcd' suffix
|
||||
let (baseName, suffix) = text.splitSuffix()
|
||||
var result = AttributedString()
|
||||
|
||||
var mentionStyle = AttributeContainer()
|
||||
mentionStyle.foregroundColor = .blue
|
||||
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
|
||||
result.append(AttributedString(baseName).mergingAttributes(mentionStyle))
|
||||
|
||||
if !suffix.isEmpty {
|
||||
var suffixStyle = mentionStyle
|
||||
suffixStyle.foregroundColor = Color.gray.opacity(0.7)
|
||||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
case .hashtag:
|
||||
style.foregroundColor = .purple
|
||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
||||
|
||||
case .url:
|
||||
style.foregroundColor = .blue
|
||||
style.font = .bitchatSystem(size: 14, design: .monospaced)
|
||||
style.underlineStyle = .single
|
||||
if let url = URL(string: text) {
|
||||
style.link = url
|
||||
}
|
||||
|
||||
case .cashu:
|
||||
style.foregroundColor = .green
|
||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
||||
style.backgroundColor = Color.green.opacity(0.1)
|
||||
|
||||
case .lightning, .bolt11, .lnurl:
|
||||
style.foregroundColor = .yellow
|
||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
||||
style.backgroundColor = Color.yellow.opacity(0.1)
|
||||
}
|
||||
|
||||
return AttributedString(text).mergingAttributes(style)
|
||||
}
|
||||
|
||||
private static func formatTimestamp(_ timestamp: String) -> AttributedString {
|
||||
let text = AttributedString(" [\(timestamp)]")
|
||||
var style = AttributeContainer()
|
||||
style.foregroundColor = Color.gray.opacity(0.5)
|
||||
style.font = .bitchatSystem(size: 10, design: .monospaced)
|
||||
return text.mergingAttributes(style)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,27 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Routes messages between BLE and Nostr transports
|
||||
/// Routes messages using available transports (Mesh, Nostr, etc.)
|
||||
@MainActor
|
||||
final class MessageRouter {
|
||||
private let mesh: Transport
|
||||
private let nostr: NostrTransport
|
||||
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||
private let transports: [Transport]
|
||||
|
||||
init(mesh: Transport, nostr: NostrTransport) {
|
||||
self.mesh = mesh
|
||||
self.nostr = nostr
|
||||
self.nostr.senderPeerID = mesh.myPeerID
|
||||
// Outbox entry with timestamp for TTL-based eviction
|
||||
private struct QueuedMessage {
|
||||
let content: String
|
||||
let nickname: String
|
||||
let messageID: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||
|
||||
// Outbox limits to prevent unbounded memory growth
|
||||
private static let maxMessagesPerPeer = 100
|
||||
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
|
||||
|
||||
init(transports: [Transport]) {
|
||||
self.transports = transports
|
||||
|
||||
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||
NotificationCenter.default.addObserver(
|
||||
@@ -37,89 +47,87 @@ final class MessageRouter {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transport Selection
|
||||
|
||||
private func reachableTransport(for peerID: PeerID) -> Transport? {
|
||||
transports.first { $0.isPeerReachable(peerID) }
|
||||
}
|
||||
|
||||
private func connectedTransport(for peerID: PeerID) -> Transport? {
|
||||
transports.first { $0.isPeerConnected(peerID) }
|
||||
}
|
||||
|
||||
// MARK: - Message Sending
|
||||
|
||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
let reachableMesh = mesh.isPeerReachable(peerID)
|
||||
if reachableMesh {
|
||||
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
// BLEService will initiate a handshake if needed and queue the message
|
||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
} else if canSendViaNostr(peerID: peerID) {
|
||||
SecureLogger.debug("Routing PM via Nostr to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
if let transport = reachableTransport(for: peerID) {
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
} else {
|
||||
// Queue for later (when mesh connects or Nostr mapping appears)
|
||||
// Queue for later with timestamp for TTL tracking
|
||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||
outbox[peerID]?.append((content, recipientNickname, messageID))
|
||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
||||
|
||||
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date())
|
||||
outbox[peerID]?.append(message)
|
||||
|
||||
// Enforce per-peer size limit with FIFO eviction
|
||||
if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer {
|
||||
let evicted = outbox[peerID]?.removeFirst()
|
||||
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted?.messageID.prefix(8) ?? "?")…", category: .session)
|
||||
}
|
||||
|
||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
mesh.sendReadReceipt(receipt, to: peerID)
|
||||
} else {
|
||||
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
nostr.sendReadReceipt(receipt, to: peerID)
|
||||
if let transport = reachableTransport(for: peerID) {
|
||||
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
transport.sendReadReceipt(receipt, to: peerID)
|
||||
} else if !transports.isEmpty {
|
||||
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))…", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||
} else {
|
||||
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||
if let transport = reachableTransport(for: peerID) {
|
||||
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendDeliveryAck(for: messageID, to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
// Route via mesh when connected; else use Nostr
|
||||
if mesh.isPeerConnected(peerID) {
|
||||
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
} else {
|
||||
nostr.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
if let transport = connectedTransport(for: peerID) {
|
||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
} else if let transport = reachableTransport(for: peerID) {
|
||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Outbox Management
|
||||
private func canSendViaNostr(peerID: PeerID) -> Bool {
|
||||
// Two forms are supported:
|
||||
// - 64-hex Noise public key (32 bytes)
|
||||
// - 16-hex short peer ID (derived from Noise pubkey)
|
||||
if let noiseKey = peerID.noiseKey {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
fav.peerNostrPublicKey != nil {
|
||||
return true
|
||||
}
|
||||
} else if peerID.isShort {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||
fav.peerNostrPublicKey != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func flushOutbox(for peerID: PeerID) {
|
||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||
for (content, nickname, messageID) in queued {
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.debug("Outbox -> mesh for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
} else if canSendViaNostr(peerID: peerID) {
|
||||
SecureLogger.debug("Outbox -> Nostr for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
|
||||
let now = Date()
|
||||
var remaining: [QueuedMessage] = []
|
||||
|
||||
for message in queued {
|
||||
// Skip expired messages (TTL exceeded)
|
||||
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
||||
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
|
||||
continue
|
||||
}
|
||||
|
||||
if let transport = reachableTransport(for: peerID) {
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||
} else {
|
||||
// Keep unsent items queued
|
||||
remaining.append((content, nickname, messageID))
|
||||
remaining.append(message)
|
||||
}
|
||||
}
|
||||
// Persist only items we could not send
|
||||
|
||||
if remaining.isEmpty {
|
||||
outbox.removeValue(forKey: peerID)
|
||||
} else {
|
||||
@@ -130,4 +138,15 @@ final class MessageRouter {
|
||||
func flushAllOutbox() {
|
||||
for key in Array(outbox.keys) { flushOutbox(for: key) }
|
||||
}
|
||||
|
||||
/// Periodically clean up expired messages from all outboxes
|
||||
func cleanupExpiredMessages() {
|
||||
let now = Date()
|
||||
for peerID in Array(outbox.keys) {
|
||||
outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds }
|
||||
if outbox[peerID]?.isEmpty == true {
|
||||
outbox.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,64 +199,153 @@ final class NoiseEncryptionService {
|
||||
|
||||
init(keychain: KeychainManagerProtocol) {
|
||||
self.keychain = keychain
|
||||
|
||||
// Load or create static identity key (ONLY from keychain)
|
||||
|
||||
// BCH-01-009: Load or create static identity key with proper error handling
|
||||
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
||||
|
||||
// Try to load from keychain
|
||||
if let identityData = keychain.getIdentityKey(forKey: "noiseStaticKey"),
|
||||
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
|
||||
loadedKey = key
|
||||
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
|
||||
}
|
||||
// If no identity exists, create new one
|
||||
else {
|
||||
|
||||
// Try to load from keychain with proper error classification
|
||||
let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey")
|
||||
|
||||
switch noiseKeyResult {
|
||||
case .success(let identityData):
|
||||
if let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
|
||||
loadedKey = key
|
||||
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
|
||||
} else {
|
||||
// Data corrupted, regenerate
|
||||
SecureLogger.warning("Noise static key data corrupted, regenerating", category: .keychain)
|
||||
loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain)
|
||||
}
|
||||
|
||||
case .itemNotFound:
|
||||
// Expected case: no key exists yet, create new one
|
||||
loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain)
|
||||
|
||||
case .accessDenied:
|
||||
// Critical error - log but proceed with ephemeral key (will be lost on restart)
|
||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
||||
context: "Keychain access denied - using ephemeral identity", category: .keychain)
|
||||
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
case .deviceLocked, .authenticationFailed:
|
||||
// Recoverable error - use ephemeral key and warn
|
||||
SecureLogger.warning("Device locked or auth failed - using ephemeral identity until unlocked", category: .keychain)
|
||||
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
case .otherError(let status):
|
||||
// Unexpected error - log and use ephemeral key
|
||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
||||
context: "Unexpected keychain error - using ephemeral identity", category: .keychain)
|
||||
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let keyData = loadedKey.rawRepresentation
|
||||
|
||||
// Save to keychain
|
||||
let saved = keychain.saveIdentityKey(keyData, forKey: "noiseStaticKey")
|
||||
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved)
|
||||
}
|
||||
|
||||
|
||||
// Now assign the final value
|
||||
self.staticIdentityKey = loadedKey
|
||||
self.staticIdentityPublicKey = staticIdentityKey.publicKey
|
||||
|
||||
// Load or create signing key pair
|
||||
|
||||
// BCH-01-009: Load or create signing key pair with proper error handling
|
||||
let loadedSigningKey: Curve25519.Signing.PrivateKey
|
||||
|
||||
// Try to load from keychain
|
||||
if let signingData = keychain.getIdentityKey(forKey: "ed25519SigningKey"),
|
||||
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
|
||||
loadedSigningKey = key
|
||||
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
|
||||
}
|
||||
// If no signing key exists, create new one
|
||||
else {
|
||||
|
||||
let signingKeyResult = keychain.getIdentityKeyWithResult(forKey: "ed25519SigningKey")
|
||||
|
||||
switch signingKeyResult {
|
||||
case .success(let signingData):
|
||||
if let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
|
||||
loadedSigningKey = key
|
||||
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
|
||||
} else {
|
||||
// Data corrupted, regenerate
|
||||
SecureLogger.warning("Ed25519 signing key data corrupted, regenerating", category: .keychain)
|
||||
loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain)
|
||||
}
|
||||
|
||||
case .itemNotFound:
|
||||
// Expected case: no key exists yet, create new one
|
||||
loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain)
|
||||
|
||||
case .accessDenied:
|
||||
// Critical error - log but proceed with ephemeral key
|
||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
||||
context: "Keychain access denied - using ephemeral signing key", category: .keychain)
|
||||
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
||||
|
||||
case .deviceLocked, .authenticationFailed:
|
||||
// Recoverable error - use ephemeral key and warn
|
||||
SecureLogger.warning("Device locked or auth failed - using ephemeral signing key until unlocked", category: .keychain)
|
||||
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
||||
|
||||
case .otherError(let status):
|
||||
// Unexpected error - log and use ephemeral key
|
||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
||||
context: "Unexpected keychain error - using ephemeral signing key", category: .keychain)
|
||||
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
||||
let keyData = loadedSigningKey.rawRepresentation
|
||||
|
||||
// Save to keychain
|
||||
let saved = keychain.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
|
||||
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved)
|
||||
}
|
||||
|
||||
|
||||
// Now assign the signing keys
|
||||
self.signingKey = loadedSigningKey
|
||||
self.signingPublicKey = signingKey.publicKey
|
||||
|
||||
|
||||
// Initialize session manager
|
||||
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
||||
|
||||
|
||||
// Set up session callbacks
|
||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
||||
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
|
||||
}
|
||||
|
||||
|
||||
// Start session maintenance timer
|
||||
startRekeyTimer()
|
||||
}
|
||||
|
||||
// MARK: - BCH-01-009: Key Generation Helpers with Save Verification
|
||||
|
||||
/// Generate and save a new Noise static key, verifying the save succeeds
|
||||
private static func generateAndSaveNoiseKey(keychain: KeychainManagerProtocol) -> Curve25519.KeyAgreement.PrivateKey {
|
||||
let newKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let keyData = newKey.rawRepresentation
|
||||
|
||||
// Save to keychain and verify success
|
||||
let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "noiseStaticKey")
|
||||
|
||||
switch saveResult {
|
||||
case .success:
|
||||
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: true)
|
||||
case .duplicateItem:
|
||||
// This shouldn't happen since we just tried to load, but handle it
|
||||
SecureLogger.warning("Noise key already exists (race condition?)", category: .keychain)
|
||||
default:
|
||||
// Save failed - log but continue with the key (it will be ephemeral)
|
||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
||||
context: "Failed to persist noise static key - identity will be lost on restart",
|
||||
category: .keychain)
|
||||
}
|
||||
|
||||
return newKey
|
||||
}
|
||||
|
||||
/// Generate and save a new Ed25519 signing key, verifying the save succeeds
|
||||
private static func generateAndSaveSigningKey(keychain: KeychainManagerProtocol) -> Curve25519.Signing.PrivateKey {
|
||||
let newKey = Curve25519.Signing.PrivateKey()
|
||||
let keyData = newKey.rawRepresentation
|
||||
|
||||
// Save to keychain and verify success
|
||||
let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "ed25519SigningKey")
|
||||
|
||||
switch saveResult {
|
||||
case .success:
|
||||
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: true)
|
||||
case .duplicateItem:
|
||||
// This shouldn't happen since we just tried to load, but handle it
|
||||
SecureLogger.warning("Signing key already exists (race condition?)", category: .keychain)
|
||||
default:
|
||||
// Save failed - log but continue with the key (it will be ephemeral)
|
||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
||||
context: "Failed to persist signing key - identity will be lost on restart",
|
||||
category: .keychain)
|
||||
}
|
||||
|
||||
return newKey
|
||||
}
|
||||
|
||||
// MARK: - Public Interface
|
||||
|
||||
@@ -527,6 +616,17 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
rateLimiter.resetAll()
|
||||
}
|
||||
|
||||
/// Clear session for a specific peer (e.g., on decryption failure to allow re-handshake)
|
||||
func clearSession(for peerID: PeerID) {
|
||||
sessionManager.removeSession(for: peerID)
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
if let fingerprint = peerFingerprints.removeValue(forKey: peerID) {
|
||||
fingerprintToPeerID.removeValue(forKey: fingerprint)
|
||||
}
|
||||
}
|
||||
SecureLogger.debug("🔓 Cleared Noise session for \(peerID)", category: .session)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import Foundation
|
||||
import Combine
|
||||
|
||||
// Minimal Nostr transport conforming to Transport for offline sending
|
||||
final class NostrTransport: Transport {
|
||||
final class NostrTransport: Transport, @unchecked Sendable {
|
||||
// Provide BLE short peer ID for BitChat embedding
|
||||
var senderPeerID = PeerID(str: "")
|
||||
|
||||
@@ -18,9 +18,49 @@ final class NostrTransport: Transport {
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let idBridge: NostrIdentityBridge
|
||||
|
||||
// Reachability Cache (thread-safe)
|
||||
private var reachablePeers: Set<PeerID> = []
|
||||
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
|
||||
|
||||
@MainActor
|
||||
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
||||
self.keychain = keychain
|
||||
self.idBridge = idBridge
|
||||
|
||||
setupObservers()
|
||||
|
||||
// Synchronously warm the cache to avoid startup race
|
||||
let favorites = FavoritesPersistenceService.shared.favorites
|
||||
let reachable = favorites.values
|
||||
.filter { $0.peerNostrPublicKey != nil }
|
||||
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
||||
|
||||
queue.sync(flags: .barrier) {
|
||||
self.reachablePeers = Set(reachable)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupObservers() {
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .favoriteStatusChanged,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { [weak self] _ in
|
||||
self?.refreshReachablePeers()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshReachablePeers() {
|
||||
Task { @MainActor in
|
||||
let favorites = FavoritesPersistenceService.shared.favorites
|
||||
let reachable = favorites.values
|
||||
.filter { $0.peerNostrPublicKey != nil }
|
||||
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
||||
|
||||
self.queue.async(flags: .barrier) { [weak self] in
|
||||
self?.reachablePeers = Set(reachable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transport Protocol Conformance
|
||||
@@ -42,7 +82,19 @@ final class NostrTransport: Transport {
|
||||
func emergencyDisconnectAll() { /* no-op */ }
|
||||
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool { false }
|
||||
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
||||
queue.sync {
|
||||
// Check if exact match
|
||||
if reachablePeers.contains(peerID) { return true }
|
||||
// Check for short ID match
|
||||
if peerID.isShort {
|
||||
return reachablePeers.contains(where: { $0.toShort() == peerID })
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||
func getPeerNicknames() -> [PeerID : String] { [:] }
|
||||
|
||||
@@ -66,89 +118,54 @@ final class NostrTransport: Transport {
|
||||
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
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)
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else {
|
||||
SecureLogger.error("NostrTransport: recipient key not npub (hrp=\(hrp))", category: .session)
|
||||
return
|
||||
}
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch {
|
||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||
return
|
||||
}
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for PM", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
// Enqueue and process with throttling to avoid relay rate limits
|
||||
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||
processReadQueueIfNeeded()
|
||||
// Use barrier to synchronize access to readQueue
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||
self?.processReadQueueIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||
// Convert recipient npub -> hex
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { return }
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { return }
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session)
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,21 +177,17 @@ extension NostrTransport {
|
||||
// MARK: Geohash ACK helpers
|
||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,19 +195,12 @@ extension NostrTransport {
|
||||
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard !recipientHex.isEmpty else { return }
|
||||
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
// Build embedded BitChat packet without recipient peer ID
|
||||
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,46 +208,63 @@ extension NostrTransport {
|
||||
// MARK: - Private Helpers
|
||||
|
||||
extension NostrTransport {
|
||||
/// Converts npub bech32 string to hex pubkey
|
||||
@MainActor
|
||||
private func npubToHex(_ npub: String) -> String? {
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(npub)
|
||||
guard hrp == "npub" else { return nil }
|
||||
return data.hexEncodedString()
|
||||
} catch {
|
||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates and sends a gift-wrapped private message event
|
||||
@MainActor
|
||||
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
|
||||
return
|
||||
}
|
||||
if registerPending {
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
}
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
|
||||
/// Must be called within a barrier on `queue`
|
||||
private func processReadQueueIfNeeded() {
|
||||
guard !isSendingReadAcks else { return }
|
||||
guard !readQueue.isEmpty else { return }
|
||||
isSendingReadAcks = true
|
||||
sendNextReadAck()
|
||||
let item = readQueue.removeFirst()
|
||||
sendReadAckItem(item)
|
||||
}
|
||||
|
||||
private func sendNextReadAck() {
|
||||
guard !readQueue.isEmpty else { isSendingReadAcks = false; return }
|
||||
let item = readQueue.removeFirst()
|
||||
/// Sends a single read ack item (called after extraction from queue within barrier)
|
||||
private func sendReadAckItem(_ item: QueuedRead) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
||||
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||
// Convert recipient npub -> hex
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { scheduleNextReadAck(); return }
|
||||
defer { scheduleNextReadAck() }
|
||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||
scheduleNextReadAck(); return
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event for READ ack", category: .session)
|
||||
scheduleNextReadAck(); return
|
||||
}
|
||||
SecureLogger.debug("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
scheduleNextReadAck()
|
||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleNextReadAck() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.isSendingReadAcks = false
|
||||
self.processReadQueueIfNeeded()
|
||||
self?.queue.async(flags: .barrier) { [weak self] in
|
||||
self?.isSendingReadAcks = false
|
||||
self?.processReadQueueIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,21 @@ import AppKit
|
||||
|
||||
final class NotificationService {
|
||||
static let shared = NotificationService()
|
||||
|
||||
|
||||
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
|
||||
private var isRunningTests: Bool {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
return NSClassFromString("XCTestCase") != nil ||
|
||||
env["XCTestConfigurationFilePath"] != nil ||
|
||||
env["XCTestBundlePath"] != nil ||
|
||||
env["GITHUB_ACTIONS"] != nil ||
|
||||
env["CI"] != nil
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
|
||||
func requestAuthorization() {
|
||||
guard !isRunningTests else { return }
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||
if granted {
|
||||
// Permission granted
|
||||
@@ -36,6 +47,7 @@ final class NotificationService {
|
||||
userInfo: [String: Any]? = nil,
|
||||
interruptionLevel: UNNotificationInterruptionLevel = .active
|
||||
) {
|
||||
guard !isRunningTests else { return }
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
|
||||
@@ -62,6 +62,7 @@ struct NotificationStreamAssembler {
|
||||
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
||||
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
||||
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
|
||||
let hasRoute = (version >= 2) && (flags & BinaryProtocol.Flags.hasRoute) != 0
|
||||
|
||||
let lengthOffset = 12
|
||||
let payloadLength: Int
|
||||
@@ -80,6 +81,15 @@ struct NotificationStreamAssembler {
|
||||
var frameLength = framePrefix + payloadLength
|
||||
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
||||
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
||||
|
||||
if hasRoute {
|
||||
let routeCountOffset = framePrefix + (hasRecipient ? BinaryProtocol.recipientIDSize : 0)
|
||||
let routeCountIndex = buffer.startIndex + routeCountOffset
|
||||
guard buffer.count > routeCountOffset else { break }
|
||||
let routeCount = Int(buffer[routeCountIndex])
|
||||
frameLength += 1 + (routeCount * BinaryProtocol.senderIDSize)
|
||||
}
|
||||
|
||||
if isCompressed {
|
||||
let rawLengthFieldBytes = (version == 2) ? 4 : 2
|
||||
if payloadLength < rawLengthFieldBytes {
|
||||
|
||||
@@ -15,20 +15,174 @@ final class PrivateChatManager: ObservableObject {
|
||||
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||
@Published var selectedPeer: PeerID? = nil
|
||||
@Published var unreadMessages: Set<PeerID> = []
|
||||
|
||||
|
||||
private var selectedPeerFingerprint: String? = nil
|
||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||
|
||||
|
||||
weak var meshService: Transport?
|
||||
// Route acks/receipts via MessageRouter (chooses mesh or Nostr)
|
||||
weak var messageRouter: MessageRouter?
|
||||
|
||||
// Peer service for looking up peer info during consolidation
|
||||
weak var unifiedPeerService: UnifiedPeerService?
|
||||
|
||||
init(meshService: Transport? = nil) {
|
||||
self.meshService = meshService
|
||||
}
|
||||
|
||||
// Cap for messages stored per private chat
|
||||
private let privateChatCap = TransportConfig.privateChatCap
|
||||
|
||||
// MARK: - Message Consolidation
|
||||
|
||||
/// Consolidates messages from different peer ID representations into a single chat.
|
||||
/// This ensures messages from stable Noise keys and temporary Nostr peer IDs are merged.
|
||||
/// - Parameters:
|
||||
/// - peerID: The target peer ID to consolidate messages into
|
||||
/// - peerNickname: The peer's display name (lowercased for matching)
|
||||
/// - persistedReadReceipts: The persisted read receipts set from ChatViewModel (UserDefaults-backed)
|
||||
/// - Returns: True if any unread messages were found during consolidation
|
||||
@MainActor
|
||||
func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set<String>) -> Bool {
|
||||
guard let meshService = meshService else { return false }
|
||||
var hasUnreadMessages = false
|
||||
|
||||
// 1. Consolidate from stable Noise key (64-char hex)
|
||||
if let peer = unifiedPeerService?.getPeer(by: peerID) {
|
||||
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
||||
|
||||
if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty {
|
||||
if privateChats[peerID] == nil {
|
||||
privateChats[peerID] = []
|
||||
}
|
||||
|
||||
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
||||
for message in nostrMessages {
|
||||
if !existingMessageIds.contains(message.id) {
|
||||
// Update senderPeerID for correct read receipts
|
||||
let updatedMessage = BitchatMessage(
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
isRelay: message.isRelay,
|
||||
originalSender: message.originalSender,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientNickname: message.recipientNickname,
|
||||
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
|
||||
mentions: message.mentions,
|
||||
deliveryStatus: message.deliveryStatus
|
||||
)
|
||||
privateChats[peerID]?.append(updatedMessage)
|
||||
|
||||
// Check for recent unread messages (< 60s, not sent by us, not already read)
|
||||
// Use persistedReadReceipts to correctly identify already-read messages after app restart
|
||||
if message.senderPeerID != meshService.myPeerID {
|
||||
let messageAge = Date().timeIntervalSince(message.timestamp)
|
||||
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
|
||||
hasUnreadMessages = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
||||
|
||||
if hasUnreadMessages {
|
||||
unreadMessages.insert(peerID)
|
||||
} else if unreadMessages.contains(noiseKeyHex) {
|
||||
unreadMessages.remove(noiseKeyHex)
|
||||
}
|
||||
|
||||
privateChats.removeValue(forKey: noiseKeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Consolidate from temporary Nostr peer IDs (nostr_* prefixed)
|
||||
let normalizedNickname = peerNickname.lowercased()
|
||||
var tempPeerIDsToConsolidate: [PeerID] = []
|
||||
|
||||
for (storedPeerID, messages) in privateChats {
|
||||
if storedPeerID.isGeoDM && storedPeerID != peerID {
|
||||
let nicknamesMatch = messages.allSatisfy { $0.sender.lowercased() == normalizedNickname }
|
||||
if nicknamesMatch && !messages.isEmpty {
|
||||
tempPeerIDsToConsolidate.append(storedPeerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !tempPeerIDsToConsolidate.isEmpty {
|
||||
if privateChats[peerID] == nil {
|
||||
privateChats[peerID] = []
|
||||
}
|
||||
|
||||
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
||||
var consolidatedCount = 0
|
||||
var hadUnreadTemp = false
|
||||
|
||||
for tempPeerID in tempPeerIDsToConsolidate {
|
||||
if unreadMessages.contains(tempPeerID) {
|
||||
hadUnreadTemp = true
|
||||
}
|
||||
|
||||
if let tempMessages = privateChats[tempPeerID] {
|
||||
for message in tempMessages {
|
||||
if !existingMessageIds.contains(message.id) {
|
||||
let updatedMessage = BitchatMessage(
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
isRelay: message.isRelay,
|
||||
originalSender: message.originalSender,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientNickname: message.recipientNickname,
|
||||
senderPeerID: peerID,
|
||||
mentions: message.mentions,
|
||||
deliveryStatus: message.deliveryStatus
|
||||
)
|
||||
privateChats[peerID]?.append(updatedMessage)
|
||||
consolidatedCount += 1
|
||||
}
|
||||
}
|
||||
privateChats.removeValue(forKey: tempPeerID)
|
||||
unreadMessages.remove(tempPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
if hadUnreadTemp {
|
||||
unreadMessages.insert(peerID)
|
||||
hasUnreadMessages = true
|
||||
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
|
||||
}
|
||||
|
||||
if consolidatedCount > 0 {
|
||||
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
||||
SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
return hasUnreadMessages
|
||||
}
|
||||
|
||||
/// Syncs the read receipt tracking between manager and view model for sent messages
|
||||
@MainActor
|
||||
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
|
||||
guard let messages = privateChats[peerID] else { return }
|
||||
|
||||
for message in messages {
|
||||
if message.sender == nickname {
|
||||
if let status = message.deliveryStatus {
|
||||
switch status {
|
||||
case .read, .delivered:
|
||||
externalReceipts.insert(message.id)
|
||||
sentReadReceipts.insert(message.id)
|
||||
case .failed, .partiallyDelivered, .sending, .sent:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a private chat with a peer
|
||||
func startChat(with peerID: PeerID) {
|
||||
|
||||
@@ -58,6 +58,10 @@ protocol Transport: AnyObject {
|
||||
// QR verification (optional for transports)
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
|
||||
// Pending file management (BCH-01-002: files held in memory until user accepts)
|
||||
func acceptPendingFile(id: String) -> URL?
|
||||
func declinePendingFile(id: String)
|
||||
}
|
||||
|
||||
extension Transport {
|
||||
@@ -70,6 +74,9 @@ extension Transport {
|
||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||
sendMessage(content, mentions: mentions)
|
||||
}
|
||||
|
||||
func acceptPendingFile(id: String) -> URL? { nil }
|
||||
func declinePendingFile(id: String) {}
|
||||
}
|
||||
|
||||
protocol TransportPeerEventsDelegate: AnyObject {
|
||||
|
||||
@@ -96,11 +96,12 @@ enum TransportConfig {
|
||||
// Keep scanning fully ON when we saw traffic very recently
|
||||
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
|
||||
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
|
||||
static let bleExpectedWritePerFragmentMs: Int = 8
|
||||
static let bleExpectedWriteMaxMs: Int = 2000
|
||||
// Faster fragment pacing; use slightly tighter spacing for directed trains
|
||||
static let bleFragmentSpacingMs: Int = 5
|
||||
static let bleFragmentSpacingDirectedMs: Int = 4
|
||||
static let bleExpectedWritePerFragmentMs: Int = 20
|
||||
static let bleExpectedWriteMaxMs: Int = 5000
|
||||
// Fragment pacing: Conservative spacing to prevent BLE buffer overflow
|
||||
// Aggressive pacing causes packet loss; needs 25-30ms between fragments for reliable delivery
|
||||
static let bleFragmentSpacingMs: Int = 30
|
||||
static let bleFragmentSpacingDirectedMs: Int = 25
|
||||
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
|
||||
static let bleDutyOnDurationDense: TimeInterval = 3.0
|
||||
static let bleDutyOffDurationDense: TimeInterval = 15.0
|
||||
@@ -162,6 +163,14 @@ enum TransportConfig {
|
||||
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
|
||||
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
|
||||
|
||||
// BCH-01-004: Rate-limiting for subscription-triggered announces
|
||||
// Prevents rapid enumeration attacks by rate-limiting announce responses
|
||||
static let bleSubscriptionRateLimitMinSeconds: TimeInterval = 2.0 // Minimum interval between announces per central
|
||||
static let bleSubscriptionRateLimitBackoffFactor: Double = 2.0 // Exponential backoff multiplier
|
||||
static let bleSubscriptionRateLimitMaxBackoffSeconds: TimeInterval = 30.0 // Maximum backoff period
|
||||
static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
|
||||
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
|
||||
|
||||
// Store-and-forward for directed packets at relays
|
||||
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
|
||||
|
||||
@@ -203,4 +212,18 @@ enum TransportConfig {
|
||||
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
||||
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
||||
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
||||
|
||||
// Gossip Sync Configuration
|
||||
static let syncSeenCapacity: Int = 1000
|
||||
static let syncGCSMaxBytes: Int = 400
|
||||
static let syncGCSTargetFpr: Double = 0.01
|
||||
static let syncMaxMessageAgeSeconds: TimeInterval = 900
|
||||
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
|
||||
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||
static let syncFragmentCapacity: Int = 600
|
||||
static let syncFileTransferCapacity: Int = 200
|
||||
static let syncFragmentIntervalSeconds: TimeInterval = 30.0
|
||||
static let syncFileTransferIntervalSeconds: TimeInterval = 60.0
|
||||
static let syncMessageIntervalSeconds: TimeInterval = 15.0
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import BitLogger
|
||||
|
||||
// Gossip-based sync manager using on-demand GCS filters
|
||||
final class GossipSyncManager {
|
||||
@@ -6,6 +7,7 @@ final class GossipSyncManager {
|
||||
func sendPacket(_ packet: BitchatPacket)
|
||||
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
|
||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
||||
func getConnectedPeers() -> [PeerID]
|
||||
}
|
||||
|
||||
private struct PacketStore {
|
||||
@@ -74,6 +76,7 @@ final class GossipSyncManager {
|
||||
|
||||
private let myPeerID: PeerID
|
||||
private let config: Config
|
||||
private let requestSyncManager: RequestSyncManager
|
||||
weak var delegate: Delegate?
|
||||
|
||||
// Storage: broadcast packets by type, and latest announce per sender
|
||||
@@ -88,9 +91,10 @@ final class GossipSyncManager {
|
||||
private var lastStalePeerCleanup: Date = .distantPast
|
||||
private var syncSchedules: [SyncSchedule] = []
|
||||
|
||||
init(myPeerID: PeerID, config: Config = Config()) {
|
||||
init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) {
|
||||
self.myPeerID = myPeerID
|
||||
self.config = config
|
||||
self.requestSyncManager = requestSyncManager
|
||||
var schedules: [SyncSchedule] = []
|
||||
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
||||
@@ -202,6 +206,19 @@ final class GossipSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
private func sendPeriodicSync(for types: SyncTypeFlags) {
|
||||
// Unicast sync to connected peers to allow RSR attribution
|
||||
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
|
||||
SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync)
|
||||
for peerID in connectedPeers {
|
||||
sendRequestSync(to: peerID, types: types)
|
||||
}
|
||||
} else {
|
||||
// Fallback to broadcast (discovery phase)
|
||||
sendRequestSync(for: types)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendRequestSync(for types: SyncTypeFlags) {
|
||||
let payload = buildGcsPayload(for: types)
|
||||
let pkt = BitchatPacket(
|
||||
@@ -218,6 +235,9 @@ final class GossipSyncManager {
|
||||
}
|
||||
|
||||
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
|
||||
// Register the request for RSR validation
|
||||
requestSyncManager.registerRequest(to: peerID)
|
||||
|
||||
let payload = buildGcsPayload(for: types)
|
||||
var recipient = Data()
|
||||
var temp = peerID.id
|
||||
@@ -262,6 +282,7 @@ final class GossipSyncManager {
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
@@ -274,6 +295,7 @@ final class GossipSyncManager {
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
@@ -286,6 +308,7 @@ final class GossipSyncManager {
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
@@ -298,6 +321,7 @@ final class GossipSyncManager {
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
@@ -366,11 +390,13 @@ final class GossipSyncManager {
|
||||
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||
cleanupExpiredMessages()
|
||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||
requestSyncManager.cleanup() // Cleanup expired sync requests
|
||||
|
||||
for index in syncSchedules.indices {
|
||||
guard syncSchedules[index].interval > 0 else { continue }
|
||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||
syncSchedules[index].lastSent = now
|
||||
sendRequestSync(for: syncSchedules[index].types)
|
||||
sendPeriodicSync(for: syncSchedules[index].types)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// RequestSyncManager.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitLogger
|
||||
|
||||
/// Manages outgoing sync requests and validates incoming responses.
|
||||
///
|
||||
/// Allows attributing RSR (Request-Sync Response) packets to specific peers
|
||||
/// that we have actively requested sync from.
|
||||
final class RequestSyncManager {
|
||||
|
||||
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
|
||||
private var pendingRequests: [PeerID: TimeInterval] = [:]
|
||||
|
||||
// Allow responses for 30s after request
|
||||
private let responseWindow: TimeInterval = 30.0
|
||||
|
||||
/// Register that we are sending a sync request to a peer.
|
||||
/// - Parameter peerID: The peer we are requesting sync from
|
||||
func registerRequest(to peerID: PeerID) {
|
||||
let now = Date().timeIntervalSince1970
|
||||
queue.async(flags: .barrier) {
|
||||
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))…", category: .sync)
|
||||
self.pendingRequests[peerID] = now
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a packet from a peer is a valid response to a sync request.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - peerID: The sender of the packet
|
||||
/// - isRSR: Whether the packet is marked as a Request-Sync Response
|
||||
/// - Returns: true if we have a pending request for this peer and the window is open
|
||||
func isValidResponse(from peerID: PeerID, isRSR: Bool) -> Bool {
|
||||
guard isRSR else { return false }
|
||||
|
||||
return queue.sync {
|
||||
guard let requestTime = pendingRequests[peerID] else {
|
||||
SecureLogger.warning("Received unsolicited RSR packet from \(peerID.id.prefix(8))…", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
let now = Date().timeIntervalSince1970
|
||||
if now - requestTime > responseWindow {
|
||||
SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security)
|
||||
// We don't remove here because we might receive multiple packets for one request
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Periodic cleanup of expired requests
|
||||
func cleanup() {
|
||||
let now = Date().timeIntervalSince1970
|
||||
queue.async(flags: .barrier) {
|
||||
let originalCount = self.pendingRequests.count
|
||||
self.pendingRequests = self.pendingRequests.filter { _, timestamp in
|
||||
now - timestamp <= self.responseWindow
|
||||
}
|
||||
let removed = originalCount - self.pendingRequests.count
|
||||
if removed > 0 {
|
||||
SecureLogger.debug("Cleaned up \(removed) expired sync requests", category: .sync)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,11 +52,13 @@ struct InputValidator {
|
||||
// MessageType/NoisePayloadType enums; keeping validator free of stale lists.
|
||||
|
||||
/// Validates timestamp is reasonable (not too far in past or future)
|
||||
/// BCH-01-011: Reduced from ±1 hour to ±5 minutes to limit replay attack window
|
||||
static func validateTimestamp(_ timestamp: Date) -> Bool {
|
||||
let now = Date()
|
||||
let oneHourAgo = now.addingTimeInterval(-3600)
|
||||
let oneHourFromNow = now.addingTimeInterval(3600)
|
||||
return timestamp >= oneHourAgo && timestamp <= oneHourFromNow
|
||||
// 5 minutes = 300 seconds (industry standard for replay protection)
|
||||
let fiveMinutesAgo = now.addingTimeInterval(-300)
|
||||
let fiveMinutesFromNow = now.addingTimeInterval(300)
|
||||
return timestamp >= fiveMinutesAgo && timestamp <= fiveMinutesFromNow
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,66 +2,112 @@ import Foundation
|
||||
|
||||
// MARK: - Message Deduplicator (shared)
|
||||
|
||||
/// Thread-safe deduplicator with LRU eviction and time-based expiry.
|
||||
/// Used for both message ID deduplication (network layer) and content key deduplication (UI layer).
|
||||
final class MessageDeduplicator {
|
||||
private struct Entry {
|
||||
let messageID: String
|
||||
private struct Entry: Equatable {
|
||||
let id: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
private var entries: [Entry] = []
|
||||
private var head: Int = 0
|
||||
private var lookup = Set<String>()
|
||||
private var lookup: [String: Date] = [:] // id -> timestamp for O(1) lookup
|
||||
private let lock = NSLock()
|
||||
private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes
|
||||
private let maxCount = TransportConfig.messageDedupMaxCount
|
||||
private let maxAge: TimeInterval
|
||||
private let maxCount: Int
|
||||
|
||||
/// Check if message is duplicate and add if not
|
||||
func isDuplicate(_ messageID: String) -> Bool {
|
||||
/// Initialize with default config from TransportConfig
|
||||
convenience init() {
|
||||
self.init(
|
||||
maxAge: TransportConfig.messageDedupMaxAgeSeconds,
|
||||
maxCount: TransportConfig.messageDedupMaxCount
|
||||
)
|
||||
}
|
||||
|
||||
/// Initialize with custom config for content deduplication
|
||||
init(maxAge: TimeInterval, maxCount: Int) {
|
||||
self.maxAge = maxAge
|
||||
self.maxCount = maxCount
|
||||
}
|
||||
|
||||
/// Check if message is duplicate and add if not.
|
||||
/// - Parameter id: The message identifier to check.
|
||||
/// - Returns: `true` if the message was already seen, `false` otherwise.
|
||||
func isDuplicate(_ id: String) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
cleanupOldEntries()
|
||||
let now = Date()
|
||||
cleanupOldEntries(before: now.addingTimeInterval(-maxAge))
|
||||
|
||||
if lookup.contains(messageID) {
|
||||
if lookup[id] != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
entries.append(Entry(messageID: messageID, timestamp: Date()))
|
||||
lookup.insert(messageID)
|
||||
|
||||
// Soft-cap and advance head by a chunk to avoid O(n) shifting
|
||||
if (entries.count - head) > maxCount {
|
||||
let removeCount = min(100, entries.count - head)
|
||||
for i in head..<(head + removeCount) {
|
||||
lookup.remove(entries[i].messageID)
|
||||
}
|
||||
head += removeCount
|
||||
// Periodically compact to reclaim memory
|
||||
if head > entries.count / 2 {
|
||||
entries.removeFirst(head)
|
||||
head = 0
|
||||
}
|
||||
}
|
||||
entries.append(Entry(id: id, timestamp: now))
|
||||
lookup[id] = now
|
||||
trimIfNeeded()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/// Add an ID without checking (for announce-back tracking)
|
||||
func markProcessed(_ messageID: String) {
|
||||
/// Record an ID with a specific timestamp (for content key tracking)
|
||||
func record(_ id: String, timestamp: Date) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
if !lookup.contains(messageID) {
|
||||
entries.append(Entry(messageID: messageID, timestamp: Date()))
|
||||
lookup.insert(messageID)
|
||||
if lookup[id] == nil {
|
||||
entries.append(Entry(id: id, timestamp: timestamp))
|
||||
}
|
||||
lookup[id] = timestamp
|
||||
trimIfNeeded()
|
||||
}
|
||||
|
||||
/// Add an ID without checking (for announce-back tracking)
|
||||
func markProcessed(_ id: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
if lookup[id] == nil {
|
||||
let now = Date()
|
||||
entries.append(Entry(id: id, timestamp: now))
|
||||
lookup[id] = now
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if ID exists without adding
|
||||
func contains(_ messageID: String) -> Bool {
|
||||
func contains(_ id: String) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return lookup.contains(messageID)
|
||||
return lookup[id] != nil
|
||||
}
|
||||
|
||||
/// Get timestamp for an ID (for content deduplication time-window checks)
|
||||
func timestampFor(_ id: String) -> Date? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return lookup[id]
|
||||
}
|
||||
|
||||
private func trimIfNeeded() {
|
||||
let activeCount = entries.count - head
|
||||
guard activeCount > maxCount else { return }
|
||||
|
||||
// Remove down to 75% of maxCount for better amortization
|
||||
let targetCount = (maxCount * 3) / 4
|
||||
let removeCount = activeCount - targetCount
|
||||
|
||||
for i in head..<(head + removeCount) {
|
||||
lookup.removeValue(forKey: entries[i].id)
|
||||
}
|
||||
head += removeCount
|
||||
|
||||
// Compact when head exceeds half the array to reclaim memory
|
||||
if head > entries.count / 2 {
|
||||
entries.removeFirst(head)
|
||||
head = 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all entries
|
||||
@@ -74,24 +120,25 @@ final class MessageDeduplicator {
|
||||
lookup.removeAll()
|
||||
}
|
||||
|
||||
/// Periodic cleanup
|
||||
/// Periodic cleanup of expired entries and memory optimization.
|
||||
func cleanup() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
cleanupOldEntries()
|
||||
cleanupOldEntries(before: Date().addingTimeInterval(-maxAge))
|
||||
|
||||
if entries.capacity > maxCount * 2 {
|
||||
// Shrink capacity if significantly oversized
|
||||
if entries.capacity > maxCount * 2 && entries.count < maxCount {
|
||||
entries.reserveCapacity(maxCount)
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupOldEntries() {
|
||||
let cutoff = Date().addingTimeInterval(-maxAge)
|
||||
private func cleanupOldEntries(before cutoff: Date) {
|
||||
while head < entries.count, entries[head].timestamp < cutoff {
|
||||
lookup.remove(entries[head].messageID)
|
||||
lookup.removeValue(forKey: entries[head].id)
|
||||
head += 1
|
||||
}
|
||||
// Compact when head exceeds half the array
|
||||
if head > 0 && head > entries.count / 2 {
|
||||
entries.removeFirst(head)
|
||||
head = 0
|
||||
|
||||
+302
-2565
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,836 @@
|
||||
//
|
||||
// ChatViewModel+Nostr.swift
|
||||
// bitchat
|
||||
//
|
||||
// Geohash and Nostr logic for ChatViewModel
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import BitLogger
|
||||
import SwiftUI
|
||||
import Tor
|
||||
|
||||
extension ChatViewModel {
|
||||
|
||||
// MARK: - Geohash Subscription
|
||||
|
||||
// Resubscribe to the active geohash channel without clearing timeline
|
||||
@MainActor
|
||||
func resubscribeCurrentGeohash() {
|
||||
guard case .location(let ch) = activeChannel else { return }
|
||||
guard let subID = geoSubscriptionID else {
|
||||
// No existing subscription; set it up
|
||||
switchLocationChannel(to: activeChannel)
|
||||
return
|
||||
}
|
||||
// Ensure participant decay timer is running
|
||||
participantTracker.startRefreshTimer()
|
||||
// Unsubscribe + resubscribe
|
||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||
let filter = NostrFilter.geohashEphemeral(
|
||||
ch.geohash,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
|
||||
limit: TransportConfig.nostrGeohashInitialLimit
|
||||
)
|
||||
let subRelays = GeoRelayDirectory.shared.closestRelays(
|
||||
toGeohash: ch.geohash,
|
||||
count: TransportConfig.nostrGeoRelayCount
|
||||
)
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||
self?.subscribeNostrEvent(event)
|
||||
}
|
||||
// Resubscribe geohash DMs for this identity
|
||||
if let dmSub = geoDmSubscriptionID {
|
||||
NostrRelayManager.shared.unsubscribe(id: dmSub); geoDmSubscriptionID = nil
|
||||
}
|
||||
|
||||
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
let dmSub = "geo-dm-\(ch.geohash)"
|
||||
geoDmSubscriptionID = dmSub
|
||||
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
|
||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
||||
self?.subscribeGiftWrap(giftWrap, id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
||||
!deduplicationService.hasProcessedNostrEvent(event.id)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
deduplicationService.recordNostrEvent(event.id)
|
||||
|
||||
if let gh = currentGeohash,
|
||||
let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh),
|
||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||
// Skip very recent self-echo from relay, but allow older events (e.g., after app restart)
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
if Date().timeIntervalSince(eventTime) < 15 {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
geoNicknames[event.pubkey.lowercased()] = nick
|
||||
}
|
||||
|
||||
// Store mapping for geohash sender IDs used in messages (ensures consistent colors)
|
||||
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
||||
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
||||
|
||||
// Update participants last-seen for this pubkey
|
||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
||||
|
||||
// If presence heartbeat (Kind 20001), stop here - no content to display
|
||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
||||
return
|
||||
}
|
||||
|
||||
// Track teleported tag (only our format ["t","teleport"]) for icon state
|
||||
let hasTeleportTag = event.tags.contains(where: { tag in
|
||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
||||
})
|
||||
|
||||
if hasTeleportTag {
|
||||
let key = event.pubkey.lowercased()
|
||||
// Do not mark our own key from historical events; rely on manager.teleported for self
|
||||
let isSelf: Bool = {
|
||||
if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) {
|
||||
return my.publicKeyHex.lowercased() == key
|
||||
}
|
||||
return false
|
||||
}()
|
||||
if !isSelf {
|
||||
Task { @MainActor in
|
||||
teleportedGeo = teleportedGeo.union([key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let senderName = displayNameForNostrPubkey(event.pubkey)
|
||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Clamp future timestamps to now to avoid future-dated messages skewing order
|
||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let timestamp = min(rawTs, Date())
|
||||
let mentions = parseMentions(from: content)
|
||||
let msg = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
senderPeerID: PeerID(nostr: event.pubkey),
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
Task { @MainActor in
|
||||
// BCH-01-012: Check blocking before any notifications
|
||||
// handlePublicMessage has its own blocking check but returns silently,
|
||||
// so we must also guard checkForMentions to prevent notification bypass
|
||||
let isBlocked = identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
||||
|
||||
handlePublicMessage(msg)
|
||||
|
||||
// Only check mentions and send haptic if sender is not blocked
|
||||
if !isBlocked {
|
||||
checkForMentions(msg)
|
||||
sendHapticFeedback(for: msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||
deduplicationService.recordNostrEvent(giftWrap.id)
|
||||
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id),
|
||||
content.hasPrefix("bitchat1:"),
|
||||
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||
let packet = BitchatPacket.from(packetData),
|
||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let noisePayload = NoisePayload.decode(packet.payload)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
nostrKeyMapping[convKey] = senderPubkey
|
||||
|
||||
switch noisePayload.type {
|
||||
case .privateMessage:
|
||||
handlePrivateMessage(noisePayload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp)
|
||||
case .delivered:
|
||||
handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
// QR verification payloads over Nostr are not supported; ignore in geohash DMs
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash Channel Handling
|
||||
|
||||
@MainActor
|
||||
func switchLocationChannel(to channel: ChannelID) {
|
||||
// Reset pending public batches to avoid cross-channel bleed
|
||||
publicMessagePipeline.reset()
|
||||
|
||||
activeChannel = channel
|
||||
publicMessagePipeline.updateActiveChannel(channel)
|
||||
|
||||
// Reset deduplication set and optionally hydrate timeline for mesh
|
||||
deduplicationService.clearNostrCaches()
|
||||
switch channel {
|
||||
case .mesh:
|
||||
refreshVisibleMessages(from: .mesh)
|
||||
// Debug: log if any empty messages are present
|
||||
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
|
||||
if emptyMesh > 0 {
|
||||
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
|
||||
}
|
||||
participantTracker.stopRefreshTimer()
|
||||
participantTracker.setActiveGeohash(nil)
|
||||
teleportedGeo.removeAll()
|
||||
case .location:
|
||||
refreshVisibleMessages(from: channel)
|
||||
}
|
||||
// If switching to a location channel, flush any pending geohash-only system messages
|
||||
if case .location = channel {
|
||||
for content in timelineStore.drainPendingGeohashSystemMessages() {
|
||||
addPublicSystemMessage(content)
|
||||
}
|
||||
}
|
||||
// Unsubscribe previous
|
||||
if let sub = geoSubscriptionID {
|
||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
||||
geoSubscriptionID = nil
|
||||
}
|
||||
if let dmSub = geoDmSubscriptionID {
|
||||
NostrRelayManager.shared.unsubscribe(id: dmSub)
|
||||
geoDmSubscriptionID = nil
|
||||
}
|
||||
currentGeohash = nil
|
||||
participantTracker.setActiveGeohash(nil)
|
||||
// Reset nickname cache for geochat participants
|
||||
geoNicknames.removeAll()
|
||||
|
||||
guard case .location(let ch) = channel else { return }
|
||||
currentGeohash = ch.geohash
|
||||
participantTracker.setActiveGeohash(ch.geohash)
|
||||
|
||||
// Ensure self appears immediately in the people list; mark teleported state only when truly teleported
|
||||
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
participantTracker.recordParticipant(pubkeyHex: id.publicKeyHex)
|
||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
let key = id.publicKeyHex.lowercased()
|
||||
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
|
||||
teleportedGeo = teleportedGeo.union([key])
|
||||
SecureLogger.info("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
|
||||
} else {
|
||||
teleportedGeo.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
let subID = "geo-\(ch.geohash)"
|
||||
geoSubscriptionID = subID
|
||||
participantTracker.startRefreshTimer()
|
||||
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
|
||||
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
|
||||
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||
self?.handleNostrEvent(event)
|
||||
}
|
||||
|
||||
subscribeToGeoChat(ch)
|
||||
}
|
||||
|
||||
func handleNostrEvent(_ event: NostrEvent) {
|
||||
// Only handle ephemeral kind 20000 or presence kind 20001 with matching tag
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
||||
|
||||
// Deduplicate
|
||||
if deduplicationService.hasProcessedNostrEvent(event.id) { return }
|
||||
deduplicationService.recordNostrEvent(event.id)
|
||||
|
||||
// Log incoming tags for diagnostics
|
||||
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
||||
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
|
||||
|
||||
// If this pubkey is blocked, skip mapping, participants, and timeline
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||
return
|
||||
}
|
||||
|
||||
// Track teleport tag for participants – only our format ["t", "teleport"]
|
||||
let hasTeleportTag: Bool = event.tags.contains { tag in
|
||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
||||
}
|
||||
|
||||
let isSelf: Bool = {
|
||||
if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) {
|
||||
return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
if hasTeleportTag {
|
||||
// Avoid marking our own key from historical events; rely on manager.teleported for self
|
||||
if !isSelf {
|
||||
let key = event.pubkey.lowercased()
|
||||
Task { @MainActor in
|
||||
teleportedGeo = teleportedGeo.union([key])
|
||||
SecureLogger.info("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update participants last-seen for this pubkey
|
||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
||||
|
||||
// Skip only very recent self-echo from relay; include older self events for hydration
|
||||
if isSelf {
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
if Date().timeIntervalSince(eventTime) < 15 {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Cache nickname from tag if present
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
geoNicknames[event.pubkey.lowercased()] = nick
|
||||
}
|
||||
|
||||
// Store mapping for geohash DM initiation
|
||||
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
||||
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
||||
|
||||
// If presence heartbeat (Kind 20001), stop here - no content to display
|
||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
||||
return
|
||||
}
|
||||
|
||||
let senderName = displayNameForNostrPubkey(event.pubkey)
|
||||
let content = event.content
|
||||
|
||||
// If this is a teleport presence event (no content), don't add to timeline
|
||||
if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, (teleTag[1] == "teleport"),
|
||||
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
// Clamp future timestamps
|
||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let mentions = parseMentions(from: content)
|
||||
let msg = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
content: content,
|
||||
timestamp: min(rawTs, Date()),
|
||||
isRelay: false,
|
||||
senderPeerID: PeerID(nostr: event.pubkey),
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
Task { @MainActor in
|
||||
handlePublicMessage(msg)
|
||||
checkForMentions(msg)
|
||||
sendHapticFeedback(for: msg)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func subscribeToGeoChat(_ ch: GeohashChannel) {
|
||||
guard let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return }
|
||||
|
||||
let dmSub = "geo-dm-\(ch.geohash)"
|
||||
geoDmSubscriptionID = dmSub
|
||||
// pared back logging: subscribe debug only
|
||||
// Log GeoDM subscribe only when Tor is ready to avoid early noise
|
||||
if TorManager.shared.isReady {
|
||||
SecureLogger.debug("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
||||
}
|
||||
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
|
||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
||||
self?.handleGiftWrap(giftWrap, id: id)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
|
||||
return
|
||||
}
|
||||
deduplicationService.recordNostrEvent(giftWrap.id)
|
||||
|
||||
// Decrypt with per-geohash identity
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else {
|
||||
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session)
|
||||
|
||||
guard content.hasPrefix("bitchat1:"),
|
||||
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||
let packet = BitchatPacket.from(packetData),
|
||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let payload = NoisePayload.decode(packet.payload)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
nostrKeyMapping[convKey] = senderPubkey
|
||||
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp)
|
||||
case .delivered:
|
||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
|
||||
// Explicitly list other cases so we get compile-time check if a new case is added in the future
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendGeohash(context: GeoOutgoingContext) {
|
||||
let ch = context.channel
|
||||
let event = context.event
|
||||
let identity = context.identity
|
||||
|
||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
||||
toGeohash: ch.geohash,
|
||||
count: TransportConfig.nostrGeoRelayCount
|
||||
)
|
||||
|
||||
if targetRelays.isEmpty {
|
||||
SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session)
|
||||
} else {
|
||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||
}
|
||||
|
||||
// Track ourselves as active participant
|
||||
participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
|
||||
SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", category: .session)
|
||||
|
||||
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
|
||||
// Only when not in our regional set (and regional list is known)
|
||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
|
||||
if context.teleported && hasRegional && !inRegional {
|
||||
let key = identity.publicKeyHex.lowercased()
|
||||
teleportedGeo = teleportedGeo.union([key])
|
||||
SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
|
||||
}
|
||||
|
||||
deduplicationService.recordNostrEvent(event.id)
|
||||
}
|
||||
|
||||
// MARK: - Sampling
|
||||
|
||||
/// Begin sampling multiple geohashes (used by channel sheet) without changing active channel.
|
||||
@MainActor
|
||||
func beginGeohashSampling(for geohashes: [String]) {
|
||||
// Disable sampling when app is backgrounded (Tor is stopped there)
|
||||
if !TorManager.shared.isForeground() {
|
||||
endGeohashSampling()
|
||||
return
|
||||
}
|
||||
// Determine which to add and which to remove
|
||||
let desired = Set(geohashes)
|
||||
let current = Set(geoSamplingSubs.values)
|
||||
let toAdd = desired.subtracting(current)
|
||||
let toRemove = current.subtracting(desired)
|
||||
|
||||
for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) {
|
||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||
geoSamplingSubs.removeValue(forKey: subID)
|
||||
}
|
||||
|
||||
for gh in toAdd {
|
||||
subscribe(gh)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func subscribe(_ gh: String) {
|
||||
let subID = "geo-sample-\(gh)"
|
||||
geoSamplingSubs[subID] = gh
|
||||
let filter = NostrFilter.geohashEphemeral(
|
||||
gh,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
|
||||
limit: TransportConfig.nostrGeohashSampleLimit
|
||||
)
|
||||
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||
self?.subscribeNostrEvent(event, gh: gh)
|
||||
}
|
||||
}
|
||||
|
||||
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
||||
|
||||
// Compute current participant count (5-minute window) BEFORE updating with this event
|
||||
let existingCount = participantTracker.participantCount(for: gh)
|
||||
|
||||
// Update participants for this specific geohash
|
||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
||||
|
||||
// Notify only on rising-edge: previously zero people, now someone sends a chat
|
||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !content.isEmpty else { return }
|
||||
|
||||
// Respect geohash blocks
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
||||
|
||||
// Skip self identity for this geohash
|
||||
if let my = try? idBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
|
||||
|
||||
// Only trigger when there were zero participants in this geohash recently
|
||||
guard existingCount == 0 else { return }
|
||||
|
||||
// Avoid notifications for old sampled events when launching or (re)subscribing
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
if Date().timeIntervalSince(eventTime) > 30 { return }
|
||||
|
||||
// Foreground-only notifications: app must be active, and not already viewing this geohash
|
||||
#if os(iOS)
|
||||
guard UIApplication.shared.applicationState == .active else { return }
|
||||
if case .location(let ch) = activeChannel, ch.geohash == gh { return }
|
||||
#elseif os(macOS)
|
||||
guard NSApplication.shared.isActive else { return }
|
||||
if case .location(let ch) = activeChannel, ch.geohash == gh { return }
|
||||
#endif
|
||||
|
||||
cooldownPerGeohash(gh, content: content, event: event)
|
||||
}
|
||||
|
||||
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
|
||||
let now = Date()
|
||||
let last = lastGeoNotificationAt[gh] ?? .distantPast
|
||||
if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }
|
||||
|
||||
// Compose a short preview
|
||||
let preview: String = {
|
||||
let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen
|
||||
if content.count <= maxLen { return content }
|
||||
let idx = content.index(content.startIndex, offsetBy: maxLen)
|
||||
return String(content[..<idx]) + "…"
|
||||
}()
|
||||
|
||||
Task { @MainActor in
|
||||
lastGeoNotificationAt[gh] = now
|
||||
// Pre-populate the target geohash timeline so the triggering message appears when user opens it
|
||||
let senderSuffix = String(event.pubkey.suffix(4))
|
||||
let nick = geoNicknames[event.pubkey.lowercased()]
|
||||
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
|
||||
|
||||
// Clamp future timestamps
|
||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let ts = min(rawTs, Date())
|
||||
let mentions = self.parseMentions(from: content)
|
||||
let msg = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
content: content,
|
||||
timestamp: ts,
|
||||
isRelay: false,
|
||||
senderPeerID: PeerID(nostr: event.pubkey),
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
if timelineStore.appendIfAbsent(msg, toGeohash: gh) {
|
||||
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop sampling all extra geohashes.
|
||||
@MainActor
|
||||
func endGeohashSampling() {
|
||||
for subID in geoSamplingSubs.keys { NostrRelayManager.shared.unsubscribe(id: subID) }
|
||||
geoSamplingSubs.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Nostr DM Handling
|
||||
|
||||
func setupNostrMessageHandling() {
|
||||
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else {
|
||||
SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.debug("🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...", category: .session)
|
||||
|
||||
// Subscribe to Nostr messages
|
||||
let filter = NostrFilter.giftWrapsFor(
|
||||
pubkey: currentIdentity.publicKeyHex,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) // Last 24 hours
|
||||
)
|
||||
|
||||
nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
|
||||
self?.handleNostrMessage(event)
|
||||
}
|
||||
}
|
||||
|
||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||
// Deduplicate messages by ID
|
||||
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) { return }
|
||||
deduplicationService.recordNostrEvent(giftWrap.id)
|
||||
|
||||
// Ensure we're on a background queue for decryption
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
await self?.processNostrMessage(giftWrap)
|
||||
}
|
||||
}
|
||||
|
||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
|
||||
do {
|
||||
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: currentIdentity
|
||||
)
|
||||
|
||||
// Handle verification payloads first
|
||||
if content.hasPrefix("verify:") {
|
||||
// Ignore verification payloads arriving via Nostr path for now
|
||||
// Verification should ideally happen over mesh for security binding
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it's a BitChat packet embedded in the content (bitchat1:...)
|
||||
if content.hasPrefix("bitchat1:") {
|
||||
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||
let packet = BitchatPacket.from(packetData) else {
|
||||
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Map sender by Nostr pubkey to Noise key when possible
|
||||
let actualSenderNoiseKey = findNoiseKey(for: senderPubkey)
|
||||
|
||||
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
|
||||
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
|
||||
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||
if let payload = NoisePayload.decode(packet.payload) {
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||
// Store Nostr mapping
|
||||
await MainActor.run {
|
||||
nostrKeyMapping[targetPeerID] = senderPubkey
|
||||
|
||||
// Handle packet types
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
|
||||
case .delivered:
|
||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .readReceipt:
|
||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
||||
// Check favorites for this Nostr key
|
||||
let favorites = FavoritesPersistenceService.shared.favorites.values
|
||||
var npubToMatch = nostrPubkey
|
||||
|
||||
// Convert hex to npub if needed for comparison
|
||||
if !nostrPubkey.hasPrefix("npub") {
|
||||
if let pubkeyData = Data(hexString: nostrPubkey),
|
||||
let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) {
|
||||
npubToMatch = encoded
|
||||
} else {
|
||||
SecureLogger.warning("⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
for relationship in favorites {
|
||||
// Search through favorites for matching Nostr pubkey
|
||||
if let storedNostrKey = relationship.peerNostrPublicKey {
|
||||
// Compare against stored key (could be hex or npub)
|
||||
if storedNostrKey == npubToMatch {
|
||||
// SecureLogger.debug("✅ Found Noise key for Nostr sender (npub match)", category: .session)
|
||||
return relationship.peerNoisePublicKey
|
||||
}
|
||||
|
||||
// Also try comparing raw hex if stored key is hex
|
||||
if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey {
|
||||
SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session)
|
||||
return relationship.peerNoisePublicKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SecureLogger.debug("⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", category: .session)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) {
|
||||
// If we have a Noise key, try to route securely if possible, otherwise fallback to direct
|
||||
if let _ = key {
|
||||
// Ideally we would use MessageRouter here, but for simplicity in this direct callback:
|
||||
// check if we have an identity
|
||||
if let id = try? idBridge.getCurrentNostrIdentity() {
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
nt.senderPeerID = meshService.myPeerID
|
||||
nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)
|
||||
}
|
||||
} else if let id = try? idBridge.getCurrentNostrIdentity() {
|
||||
// Fallback: no Noise mapping yet — send directly to sender's Nostr pubkey
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
nt.senderPeerID = meshService.myPeerID
|
||||
nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)
|
||||
SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session)
|
||||
}
|
||||
|
||||
// Same for READ receipt if viewing
|
||||
if !wasReadBefore && selectedPrivateChatPeer == message.senderPeerID {
|
||||
if let _ = key {
|
||||
if let id = try? idBridge.getCurrentNostrIdentity() {
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
nt.senderPeerID = meshService.myPeerID
|
||||
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
|
||||
}
|
||||
} else if let id = try? idBridge.getCurrentNostrIdentity() {
|
||||
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
nt.senderPeerID = meshService.myPeerID
|
||||
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
|
||||
SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
|
||||
// Try to find Noise key associated with this Nostr pubkey
|
||||
guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return }
|
||||
|
||||
let isFavorite = content.contains("FAVORITE:TRUE")
|
||||
let senderNickname = content.components(separatedBy: "|").last ?? "Unknown"
|
||||
|
||||
// Update favorite status
|
||||
if isFavorite {
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: senderNoiseKey,
|
||||
peerNostrPublicKey: nostrPubkey,
|
||||
peerNickname: senderNickname
|
||||
)
|
||||
} else {
|
||||
// Only remove if we don't have it set locally
|
||||
// Logic handled by persistence service usually, here we just update remote state
|
||||
// Actually for now we just process the notification
|
||||
}
|
||||
|
||||
// Extract Nostr public key if included
|
||||
var extractedNostrPubkey: String? = nil
|
||||
if let range = content.range(of: "NPUB:") {
|
||||
let suffix = content[range.upperBound...]
|
||||
let parts = suffix.components(separatedBy: "|")
|
||||
if let key = parts.first {
|
||||
extractedNostrPubkey = String(key)
|
||||
}
|
||||
} else if content.contains(":") {
|
||||
// Fallback: simple format FAVORITE:TRUE:npub...
|
||||
let parts = content.components(separatedBy: ":")
|
||||
if parts.count >= 3 {
|
||||
extractedNostrPubkey = String(parts[2])
|
||||
}
|
||||
}
|
||||
|
||||
SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session)
|
||||
|
||||
// If they favorited us and provided their Nostr key, ensure it's stored
|
||||
if isFavorite && extractedNostrPubkey != nil {
|
||||
SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...", category: .session)
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: senderNoiseKey,
|
||||
peerNostrPublicKey: extractedNostrPubkey,
|
||||
peerNickname: senderNickname
|
||||
)
|
||||
}
|
||||
|
||||
// Show notification
|
||||
NotificationService.shared.sendLocalNotification(
|
||||
title: isFavorite ? "New Favorite" : "Favorite Removed",
|
||||
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
|
||||
identifier: "fav-\(UUID().uuidString)"
|
||||
)
|
||||
}
|
||||
|
||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||
// Find peer Nostr key
|
||||
guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey),
|
||||
relationship.peerNostrPublicKey != nil else {
|
||||
SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
let peerID = PeerID(hexData: noisePublicKey)
|
||||
|
||||
// Route via message router
|
||||
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
}
|
||||
|
||||
// MARK: - Geohash Nickname Resolution (for /block in geohash)
|
||||
|
||||
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
||||
// Look up current visible geohash participants for an exact displayName match
|
||||
for p in visibleGeohashPeople() {
|
||||
if p.displayName == name {
|
||||
return p.id
|
||||
}
|
||||
}
|
||||
// Also check nickname cache directly
|
||||
for (pub, nick) in geoNicknames {
|
||||
if nick == name { return pub }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func startGeohashDM(withPubkeyHex hex: String) {
|
||||
let convKey = PeerID(nostr_: hex)
|
||||
nostrKeyMapping[convKey] = hex
|
||||
startPrivateChat(with: convKey)
|
||||
}
|
||||
|
||||
func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? {
|
||||
return nostrKeyMapping[senderID]
|
||||
}
|
||||
|
||||
func geohashDisplayName(for convKey: PeerID) -> String {
|
||||
guard let full = nostrKeyMapping[convKey] else {
|
||||
return convKey.bare
|
||||
}
|
||||
return displayNameForNostrPubkey(full)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// ChatViewModel+Tor.swift
|
||||
// bitchat
|
||||
//
|
||||
// Tor lifecycle handling for ChatViewModel
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import Tor
|
||||
|
||||
extension ChatViewModel {
|
||||
|
||||
// MARK: - Tor notifications
|
||||
|
||||
@objc func handleTorWillStart() {
|
||||
Task { @MainActor in
|
||||
if !self.torStatusAnnounced && TorManager.shared.torEnforced {
|
||||
self.torStatusAnnounced = true
|
||||
// Post only in geohash channels (queue if not active)
|
||||
self.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func handleTorWillRestart() {
|
||||
Task { @MainActor in
|
||||
self.torRestartPending = true
|
||||
// Post only in geohash channels (queue if not active)
|
||||
self.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.restarting", comment: "System message when Tor is restarting")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func handleTorDidBecomeReady() {
|
||||
Task { @MainActor in
|
||||
// Only announce "restarted" if we actually restarted this session
|
||||
if self.torRestartPending {
|
||||
// Post only in geohash channels (queue if not active)
|
||||
self.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.restarted", comment: "System message when Tor has restarted")
|
||||
)
|
||||
self.torRestartPending = false
|
||||
} else if TorManager.shared.torEnforced && !self.torInitialReadyAnnounced {
|
||||
// Initial start completed
|
||||
self.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.started", comment: "System message when Tor has started")
|
||||
)
|
||||
self.torInitialReadyAnnounced = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func handleTorPreferenceChanged(_ notification: Notification) {
|
||||
Task { @MainActor in
|
||||
self.torStatusAnnounced = false
|
||||
self.torInitialReadyAnnounced = false
|
||||
self.torRestartPending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# ChatViewModel Extensions
|
||||
|
||||
This directory contains extensions to `ChatViewModel` to modularize its functionality.
|
||||
|
||||
- `ChatViewModel+Tor.swift`: Handles Tor lifecycle events and notifications.
|
||||
- `ChatViewModel+PrivateChat.swift`: Manages private chat logic, media transfers (images, voice notes), and file handling.
|
||||
- `ChatViewModel+Nostr.swift`: Contains all logic related to Nostr integration, Geohash channels, and Nostr identity management.
|
||||
|
||||
The main `ChatViewModel.swift` retains core state, initialization, and coordination logic.
|
||||
@@ -86,10 +86,6 @@ struct AppInfoView: View {
|
||||
]
|
||||
}
|
||||
|
||||
enum Warning {
|
||||
static let title: LocalizedStringKey = "app_info.warning.title"
|
||||
static let message: LocalizedStringKey = "app_info.warning.message"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -192,24 +188,6 @@ struct AppInfoView: View {
|
||||
|
||||
FeatureRow(info: Strings.Privacy.panic)
|
||||
}
|
||||
|
||||
// Warning
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
SectionHeader(Strings.Warning.title)
|
||||
.foregroundColor(Color.red)
|
||||
|
||||
Text(Strings.Warning.message)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(Color.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 16)
|
||||
.padding(.horizontal)
|
||||
.background(Color.red.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
|
||||
.padding(.top)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@@ -15,10 +15,22 @@ struct PaymentChipView: View {
|
||||
enum PaymentType {
|
||||
case cashu(String)
|
||||
case lightning(String)
|
||||
|
||||
|
||||
private static let cashuAllowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||
|
||||
private static func cashuURL(from link: String) -> URL? {
|
||||
if let url = URL(string: link), url.scheme != nil {
|
||||
return url
|
||||
}
|
||||
let enc = link.addingPercentEncoding(withAllowedCharacters: cashuAllowedCharacters) ?? link
|
||||
return URL(string: "cashu:\(enc)")
|
||||
}
|
||||
|
||||
var url: URL? {
|
||||
switch self {
|
||||
case .cashu(let link), .lightning(let link):
|
||||
case .cashu(let link):
|
||||
return Self.cashuURL(from: link)
|
||||
case .lightning(let link):
|
||||
return URL(string: link)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ struct ContentView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
@State private var showPeerList = false
|
||||
@State private var showSidebar = false
|
||||
@State private var showAppInfo = false
|
||||
@State private var showMessageActions = false
|
||||
@@ -57,7 +56,6 @@ struct ContentView: View {
|
||||
@State private var expandedMessageIDs: Set<String> = []
|
||||
@State private var showLocationNotes = false
|
||||
@State private var notesGeohash: String? = nil
|
||||
@State private var sheetNotesCount: Int = 0
|
||||
@State private var imagePreviewURL: URL? = nil
|
||||
@State private var recordingAlertMessage: String = ""
|
||||
@State private var showRecordingAlert = false
|
||||
@@ -189,6 +187,7 @@ struct ContentView: View {
|
||||
}
|
||||
.sheet(isPresented: $showAppInfo) {
|
||||
AppInfoView()
|
||||
.environmentObject(viewModel)
|
||||
.onAppear { viewModel.isAppInfoPresented = true }
|
||||
.onDisappear { viewModel.isAppInfoPresented = false }
|
||||
}
|
||||
@@ -198,6 +197,7 @@ struct ContentView: View {
|
||||
)) {
|
||||
if let peerID = viewModel.showingFingerprintFor {
|
||||
FingerprintView(viewModel: viewModel, peerID: peerID)
|
||||
.environmentObject(viewModel)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
@@ -225,6 +225,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.environmentObject(viewModel)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
#endif
|
||||
@@ -253,6 +254,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.environmentObject(viewModel)
|
||||
}
|
||||
#endif
|
||||
.sheet(isPresented: Binding(
|
||||
@@ -261,6 +263,7 @@ struct ContentView: View {
|
||||
)) {
|
||||
if let url = imagePreviewURL {
|
||||
ImagePreviewView(url: url)
|
||||
.environmentObject(viewModel)
|
||||
}
|
||||
}
|
||||
.alert("Recording Error", isPresented: $showRecordingAlert, actions: {
|
||||
@@ -465,17 +468,17 @@ struct ContentView: View {
|
||||
} else {
|
||||
// Schedule a delayed scroll
|
||||
scrollThrottleTimer?.invalidate()
|
||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
||||
lastScrollTime = Date()
|
||||
let contextKey: String = {
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh: return "mesh"
|
||||
case .location(let ch): return "geo:\(ch.geohash)"
|
||||
}
|
||||
}()
|
||||
let count = windowCountPublic
|
||||
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
||||
DispatchQueue.main.async {
|
||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { [weak viewModel] _ in
|
||||
Task { @MainActor in
|
||||
lastScrollTime = Date()
|
||||
let contextKey: String = {
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh: return "mesh"
|
||||
case .location(let ch): return "geo:\(ch.geohash)"
|
||||
}
|
||||
}()
|
||||
let count = windowCountPublic
|
||||
let target = viewModel?.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
@@ -643,9 +646,11 @@ struct ContentView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.onChange(of: messageText) { newValue in
|
||||
autocompleteDebounceTimer?.invalidate()
|
||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { [weak viewModel] _ in
|
||||
let cursorPosition = newValue.count
|
||||
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||
Task { @MainActor in
|
||||
viewModel?.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,6 +828,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.environmentObject(viewModel)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
#endif
|
||||
@@ -843,6 +849,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.environmentObject(viewModel)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -1172,19 +1179,6 @@ struct ContentView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// Split a name into base and a '#abcd' suffix if present
|
||||
private func splitNameSuffix(_ name: String) -> (base: String, suffix: String) {
|
||||
guard name.count >= 5 else { return (name, "") }
|
||||
let suffix = String(name.suffix(5))
|
||||
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
|
||||
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
|
||||
}) {
|
||||
let base = String(name.dropLast(5))
|
||||
return (base, suffix)
|
||||
}
|
||||
return (name, "")
|
||||
}
|
||||
|
||||
// Compute channel-aware people count and color for toolbar (cross-platform)
|
||||
private func channelPeopleCountAndColor() -> (Int, Color) {
|
||||
switch locationManager.selectedChannel {
|
||||
@@ -1299,8 +1293,8 @@ struct ContentView: View {
|
||||
|
||||
// Bookmark toggle (geochats): to the left of #geohash
|
||||
if case .location(let ch) = locationManager.selectedChannel {
|
||||
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
|
||||
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
||||
Button(action: { bookmarks.toggle(ch.geohash) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -1380,6 +1374,7 @@ struct ContentView: View {
|
||||
.padding(.horizontal, 12)
|
||||
.sheet(isPresented: $showLocationChannelsSheet) {
|
||||
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
|
||||
.environmentObject(viewModel)
|
||||
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
|
||||
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
|
||||
}
|
||||
@@ -1557,7 +1552,7 @@ private extension ContentView {
|
||||
} else if let media = mediaAttachment(for: message) {
|
||||
mediaMessageRow(message: message, media: media)
|
||||
} else {
|
||||
textMessageRow(message)
|
||||
TextMessageView(message: message, expandedMessageIDs: $expandedMessageIDs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1621,56 +1616,6 @@ private extension ContentView {
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func textMessageRow(_ message: BitchatMessage) -> some View {
|
||||
let cashuTokens = message.content.extractCashuLinks()
|
||||
let lightningLinks = message.content.extractLightningLinks()
|
||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if message.isPrivate && message.sender == viewModel.nickname,
|
||||
let status = message.deliveryStatus {
|
||||
DeliveryStatusView(status: status)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
|
||||
if isLong && cashuTokens.isEmpty {
|
||||
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
||||
Button(labelKey) {
|
||||
if isExpanded { expandedMessageIDs.remove(message.id) }
|
||||
else { expandedMessageIDs.insert(message.id) }
|
||||
}
|
||||
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(Color.blue)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
if !lightningLinks.isEmpty || !cashuTokens.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Array(lightningLinks.prefix(3)), id: \.self) { link in
|
||||
PaymentChipView(paymentType: .lightning(link))
|
||||
}
|
||||
|
||||
ForEach(Array(cashuTokens.prefix(3)), id: \.self) { token in
|
||||
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
|
||||
let urlStr = "cashu:\(enc)"
|
||||
PaymentChipView(paymentType: .cashu(urlStr))
|
||||
}
|
||||
}
|
||||
.padding(.top, 6)
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func expandWindow(ifNeededFor message: BitchatMessage,
|
||||
allMessages: [BitchatMessage],
|
||||
privatePeer: PeerID?,
|
||||
|
||||
@@ -40,10 +40,31 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
|
||||
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
|
||||
// High-precision uncertainty: if count is 0 for high-precision levels,
|
||||
// show "?" because presence broadcasting is disabled for privacy.
|
||||
let isHighPrecision = (level == .neighborhood || level == .block || level == .building)
|
||||
if isHighPrecision && count == 0 {
|
||||
return String(
|
||||
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
|
||||
locale: .current,
|
||||
level.displayName
|
||||
)
|
||||
}
|
||||
return rowTitle(label: level.displayName, count: count)
|
||||
}
|
||||
|
||||
static func bookmarkTitle(geohash: String, count: Int) -> String {
|
||||
// Check precision for bookmarks too
|
||||
let len = geohash.count
|
||||
// Neighborhood=6, Block=7, Building=8+
|
||||
let isHighPrecision = (len >= 6)
|
||||
if isHighPrecision && count == 0 {
|
||||
return String(
|
||||
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
|
||||
locale: .current,
|
||||
"#\(geohash)"
|
||||
)
|
||||
}
|
||||
return rowTitle(label: "#\(geohash)", count: count)
|
||||
}
|
||||
|
||||
@@ -357,7 +378,7 @@ struct LocationChannelsSheet: View {
|
||||
isPresented = false
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
||||
.onAppear { bookmarks.resolveBookmarkNameIfNeeded(for: gh) }
|
||||
|
||||
if index < entries.count - 1 {
|
||||
sectionDivider
|
||||
|
||||
@@ -5,21 +5,6 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
private enum RegexCache {
|
||||
static let cashu: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||
}()
|
||||
static let lightningScheme: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", 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: [])
|
||||
}()
|
||||
}
|
||||
|
||||
extension String {
|
||||
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout
|
||||
func hasVeryLongToken(threshold: Int) -> Bool {
|
||||
@@ -38,7 +23,7 @@ extension String {
|
||||
|
||||
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
||||
func extractCashuLinks(max: Int = 3) -> [String] {
|
||||
let regex = RegexCache.cashu
|
||||
let regex = MessageFormattingEngine.Patterns.cashu
|
||||
let ns = self as NSString
|
||||
let range = NSRange(location: 0, length: ns.length)
|
||||
var found: [String] = []
|
||||
@@ -59,19 +44,19 @@ extension String {
|
||||
let ns = self as NSString
|
||||
let full = NSRange(location: 0, length: ns.length)
|
||||
// lightning: scheme
|
||||
for m in RegexCache.lightningScheme.matches(in: self, range: full) {
|
||||
for m in MessageFormattingEngine.Patterns.lightningScheme.matches(in: self, range: full) {
|
||||
let s = ns.substring(with: m.range(at: 0))
|
||||
results.append(s)
|
||||
if results.count >= max { return results }
|
||||
}
|
||||
// BOLT11
|
||||
for m in RegexCache.bolt11.matches(in: self, range: full) {
|
||||
for m in MessageFormattingEngine.Patterns.bolt11.matches(in: self, range: full) {
|
||||
let s = ns.substring(with: m.range(at: 0))
|
||||
results.append("lightning:\(s)")
|
||||
if results.count >= max { return results }
|
||||
}
|
||||
// LNURL bech32
|
||||
for m in RegexCache.lnurl.matches(in: self, range: full) {
|
||||
for m in MessageFormattingEngine.Patterns.lnurl.matches(in: self, range: full) {
|
||||
let s = ns.substring(with: m.range(at: 0))
|
||||
results.append("lightning:\(s)")
|
||||
if results.count >= max { return results }
|
||||
|
||||
@@ -10,32 +10,64 @@ import Foundation
|
||||
|
||||
final class PreviewKeychainManager: KeychainManagerProtocol {
|
||||
private var storage: [String: Data] = [:]
|
||||
private var serviceStorage: [String: [String: Data]] = [:]
|
||||
init() {}
|
||||
|
||||
|
||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||
storage[key] = keyData
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
func getIdentityKey(forKey key: String) -> Data? {
|
||||
storage[key]
|
||||
}
|
||||
|
||||
|
||||
func deleteIdentityKey(forKey key: String) -> Bool {
|
||||
storage.removeValue(forKey: key)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
func deleteAllKeychainData() -> Bool {
|
||||
storage.removeAll()
|
||||
serviceStorage.removeAll()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
func secureClear(_ data: inout Data) {}
|
||||
|
||||
|
||||
func secureClear(_ string: inout String) {}
|
||||
|
||||
|
||||
func verifyIdentityKeyExists() -> Bool {
|
||||
storage["identity_noiseStaticKey"] != nil
|
||||
}
|
||||
|
||||
// BCH-01-009: New methods with proper error classification
|
||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
||||
if let data = storage[key] {
|
||||
return .success(data)
|
||||
}
|
||||
return .itemNotFound
|
||||
}
|
||||
|
||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
||||
storage[key] = keyData
|
||||
return .success
|
||||
}
|
||||
|
||||
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
||||
|
||||
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
||||
if serviceStorage[service] == nil {
|
||||
serviceStorage[service] = [:]
|
||||
}
|
||||
serviceStorage[service]?[key] = data
|
||||
}
|
||||
|
||||
func load(key: String, service: String) -> Data? {
|
||||
serviceStorage[service]?[key]
|
||||
}
|
||||
|
||||
func delete(key: String, service: String) {
|
||||
serviceStorage[service]?.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// BLEServiceCoreTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Focused BLEService tests for packet handling behavior.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEServiceCoreTests {
|
||||
|
||||
@Test
|
||||
func duplicatePacket_isDeduped() async {
|
||||
let ble = makeService()
|
||||
let delegate = PublicCaptureDelegate()
|
||||
ble.delegate = delegate
|
||||
|
||||
let sender = PeerID(str: "1122334455667788")
|
||||
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
|
||||
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
|
||||
_ = await TestHelpers.waitUntil({ delegate.publicMessagesSnapshot().count == 1 },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
|
||||
let messages = delegate.publicMessagesSnapshot()
|
||||
#expect(messages.count == 1)
|
||||
#expect(messages.first?.content == "Hello")
|
||||
}
|
||||
|
||||
@Test
|
||||
func staleBroadcast_isIgnored() async {
|
||||
let ble = makeService()
|
||||
let delegate = PublicCaptureDelegate()
|
||||
ble.delegate = delegate
|
||||
|
||||
let sender = PeerID(str: "A1B2C3D4E5F60708")
|
||||
let oldTimestamp = UInt64(Date().addingTimeInterval(-901).timeIntervalSince1970 * 1000)
|
||||
let packet = makePublicPacket(content: "Old", sender: sender, timestamp: oldTimestamp)
|
||||
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
|
||||
let didReceive = await TestHelpers.waitUntil({ !delegate.publicMessagesSnapshot().isEmpty }, timeout: 0.3)
|
||||
#expect(!didReceive)
|
||||
#expect(delegate.publicMessagesSnapshot().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeService() -> BLEService {
|
||||
let keychain = MockKeychain()
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
return BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
||||
}
|
||||
|
||||
private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data(hexString: sender.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: Data(content.utf8),
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
)
|
||||
}
|
||||
|
||||
private final class PublicCaptureDelegate: BitchatDelegate {
|
||||
private let lock = NSLock()
|
||||
private(set) var publicMessages: [BitchatMessage] = []
|
||||
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: nickname,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
mentions: nil
|
||||
)
|
||||
lock.lock()
|
||||
publicMessages.append(message)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func didReceiveMessage(_ message: BitchatMessage) {}
|
||||
func didConnectToPeer(_ peerID: PeerID) {}
|
||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||
|
||||
func publicMessagesSnapshot() -> [BitchatMessage] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return publicMessages
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ struct BLEServiceTests {
|
||||
service.sendMessage("Hello, world!")
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
#expect(service.sentMessages.count == 1)
|
||||
}
|
||||
@@ -97,7 +97,7 @@ struct BLEServiceTests {
|
||||
)
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
#expect(service.sentMessages.count == 1)
|
||||
}
|
||||
@@ -113,7 +113,7 @@ struct BLEServiceTests {
|
||||
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ struct BLEServiceTests {
|
||||
service.simulateIncomingMessage(incomingMessage)
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ struct BLEServiceTests {
|
||||
service.simulateIncomingPacket(packet)
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ struct BLEServiceTests {
|
||||
service.sendMessage("Test delivery")
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ struct BLEServiceTests {
|
||||
service.simulateIncomingPacket(packet)
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
try await sleep(1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//
|
||||
// ChatViewModelDeliveryStatusTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for ChatViewModel delivery status state machine.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
@MainActor
|
||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
let keychain = MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let transport = MockTransport()
|
||||
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport
|
||||
)
|
||||
|
||||
return (viewModel, transport)
|
||||
}
|
||||
|
||||
// MARK: - Delivery Status Tests
|
||||
|
||||
struct ChatViewModelDeliveryStatusTests {
|
||||
|
||||
// MARK: - Status Transition Tests
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_noDowngrade_readToDelivered() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-1"
|
||||
|
||||
// Setup: create a message with .read status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .read(by: "Peer", at: Date())
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
|
||||
// Action: try to downgrade to .delivered
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
||||
|
||||
// Assert: status should remain .read (no downgrade)
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .read = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_upgrade_sentToDelivered() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-2"
|
||||
|
||||
// Setup: create a message with .sent status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
|
||||
// Action: upgrade to .delivered
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
||||
|
||||
// Assert: status should be .delivered
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .delivered = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_upgrade_deliveredToRead() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-3"
|
||||
|
||||
// Setup: create a message with .delivered status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .delivered(to: "Peer", at: Date().addingTimeInterval(-60))
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
|
||||
// Action: upgrade to .read
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: "Peer", at: Date()))
|
||||
|
||||
// Assert: status should be .read
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .read = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
// MARK: - Read Receipt Handling
|
||||
|
||||
@Test @MainActor
|
||||
func didReceiveReadReceipt_updatesMessageStatus() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-4"
|
||||
|
||||
// Setup: create a message with .sent status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
|
||||
// Action: receive read receipt
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: messageID,
|
||||
readerID: peerID,
|
||||
readerNickname: "Peer"
|
||||
)
|
||||
viewModel.didReceiveReadReceipt(receipt)
|
||||
|
||||
// Assert: status should be .read
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .read = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
// MARK: - Public Timeline Status Tests
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_publicTimeline_updatesCorrectly() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let messageID = "public-msg-1"
|
||||
|
||||
// Setup: add a message to public timeline with .sending status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Public message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: false,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
viewModel.messages.append(message)
|
||||
|
||||
// Action: update to .sent
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||
|
||||
// Assert
|
||||
let updatedMessage = viewModel.messages.first(where: { $0.id == messageID })
|
||||
#expect({
|
||||
if case .sent = updatedMessage?.deliveryStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
// MARK: - Status Rank Tests (for deduplication)
|
||||
|
||||
@Test @MainActor
|
||||
func statusRank_orderingIsCorrect() async {
|
||||
// This tests the implicit ordering used in refreshVisibleMessages
|
||||
// failed < sending < sent < partiallyDelivered < delivered < read
|
||||
|
||||
let statuses: [DeliveryStatus] = [
|
||||
.failed(reason: "test"),
|
||||
.sending,
|
||||
.sent,
|
||||
.partiallyDelivered(reached: 1, total: 3),
|
||||
.delivered(to: "B", at: Date()),
|
||||
.read(by: "C", at: Date())
|
||||
]
|
||||
|
||||
// Verify each status has a logical progression
|
||||
// This is more of a documentation test to ensure the ranking logic is understood
|
||||
for (index, status) in statuses.enumerated() {
|
||||
switch status {
|
||||
case .failed: #expect(index == 0)
|
||||
case .sending: #expect(index == 1)
|
||||
case .sent: #expect(index == 2)
|
||||
case .partiallyDelivered: #expect(index == 3)
|
||||
case .delivered: #expect(index == 4)
|
||||
case .read: #expect(index == 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
//
|
||||
// ChatViewModelExtensionsTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for ChatViewModel extensions (PrivateChat, Nostr, Tor).
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import Combine
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
@MainActor
|
||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
let keychain = MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let transport = MockTransport()
|
||||
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport
|
||||
)
|
||||
|
||||
return (viewModel, transport)
|
||||
}
|
||||
|
||||
// MARK: - Private Chat Extension Tests
|
||||
|
||||
struct ChatViewModelPrivateChatExtensionTests {
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivateMessage_mesh_storesAndSends() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
// Use valid hex string for PeerID (32 bytes = 64 hex chars for Noise key usually, or just valid hex)
|
||||
let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10"
|
||||
let peerID = PeerID(str: validHex)
|
||||
|
||||
// Simulate connection
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.peerNicknames[peerID] = "MeshUser"
|
||||
|
||||
viewModel.sendPrivateMessage("Hello Mesh", to: peerID)
|
||||
|
||||
// Verify transport was called
|
||||
// Note: MockTransport stores sent messages
|
||||
// Since sendPrivateMessage delegates to MessageRouter which delegates to Transport...
|
||||
// We need to ensure MessageRouter is using our MockTransport.
|
||||
// ChatViewModel init sets up MessageRouter with the passed transport.
|
||||
|
||||
// Wait for async processing
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Verify message stored locally
|
||||
#expect(viewModel.privateChats[peerID]?.count == 1)
|
||||
#expect(viewModel.privateChats[peerID]?.first?.content == "Hello Mesh")
|
||||
|
||||
// Verify message sent to transport (MockTransport captures sendPrivateMessage)
|
||||
// MockTransport.sendPrivateMessage is what MessageRouter calls for connected peers
|
||||
// Check MockTransport implementation... it might need update or verification
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivateMessage_unreachable_setsFailedStatus() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10"
|
||||
let peerID = PeerID(str: validHex)
|
||||
|
||||
viewModel.sendPrivateMessage("Hello", to: peerID)
|
||||
|
||||
#expect(viewModel.privateChats[peerID]?.count == 1)
|
||||
let status = viewModel.privateChats[peerID]?.last?.deliveryStatus
|
||||
#expect({
|
||||
if case .failed = status { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handlePrivateMessage_storesMessage() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "SENDER_001")
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "msg-1",
|
||||
sender: "Sender",
|
||||
content: "Private Content",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
)
|
||||
|
||||
// Simulate receiving a private message via the handlePrivateMessage extension method
|
||||
viewModel.handlePrivateMessage(message)
|
||||
|
||||
// Verify stored
|
||||
#expect(viewModel.privateChats[peerID]?.count == 1)
|
||||
#expect(viewModel.privateChats[peerID]?.first?.content == "Private Content")
|
||||
|
||||
// Verify notification trigger (unread count should increase if not viewing)
|
||||
#expect(viewModel.unreadPrivateMessages.contains(peerID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handlePrivateMessage_deduplicates() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "SENDER_001")
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "msg-1",
|
||||
sender: "Sender",
|
||||
content: "Content",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
|
||||
viewModel.handlePrivateMessage(message)
|
||||
viewModel.handlePrivateMessage(message) // Duplicate
|
||||
|
||||
#expect(viewModel.privateChats[peerID]?.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handlePrivateMessage_sendsReadReceipt_whenViewing() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "SENDER_001")
|
||||
|
||||
// Set as currently viewing
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "msg-1",
|
||||
sender: "Sender",
|
||||
content: "Content",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
|
||||
viewModel.handlePrivateMessage(message)
|
||||
|
||||
// Should NOT be marked unread
|
||||
#expect(!viewModel.unreadPrivateMessages.contains(peerID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func migratePrivateChats_consolidatesHistory_onFingerprintMatch() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let oldPeerID = PeerID(str: "OLD_PEER")
|
||||
let newPeerID = PeerID(str: "NEW_PEER")
|
||||
let fingerprint = "fp_123"
|
||||
|
||||
// Setup old chat
|
||||
let oldMessage = BitchatMessage(
|
||||
id: "msg-old",
|
||||
sender: "User",
|
||||
content: "Old message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
senderPeerID: oldPeerID
|
||||
)
|
||||
viewModel.privateChats[oldPeerID] = [oldMessage]
|
||||
viewModel.peerIDToPublicKeyFingerprint[oldPeerID] = fingerprint
|
||||
|
||||
// Setup new peer fingerprint
|
||||
viewModel.peerIDToPublicKeyFingerprint[newPeerID] = fingerprint
|
||||
|
||||
// Trigger migration
|
||||
viewModel.migratePrivateChatsIfNeeded(for: newPeerID, senderNickname: "User")
|
||||
|
||||
// Verify migration
|
||||
#expect(viewModel.privateChats[newPeerID]?.count == 1)
|
||||
#expect(viewModel.privateChats[newPeerID]?.first?.content == "Old message")
|
||||
#expect(viewModel.privateChats[oldPeerID] == nil) // Old chat removed
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func isMessageBlocked_filtersBlockedUsers() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let blockedPeerID = PeerID(str: "BLOCKED_PEER")
|
||||
|
||||
// Block the peer
|
||||
// MockIdentityManager stores state based on fingerprint
|
||||
// We need to map peerID to a fingerprint
|
||||
viewModel.peerIDToPublicKeyFingerprint[blockedPeerID] = "fp_blocked"
|
||||
viewModel.identityManager.setBlocked("fp_blocked", isBlocked: true)
|
||||
|
||||
// Also ensure UnifiedPeerService can resolve the fingerprint.
|
||||
// UnifiedPeerService uses its own cache or delegates to meshService/Peer list.
|
||||
// Since we are mocking, we can't easily inject into UnifiedPeerService's internal cache.
|
||||
// However, ChatViewModel's isMessageBlocked uses:
|
||||
// 1. isPeerBlocked(peerID) -> unifiedPeerService.isBlocked(peerID) -> getFingerprint -> identityManager.isBlocked
|
||||
|
||||
// We need UnifiedPeerService.getFingerprint(for: blockedPeerID) to return "fp_blocked"
|
||||
// UnifiedPeerService tries: cache -> meshService -> getPeer
|
||||
|
||||
// Option 1: Mock the transport (meshService) to return the fingerprint
|
||||
// (viewModel.transport is MockTransport, but UnifiedPeerService holds a reference to it)
|
||||
// Check if MockTransport has `getFingerprint`
|
||||
|
||||
// If not, we might need to rely on the fallback: ChatViewModel.isMessageBlocked also checks Nostr blocks.
|
||||
|
||||
// Let's assume MockTransport needs `getFingerprint` implementation or update it.
|
||||
// For now, let's try to verify if `MockTransport` supports `getFingerprint`.
|
||||
|
||||
// Actually, let's just use the Nostr block path which is simpler and also tested here.
|
||||
// "Check geohash (Nostr) blocks using mapping to full pubkey"
|
||||
|
||||
let hexPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
viewModel.nostrKeyMapping[blockedPeerID] = hexPubkey
|
||||
viewModel.identityManager.setNostrBlocked(hexPubkey, isBlocked: true)
|
||||
|
||||
// Force isGeoChat/isGeoDM check to be true by setting prefix?
|
||||
// Or ensure the logic covers it.
|
||||
// The logic is:
|
||||
// if peerID.isGeoChat || peerID.isGeoDM { check nostr }
|
||||
// We need a peerID that looks like geo.
|
||||
|
||||
let geoPeerID = PeerID(nostr_: hexPubkey)
|
||||
viewModel.nostrKeyMapping[geoPeerID] = hexPubkey
|
||||
|
||||
let geoMessage = BitchatMessage(
|
||||
id: "msg-geo-blocked",
|
||||
sender: "BlockedGeoUser",
|
||||
content: "Spam",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
senderPeerID: geoPeerID
|
||||
)
|
||||
|
||||
#expect(viewModel.isMessageBlocked(geoMessage))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Nostr Extension Tests
|
||||
|
||||
struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
@Test @MainActor
|
||||
func switchLocationChannel_mesh_clearsGeo() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup some geo state
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
||||
#expect(viewModel.currentGeohash == "u4pruydq")
|
||||
|
||||
// Switch to mesh
|
||||
viewModel.switchLocationChannel(to: .mesh)
|
||||
|
||||
#expect(viewModel.activeChannel == .mesh)
|
||||
#expect(viewModel.currentGeohash == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async {
|
||||
let geohash = "u4pruydq"
|
||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
||||
|
||||
LocationChannelManager.shared.select(channel)
|
||||
defer { LocationChannelManager.shared.select(.mesh) }
|
||||
|
||||
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel })
|
||||
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })
|
||||
|
||||
var event = NostrEvent(
|
||||
pubkey: "pub1",
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Hello Geo"
|
||||
)
|
||||
event.id = "evt1"
|
||||
event.sig = "sig"
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
|
||||
let didAppend = await TestHelpers.waitUntil({
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
return viewModel.messages.contains { $0.content == "Hello Geo" }
|
||||
})
|
||||
#expect(didAppend)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleNostrEvent_ignoresRecentSelfEcho() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
|
||||
|
||||
var event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Self echo"
|
||||
)
|
||||
event.id = "evt-self"
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
|
||||
#expect(!viewModel.messages.contains { $0.content == "Self echo" })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleNostrEvent_skipsBlockedSender() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let blockedPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true)
|
||||
|
||||
var event = NostrEvent(
|
||||
pubkey: blockedPubkey,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Blocked"
|
||||
)
|
||||
event.id = "evt-blocked"
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
|
||||
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func switchLocationChannel_clearsNostrDedupCache() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.deduplicationService.recordNostrEvent("evt-cache")
|
||||
#expect(viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash Queue Tests
|
||||
|
||||
struct ChatViewModelGeohashQueueTests {
|
||||
|
||||
@Test @MainActor
|
||||
func addGeohashOnlySystemMessage_queuesUntilLocationChannel() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.addGeohashOnlySystemMessage("Queued system")
|
||||
#expect(!viewModel.messages.contains { $0.content == "Queued system" })
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
#expect(viewModel.messages.contains { $0.content == "Queued system" })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GeoDM Tests
|
||||
|
||||
struct ChatViewModelGeoDMTests {
|
||||
|
||||
@Test @MainActor
|
||||
func handlePrivateMessage_geohash_dedupsAndTracksAck() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
let messageID = "pm-1"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
|
||||
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
let packet = PrivateMessagePacket(messageID: messageID, content: "Hello")
|
||||
let payloadData = try #require(packet.encode(), "Failed to encode private message")
|
||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
||||
|
||||
viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
|
||||
viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
|
||||
|
||||
#expect(viewModel.privateChats[convKey]?.count == 1)
|
||||
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// ChatViewModelRefactoringTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Pinning tests to characterize ChatViewModel behavior before refactoring.
|
||||
// These tests act as a safety net to ensure we don't break existing functionality.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct ChatViewModelRefactoringTests {
|
||||
|
||||
// Helper to setup the environment
|
||||
@MainActor
|
||||
private func makePinnedViewModel() -> (viewModel: ChatViewModel, transport: MockTransport, identity: MockIdentityManager) {
|
||||
let keychain = MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let transport = MockTransport()
|
||||
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport
|
||||
)
|
||||
|
||||
return (viewModel, transport, identityManager)
|
||||
}
|
||||
|
||||
// MARK: - Command Processor Integration "Pinning"
|
||||
|
||||
@Test @MainActor
|
||||
func command_msg_routesToTransport() async throws {
|
||||
let (viewModel, transport, _) = makePinnedViewModel()
|
||||
|
||||
// Setup: Use simulateConnect so ChatViewModel and UnifiedPeerService are notified
|
||||
let peerID = PeerID(str: "0000000000000001")
|
||||
transport.simulateConnect(peerID, nickname: "alice")
|
||||
|
||||
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#expect(didResolve)
|
||||
|
||||
// Action: User types /msg command
|
||||
viewModel.sendMessage("/msg @alice Hello Private World")
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#expect(didSend)
|
||||
|
||||
// Assert:
|
||||
// 1. Should NOT go to public transport
|
||||
#expect(transport.sentMessages.isEmpty, "Command should not be sent as public message")
|
||||
|
||||
// 2. Should go to private transport logic
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
#expect(transport.sentPrivateMessages.first?.content == "Hello Private World")
|
||||
#expect(transport.sentPrivateMessages.first?.peerID == peerID)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func command_block_updatesIdentity() async throws {
|
||||
let (viewModel, transport, identity) = makePinnedViewModel()
|
||||
|
||||
// Setup: Use simulateConnect
|
||||
let peerID = PeerID(str: "0000000000000002")
|
||||
// Mock the fingerprint so the block command finds it
|
||||
transport.peerFingerprints[peerID] = "fingerprint_123"
|
||||
transport.simulateConnect(peerID, nickname: "troll")
|
||||
|
||||
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#expect(didResolve)
|
||||
|
||||
// Action
|
||||
viewModel.sendMessage("/block @troll")
|
||||
|
||||
// Assert
|
||||
// Verify identity manager was called to block "fingerprint_123"
|
||||
let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#expect(didBlock)
|
||||
}
|
||||
|
||||
// MARK: - Message Routing Logic
|
||||
|
||||
@Test @MainActor
|
||||
func routing_incomingPrivateMessage_addsToPrivateChats() async {
|
||||
let (viewModel, _, _) = makePinnedViewModel()
|
||||
let senderID = PeerID(str: "sender_1")
|
||||
|
||||
// Setup
|
||||
let message = BitchatMessage(
|
||||
id: "msg_1",
|
||||
sender: "bob",
|
||||
content: "Secret",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: "me",
|
||||
senderPeerID: senderID,
|
||||
mentions: nil
|
||||
)
|
||||
|
||||
// Action: Simulate incoming private message
|
||||
viewModel.didReceiveMessage(message)
|
||||
|
||||
// Wait for async processing with proper timeout
|
||||
let found = await TestHelpers.waitUntil(
|
||||
{ viewModel.privateChats[senderID]?.first?.content == "Secret" },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(found)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func routing_incomingPublicMessage_addsToPublicTimeline() async {
|
||||
let (viewModel, _, _) = makePinnedViewModel()
|
||||
let senderID = PeerID(str: "sender_2")
|
||||
|
||||
// Setup
|
||||
let message = BitchatMessage(
|
||||
id: "msg_2",
|
||||
sender: "charlie",
|
||||
content: "Public Hi",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: senderID,
|
||||
mentions: nil
|
||||
)
|
||||
|
||||
// Action
|
||||
viewModel.didReceiveMessage(message)
|
||||
|
||||
// Wait for async processing with proper timeout
|
||||
let found = await TestHelpers.waitUntil(
|
||||
{ viewModel.messages.contains(where: { $0.content == "Public Hi" }) },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(found)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
//
|
||||
// ChatViewModelTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for ChatViewModel using MockTransport for isolation.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
/// Creates a ChatViewModel with mock dependencies for testing
|
||||
@MainActor
|
||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
let keychain = MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let transport = MockTransport()
|
||||
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport
|
||||
)
|
||||
|
||||
return (viewModel, transport)
|
||||
}
|
||||
|
||||
// MARK: - Initialization Tests
|
||||
|
||||
struct ChatViewModelInitializationTests {
|
||||
|
||||
@Test @MainActor
|
||||
func initialization_setsDelegate() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
// The viewModel should set itself as the transport delegate
|
||||
#expect(transport.delegate === viewModel)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func initialization_startsServices() async {
|
||||
let (_, transport) = makeTestableViewModel()
|
||||
|
||||
// Services should be started during init
|
||||
#expect(transport.startServicesCallCount == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func initialization_hasEmptyMessageList() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Initial messages may include system messages, but should be limited
|
||||
#expect(viewModel.messages.count < 10)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func initialization_setsNickname() async {
|
||||
let (_, transport) = makeTestableViewModel()
|
||||
|
||||
// Nickname should be set during init
|
||||
#expect(!transport.myNickname.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Sending Tests
|
||||
|
||||
struct ChatViewModelSendingTests {
|
||||
|
||||
@Test @MainActor
|
||||
func sendMessage_delegatesToTransport() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
viewModel.sendMessage("Hello World")
|
||||
|
||||
#expect(transport.sentMessages.count == 1)
|
||||
#expect(transport.sentMessages.first?.content == "Hello World")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendMessage_emptyContent_ignored() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
viewModel.sendMessage("")
|
||||
viewModel.sendMessage(" ")
|
||||
viewModel.sendMessage("\n\t")
|
||||
|
||||
#expect(transport.sentMessages.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendMessage_withMentions_sendsContent() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
viewModel.sendMessage("Hello @alice")
|
||||
|
||||
#expect(transport.sentMessages.count == 1)
|
||||
#expect(transport.sentMessages.first?.content == "Hello @alice")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendMessage_command_notSentToTransport() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
viewModel.sendMessage("/help")
|
||||
|
||||
// Commands are processed locally, not sent to transport
|
||||
#expect(transport.sentMessages.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Command Handling Tests
|
||||
|
||||
struct ChatViewModelCommandTests {
|
||||
|
||||
@Test @MainActor
|
||||
func sendMessage_commandsNotSentToTransport() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let commands = ["/nick bob", "/who", "/help", "/clear"]
|
||||
|
||||
for command in commands {
|
||||
transport.resetRecordings()
|
||||
viewModel.sendMessage(command)
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
#expect(transport.sentMessages.isEmpty)
|
||||
#expect(transport.sentPrivateMessages.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline Cap Tests
|
||||
|
||||
struct ChatViewModelTimelineCapTests {
|
||||
|
||||
@Test @MainActor
|
||||
func sendMessage_trimsTimelineToCap() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let total = TransportConfig.meshTimelineCap + 5
|
||||
|
||||
for i in 0..<total {
|
||||
viewModel.sendMessage("cap-msg-\(i)")
|
||||
}
|
||||
|
||||
#expect(viewModel.messages.count == TransportConfig.meshTimelineCap)
|
||||
#expect(viewModel.messages.last?.content == "cap-msg-\(total - 1)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Receiving Tests
|
||||
|
||||
struct ChatViewModelReceivingTests {
|
||||
|
||||
@Test @MainActor
|
||||
func didReceiveMessage_callsDelegate() async {
|
||||
let (_, transport) = makeTestableViewModel()
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "msg-001",
|
||||
sender: "Alice",
|
||||
content: "Hello from Alice",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: PeerID(str: "PEER001"),
|
||||
mentions: nil
|
||||
)
|
||||
|
||||
transport.simulateIncomingMessage(message)
|
||||
|
||||
// Give time for Task and pipeline processing
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
|
||||
// Message may or may not appear due to rate limiting/pipeline batching
|
||||
// The important thing is no crash and delegate was called
|
||||
#expect(transport.delegate != nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didReceivePublicMessage_addsToTimeline() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
transport.simulateIncomingPublicMessage(
|
||||
from: PeerID(str: "PEER002"),
|
||||
nickname: "Bob",
|
||||
content: "Public hello from Bob",
|
||||
timestamp: Date(),
|
||||
messageID: "pub-001"
|
||||
)
|
||||
|
||||
// Give time for async Task and pipeline processing
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
|
||||
#expect(viewModel.messages.contains { $0.content == "Public hello from Bob" })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rate Limiting Tests
|
||||
|
||||
struct ChatViewModelRateLimitingTests {
|
||||
|
||||
@Test @MainActor
|
||||
func handlePublicMessage_rateLimitsBurstBySender() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let senderID = PeerID(str: "1122334455667788")
|
||||
let now = Date()
|
||||
|
||||
for i in 0..<6 {
|
||||
let message = BitchatMessage(
|
||||
id: "rate-\(i)",
|
||||
sender: "Spammer",
|
||||
content: "rate-msg-\(i)",
|
||||
timestamp: now,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: senderID,
|
||||
mentions: nil
|
||||
)
|
||||
viewModel.handlePublicMessage(message)
|
||||
}
|
||||
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
|
||||
let burstMessages = viewModel.messages.filter { $0.content.hasPrefix("rate-msg-") }
|
||||
#expect(burstMessages.count == 5)
|
||||
#expect(!burstMessages.contains { $0.content == "rate-msg-5" })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Peer Connection Tests
|
||||
|
||||
struct ChatViewModelPeerTests {
|
||||
|
||||
@Test @MainActor
|
||||
func didConnectToPeer_notifiesDelegate() async {
|
||||
let (_, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "NEWPEER")
|
||||
|
||||
transport.simulateConnect(peerID, nickname: "NewUser")
|
||||
|
||||
#expect(transport.connectedPeers.contains(peerID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didDisconnectFromPeer_notifiesDelegate() async {
|
||||
let (_, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "OLDPEER")
|
||||
|
||||
transport.simulateConnect(peerID, nickname: "OldUser")
|
||||
transport.simulateDisconnect(peerID)
|
||||
|
||||
#expect(!transport.connectedPeers.contains(peerID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func isPeerConnected_delegatesToTransport() async {
|
||||
let (_, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "TESTPEER")
|
||||
|
||||
// Not connected initially
|
||||
#expect(!transport.isPeerConnected(peerID))
|
||||
|
||||
transport.connectedPeers.insert(peerID)
|
||||
|
||||
#expect(transport.isPeerConnected(peerID))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Deduplication Integration Tests
|
||||
//
|
||||
// Note: Detailed deduplication logic is tested in MessageDeduplicationServiceTests.
|
||||
// These tests verify that ChatViewModel has a deduplication service configured.
|
||||
|
||||
struct ChatViewModelDeduplicationTests {
|
||||
|
||||
@Test @MainActor
|
||||
func deduplicationService_isConfigured() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Verify the deduplication service is available and functional
|
||||
// by checking that we can record and query content
|
||||
let testContent = "Test dedup content \(UUID().uuidString)"
|
||||
let testDate = Date()
|
||||
|
||||
viewModel.deduplicationService.recordContent(testContent, timestamp: testDate)
|
||||
|
||||
let retrieved = viewModel.deduplicationService.contentTimestamp(for: testContent)
|
||||
#expect(retrieved == testDate)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deduplicationService_normalizedKey_consistent() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
let content = "Hello World"
|
||||
let key1 = viewModel.deduplicationService.normalizedContentKey(content)
|
||||
let key2 = viewModel.deduplicationService.normalizedContentKey(content)
|
||||
|
||||
#expect(key1 == key2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Chat Tests
|
||||
|
||||
struct ChatViewModelPrivateChatTests {
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivateMessage_delegatesToTransport() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let recipientID = PeerID(str: "RECIPIENT")
|
||||
|
||||
// Set up connected peer for routing
|
||||
transport.connectedPeers.insert(recipientID)
|
||||
transport.peerNicknames[recipientID] = "Recipient"
|
||||
|
||||
viewModel.sendPrivateMessage("Secret message", to: recipientID)
|
||||
|
||||
// The message routing depends on connection state and other factors
|
||||
// At minimum, it should not crash
|
||||
#expect(true) // If we get here without crash, the test passes
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Chat Selection Tests
|
||||
|
||||
struct ChatViewModelPrivateChatSelectionTests {
|
||||
|
||||
@Test @MainActor
|
||||
func openMostRelevantPrivateChat_prefersUnreadMostRecent() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerA = PeerID(str: "PEER_A")
|
||||
let peerB = PeerID(str: "PEER_B")
|
||||
|
||||
let older = Date().addingTimeInterval(-120)
|
||||
let newer = Date().addingTimeInterval(-30)
|
||||
|
||||
viewModel.privateChats = [
|
||||
peerA: [
|
||||
BitchatMessage(
|
||||
id: "a-1",
|
||||
sender: "A",
|
||||
content: "Old",
|
||||
timestamp: older,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerA
|
||||
)
|
||||
],
|
||||
peerB: [
|
||||
BitchatMessage(
|
||||
id: "b-1",
|
||||
sender: "B",
|
||||
content: "New",
|
||||
timestamp: newer,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerB
|
||||
)
|
||||
]
|
||||
]
|
||||
viewModel.unreadPrivateMessages = [peerA, peerB]
|
||||
|
||||
viewModel.openMostRelevantPrivateChat()
|
||||
|
||||
#expect(viewModel.selectedPrivateChatPeer == peerB)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func openMostRelevantPrivateChat_fallsBackToMostRecentChat() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerA = PeerID(str: "PEER_A")
|
||||
let peerB = PeerID(str: "PEER_B")
|
||||
|
||||
let older = Date().addingTimeInterval(-200)
|
||||
let newer = Date().addingTimeInterval(-20)
|
||||
|
||||
viewModel.privateChats = [
|
||||
peerA: [
|
||||
BitchatMessage(
|
||||
id: "a-1",
|
||||
sender: "A",
|
||||
content: "Old",
|
||||
timestamp: older,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerA
|
||||
)
|
||||
],
|
||||
peerB: [
|
||||
BitchatMessage(
|
||||
id: "b-1",
|
||||
sender: "B",
|
||||
content: "New",
|
||||
timestamp: newer,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerB
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
viewModel.openMostRelevantPrivateChat()
|
||||
|
||||
#expect(viewModel.selectedPrivateChatPeer == peerB)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bluetooth State Tests
|
||||
|
||||
struct ChatViewModelBluetoothTests {
|
||||
|
||||
@Test @MainActor
|
||||
func didUpdateBluetoothState_poweredOn_noAlert() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
transport.simulateBluetoothStateChange(.poweredOn)
|
||||
|
||||
// Give time for async processing
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
#expect(!viewModel.showBluetoothAlert)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didUpdateBluetoothState_poweredOff_showsAlert() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
transport.simulateBluetoothStateChange(.poweredOff)
|
||||
|
||||
// Give time for async processing
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
#expect(viewModel.showBluetoothAlert)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didUpdateBluetoothState_unauthorized_showsAlert() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
transport.simulateBluetoothStateChange(.unauthorized)
|
||||
|
||||
// Give time for async processing
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
#expect(viewModel.showBluetoothAlert)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Panic Clear Tests
|
||||
|
||||
struct ChatViewModelPanicTests {
|
||||
|
||||
@Test @MainActor
|
||||
func panicClearAllData_delegatesToTransport() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
// Set up some state
|
||||
transport.connectedPeers.insert(PeerID(str: "PEER1"))
|
||||
viewModel.messages = [
|
||||
BitchatMessage(
|
||||
id: "panic-1",
|
||||
sender: "Tester",
|
||||
content: "Before",
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
]
|
||||
viewModel.privateChats[PeerID(str: "PEER1")] = [
|
||||
BitchatMessage(
|
||||
id: "pm-1",
|
||||
sender: "Peer",
|
||||
content: "Secret",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: PeerID(str: "PEER1")
|
||||
)
|
||||
]
|
||||
viewModel.unreadPrivateMessages.insert(PeerID(str: "PEER1"))
|
||||
|
||||
viewModel.panicClearAllData()
|
||||
|
||||
// After panic, emergency disconnect should be called
|
||||
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||
#expect(viewModel.messages.isEmpty)
|
||||
#expect(viewModel.privateChats.isEmpty)
|
||||
#expect(viewModel.unreadPrivateMessages.isEmpty)
|
||||
#expect(viewModel.selectedPrivateChatPeer == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Service Lifecycle Tests
|
||||
|
||||
struct ChatViewModelLifecycleTests {
|
||||
|
||||
@Test @MainActor
|
||||
func startServices_calledOnInit() async {
|
||||
let (_, transport) = makeTestableViewModel()
|
||||
|
||||
#expect(transport.startServicesCallCount == 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//
|
||||
// ChatViewModelTorTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for ChatViewModel+Tor.swift Tor lifecycle notification handlers.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
@MainActor
|
||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
let keychain = MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let transport = MockTransport()
|
||||
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport
|
||||
)
|
||||
|
||||
return (viewModel, transport)
|
||||
}
|
||||
|
||||
// MARK: - Tor Notification Handler Tests
|
||||
|
||||
struct ChatViewModelTorTests {
|
||||
|
||||
// MARK: - handleTorWillStart Tests
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorWillStart_whenEnforced_setsAnnouncedFlag() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Precondition: flag should start false
|
||||
#expect(!viewModel.torStatusAnnounced)
|
||||
|
||||
// Action: simulate Tor starting notification
|
||||
viewModel.handleTorWillStart()
|
||||
|
||||
// Wait for Task to complete
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: flag should be set (torEnforced is true in tests)
|
||||
#expect(viewModel.torStatusAnnounced)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorWillStart_whenAlreadyAnnounced_doesNotDuplicate() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: pre-set the flag
|
||||
viewModel.torStatusAnnounced = true
|
||||
|
||||
// Switch to a geohash channel so messages would be visible
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
let initialMessageCount = viewModel.messages.count
|
||||
|
||||
// Action: call handler again
|
||||
viewModel.handleTorWillStart()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: no new message added (flag was already true)
|
||||
#expect(viewModel.messages.count == initialMessageCount)
|
||||
}
|
||||
|
||||
// MARK: - handleTorWillRestart Tests
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorWillRestart_setsPendingFlag() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Precondition
|
||||
#expect(!viewModel.torRestartPending)
|
||||
|
||||
// Action
|
||||
viewModel.handleTorWillRestart()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert
|
||||
#expect(viewModel.torRestartPending)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorWillRestart_setsFlag_regardlessOfChannel() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Action: call handler (works regardless of channel)
|
||||
viewModel.handleTorWillRestart()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: flag should be set
|
||||
#expect(viewModel.torRestartPending)
|
||||
}
|
||||
|
||||
// MARK: - handleTorDidBecomeReady Tests
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorDidBecomeReady_afterRestart_clearsPendingFlag() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: simulate restart pending state
|
||||
viewModel.torRestartPending = true
|
||||
|
||||
// Action
|
||||
viewModel.handleTorDidBecomeReady()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: should clear pending flag
|
||||
#expect(!viewModel.torRestartPending)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorDidBecomeReady_initialStart_setsAnnouncedFlag() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: not restarting, but initial ready not announced yet
|
||||
viewModel.torRestartPending = false
|
||||
viewModel.torInitialReadyAnnounced = false
|
||||
|
||||
// Action
|
||||
viewModel.handleTorDidBecomeReady()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: should set flag (torEnforced is true in tests)
|
||||
#expect(viewModel.torInitialReadyAnnounced)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorDidBecomeReady_alreadyAnnounced_noDuplicate() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: already announced initial ready
|
||||
viewModel.torRestartPending = false
|
||||
viewModel.torInitialReadyAnnounced = true
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
let initialMessageCount = viewModel.messages.count
|
||||
|
||||
// Action
|
||||
viewModel.handleTorDidBecomeReady()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: no new message
|
||||
#expect(viewModel.messages.count == initialMessageCount)
|
||||
}
|
||||
|
||||
// MARK: - handleTorPreferenceChanged Tests
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorPreferenceChanged_resetsAllFlags() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: set all flags
|
||||
viewModel.torStatusAnnounced = true
|
||||
viewModel.torInitialReadyAnnounced = true
|
||||
viewModel.torRestartPending = true
|
||||
|
||||
// Action
|
||||
viewModel.handleTorPreferenceChanged(Notification(name: .init("test")))
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: all flags reset
|
||||
#expect(!viewModel.torStatusAnnounced)
|
||||
#expect(!viewModel.torInitialReadyAnnounced)
|
||||
#expect(!viewModel.torRestartPending)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ struct CommandProcessorTests {
|
||||
|
||||
@MainActor
|
||||
@Test func slapNotFoundGrammar() {
|
||||
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
||||
let result = processor.process("/slap @system")
|
||||
switch result {
|
||||
case .error(let message):
|
||||
@@ -18,7 +18,7 @@ struct CommandProcessorTests {
|
||||
|
||||
@MainActor
|
||||
@Test func hugNotFoundGrammar() {
|
||||
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
||||
let result = processor.process("/hug @system")
|
||||
switch result {
|
||||
case .error(let message):
|
||||
@@ -30,7 +30,7 @@ struct CommandProcessorTests {
|
||||
|
||||
@MainActor
|
||||
@Test func slapUsageMessage() {
|
||||
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
||||
let result = processor.process("/slap")
|
||||
switch result {
|
||||
case .error(let message):
|
||||
|
||||
@@ -32,29 +32,28 @@ struct FragmentationTests {
|
||||
)
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
|
||||
// Construct a big packet (3KB) from a remote sender (not our own ID)
|
||||
let remoteShortID = PeerID(str: "1122334455667788")
|
||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
|
||||
|
||||
|
||||
// Use a small fragment size to ensure multiple pieces
|
||||
let fragments = fragmentPacket(original, fragmentSize: 400)
|
||||
|
||||
|
||||
// Shuffle fragments to simulate out-of-order arrival
|
||||
let shuffled = fragments.shuffled()
|
||||
|
||||
// Inject fragments spaced out to avoid concurrent mutation inside BLEService
|
||||
|
||||
// Send fragments sequentially with small delays (no fire-and-forget Tasks)
|
||||
for (i, fragment) in shuffled.enumerated() {
|
||||
let delay = 5 * Double(i) * 0.001
|
||||
Task {
|
||||
try await sleep(delay)
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||
if i > 0 {
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||
}
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
|
||||
|
||||
// Wait for delegate callback with proper timeout
|
||||
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2))
|
||||
|
||||
#expect(capture.publicMessages.count == 1)
|
||||
#expect(capture.publicMessages.first?.content.count == 3_000)
|
||||
}
|
||||
@@ -68,26 +67,26 @@ struct FragmentationTests {
|
||||
)
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
|
||||
let remoteShortID = PeerID(str: "A1B2C3D4E5F60708")
|
||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
|
||||
var frags = fragmentPacket(original, fragmentSize: 300)
|
||||
|
||||
|
||||
// Duplicate one fragment
|
||||
if let dup = frags.first {
|
||||
frags.insert(dup, at: 1)
|
||||
}
|
||||
|
||||
|
||||
// Send fragments sequentially with small delays (no fire-and-forget Tasks)
|
||||
for (i, fragment) in frags.enumerated() {
|
||||
let delay = 5 * Double(i) * 0.001
|
||||
Task {
|
||||
try await sleep(delay)
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||
if i > 0 {
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||
}
|
||||
|
||||
// Allow async processing
|
||||
try await sleep(0.5)
|
||||
|
||||
// Wait for delegate callback with proper timeout
|
||||
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2))
|
||||
|
||||
#expect(capture.publicMessages.count == 1)
|
||||
#expect(capture.publicMessages.first?.content.count == 2048)
|
||||
@@ -135,7 +134,7 @@ struct FragmentationTests {
|
||||
}
|
||||
}
|
||||
|
||||
try await sleep(1.0)
|
||||
try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(2))
|
||||
|
||||
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
|
||||
#expect(message.content.hasPrefix("[file]"))
|
||||
@@ -196,12 +195,128 @@ struct FragmentationTests {
|
||||
}
|
||||
|
||||
extension FragmentationTests {
|
||||
private final class CaptureDelegate: BitchatDelegate {
|
||||
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
|
||||
var receivedMessages: [BitchatMessage] = []
|
||||
func didReceiveMessage(_ message: BitchatMessage) {
|
||||
receivedMessages.append(message)
|
||||
/// Thread-safe delegate that supports awaiting message delivery
|
||||
private final class CaptureDelegate: BitchatDelegate, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var _publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
|
||||
private var _receivedMessages: [BitchatMessage] = []
|
||||
private var publicMessageContinuation: CheckedContinuation<Void, Never>?
|
||||
private var receivedMessageContinuation: CheckedContinuation<Void, Never>?
|
||||
private var expectedPublicMessageCount: Int = 0
|
||||
private var expectedReceivedMessageCount: Int = 0
|
||||
|
||||
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _publicMessages
|
||||
}
|
||||
|
||||
var receivedMessages: [BitchatMessage] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _receivedMessages
|
||||
}
|
||||
|
||||
func didReceiveMessage(_ message: BitchatMessage) {
|
||||
lock.lock()
|
||||
_receivedMessages.append(message)
|
||||
let count = _receivedMessages.count
|
||||
let expected = expectedReceivedMessageCount
|
||||
let continuation = receivedMessageContinuation
|
||||
lock.unlock()
|
||||
|
||||
if count >= expected, let cont = continuation {
|
||||
lock.lock()
|
||||
receivedMessageContinuation = nil
|
||||
lock.unlock()
|
||||
cont.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
lock.lock()
|
||||
_publicMessages.append((peerID, nickname, content))
|
||||
let count = _publicMessages.count
|
||||
let expected = expectedPublicMessageCount
|
||||
let continuation = publicMessageContinuation
|
||||
lock.unlock()
|
||||
|
||||
if count >= expected, let cont = continuation {
|
||||
lock.lock()
|
||||
publicMessageContinuation = nil
|
||||
lock.unlock()
|
||||
cont.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits for the specified number of public messages to be received
|
||||
func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
|
||||
lock.lock()
|
||||
if _publicMessages.count >= count {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
expectedPublicMessageCount = count
|
||||
lock.unlock()
|
||||
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.lock.lock()
|
||||
// Recheck count after acquiring lock to avoid race condition
|
||||
// where message arrives between initial check and continuation install
|
||||
if self._publicMessages.count >= count {
|
||||
self.lock.unlock()
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
self.publicMessageContinuation = continuation
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(for: timeout)
|
||||
throw CancellationError()
|
||||
}
|
||||
try await group.next()
|
||||
group.cancelAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits for the specified number of received messages
|
||||
func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
|
||||
lock.lock()
|
||||
if _receivedMessages.count >= count {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
expectedReceivedMessageCount = count
|
||||
lock.unlock()
|
||||
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.lock.lock()
|
||||
// Recheck count after acquiring lock to avoid race condition
|
||||
// where message arrives between initial check and continuation install
|
||||
if self._receivedMessages.count >= count {
|
||||
self.lock.unlock()
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
self.receivedMessageContinuation = continuation
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(for: timeout)
|
||||
throw CancellationError()
|
||||
}
|
||||
try await group.next()
|
||||
group.cancelAll()
|
||||
}
|
||||
}
|
||||
|
||||
func didConnectToPeer(_ peerID: PeerID) {}
|
||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||
@@ -209,9 +324,6 @@ extension FragmentationTests {
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
publicMessages.append((peerID, nickname, content))
|
||||
}
|
||||
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
//
|
||||
// GeohashParticipantTrackerTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for GeohashParticipantTracker.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
/// Mock context for testing
|
||||
@MainActor
|
||||
final class MockParticipantContext: GeohashParticipantContext {
|
||||
var blockedPubkeys: Set<String> = []
|
||||
var nicknameMap: [String: String] = [:]
|
||||
var selfPubkey: String?
|
||||
|
||||
func displayNameForPubkey(_ pubkeyHex: String) -> String {
|
||||
let suffix = String(pubkeyHex.suffix(4))
|
||||
if let self = selfPubkey, pubkeyHex.lowercased() == self.lowercased() {
|
||||
return "me#\(suffix)"
|
||||
}
|
||||
if let nick = nicknameMap[pubkeyHex.lowercased()] {
|
||||
return "\(nick)#\(suffix)"
|
||||
}
|
||||
return "anon#\(suffix)"
|
||||
}
|
||||
|
||||
func isBlocked(_ pubkeyHexLowercased: String) -> Bool {
|
||||
blockedPubkeys.contains(pubkeyHexLowercased.lowercased())
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct GeohashParticipantTrackerTests {
|
||||
|
||||
// MARK: - Basic Recording Tests
|
||||
|
||||
@Test func recordParticipant_addsToActiveGeohash() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
tracker.setActiveGeohash("abc123")
|
||||
|
||||
tracker.recordParticipant(pubkeyHex: "deadbeef1234")
|
||||
|
||||
#expect(tracker.participantCount(for: "abc123") == 1)
|
||||
}
|
||||
|
||||
@Test func recordParticipant_noActiveGeohash_noOp() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
// No active geohash set
|
||||
|
||||
tracker.recordParticipant(pubkeyHex: "deadbeef1234")
|
||||
|
||||
// Should not throw or crash
|
||||
#expect(tracker.participantCount(for: "abc123") == 0)
|
||||
}
|
||||
|
||||
@Test func recordParticipant_specificGeohash() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1")
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo2")
|
||||
|
||||
#expect(tracker.participantCount(for: "geo1") == 1)
|
||||
#expect(tracker.participantCount(for: "geo2") == 1)
|
||||
}
|
||||
|
||||
@Test func recordParticipant_updatesLastSeen() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
tracker.setActiveGeohash("abc123")
|
||||
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
||||
// Small delay and record again
|
||||
try? await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
||||
|
||||
// Should still count as 1 participant (updated, not duplicated)
|
||||
#expect(tracker.participantCount(for: "abc123") == 1)
|
||||
}
|
||||
|
||||
@Test func recordParticipant_lowercasesPubkey() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
tracker.setActiveGeohash("abc123")
|
||||
|
||||
tracker.recordParticipant(pubkeyHex: "DEADBEEF")
|
||||
tracker.recordParticipant(pubkeyHex: "deadbeef")
|
||||
|
||||
// Should be treated as same participant
|
||||
#expect(tracker.participantCount(for: "abc123") == 1)
|
||||
}
|
||||
|
||||
// MARK: - Visible People Tests
|
||||
|
||||
@Test func getVisiblePeople_returnsActiveGeohashParticipants() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
tracker.setActiveGeohash("abc123")
|
||||
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey2")
|
||||
|
||||
let people = tracker.getVisiblePeople()
|
||||
#expect(people.count == 2)
|
||||
}
|
||||
|
||||
@Test func getVisiblePeople_excludesBlockedParticipants() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
context.blockedPubkeys = ["pubkey2"]
|
||||
tracker.configure(context: context)
|
||||
tracker.setActiveGeohash("abc123")
|
||||
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey2")
|
||||
|
||||
let people = tracker.getVisiblePeople()
|
||||
#expect(people.count == 1)
|
||||
#expect(people.first?.id == "pubkey1")
|
||||
}
|
||||
|
||||
@Test func getVisiblePeople_usesDisplayNameFromContext() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
context.nicknameMap = ["pubkey1234": "alice"]
|
||||
tracker.configure(context: context)
|
||||
tracker.setActiveGeohash("abc123")
|
||||
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1234")
|
||||
|
||||
let people = tracker.getVisiblePeople()
|
||||
#expect(people.count == 1)
|
||||
#expect(people.first?.displayName == "alice#1234")
|
||||
}
|
||||
|
||||
@Test func getVisiblePeople_sortedByLastSeen() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
tracker.setActiveGeohash("abc123")
|
||||
|
||||
tracker.recordParticipant(pubkeyHex: "older")
|
||||
try? await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
||||
tracker.recordParticipant(pubkeyHex: "newer")
|
||||
|
||||
let people = tracker.getVisiblePeople()
|
||||
#expect(people.count == 2)
|
||||
#expect(people.first?.id == "newer")
|
||||
#expect(people.last?.id == "older")
|
||||
}
|
||||
|
||||
@Test func getVisiblePeople_emptyWhenNoActiveGeohash() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "abc123")
|
||||
|
||||
let people = tracker.getVisiblePeople()
|
||||
#expect(people.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Activity Cutoff Tests
|
||||
|
||||
@Test func participantCount_excludesExpiredEntries() async {
|
||||
// Use a very short cutoff for testing
|
||||
let tracker = GeohashParticipantTracker(activityCutoff: -0.05) // 50ms cutoff
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
tracker.setActiveGeohash("abc123")
|
||||
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
||||
|
||||
// Should be counted immediately
|
||||
#expect(tracker.participantCount(for: "abc123") == 1)
|
||||
|
||||
// Wait for expiry
|
||||
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
||||
|
||||
// Should be expired now
|
||||
#expect(tracker.participantCount(for: "abc123") == 0)
|
||||
}
|
||||
|
||||
// MARK: - Remove Participant Tests
|
||||
|
||||
@Test func removeParticipant_removesFromAllGeohashes() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1")
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo2")
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo1")
|
||||
|
||||
tracker.removeParticipant(pubkeyHex: "pubkey1")
|
||||
|
||||
#expect(tracker.participantCount(for: "geo1") == 1)
|
||||
#expect(tracker.participantCount(for: "geo2") == 0)
|
||||
}
|
||||
|
||||
// MARK: - Clear Tests
|
||||
|
||||
@Test func clear_removesAllData() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
tracker.setActiveGeohash("abc123")
|
||||
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "other")
|
||||
|
||||
tracker.clear()
|
||||
|
||||
#expect(tracker.participantCount(for: "abc123") == 0)
|
||||
#expect(tracker.participantCount(for: "other") == 0)
|
||||
#expect(tracker.visiblePeople.isEmpty)
|
||||
}
|
||||
|
||||
@Test func clearGeohash_removesOnlySpecificGeohash() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1")
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo2")
|
||||
|
||||
tracker.clear(geohash: "geo1")
|
||||
|
||||
#expect(tracker.participantCount(for: "geo1") == 0)
|
||||
#expect(tracker.participantCount(for: "geo2") == 1)
|
||||
}
|
||||
|
||||
// MARK: - Set Active Geohash Tests
|
||||
|
||||
@Test func setActiveGeohash_clearsVisiblePeopleWhenNil() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
tracker.setActiveGeohash("abc123")
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
||||
|
||||
#expect(!tracker.visiblePeople.isEmpty)
|
||||
|
||||
tracker.setActiveGeohash(nil)
|
||||
|
||||
#expect(tracker.visiblePeople.isEmpty)
|
||||
}
|
||||
|
||||
@Test func setActiveGeohash_refreshesVisiblePeople() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
|
||||
// Pre-populate a geohash
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "abc123")
|
||||
|
||||
// Set it as active
|
||||
tracker.setActiveGeohash("abc123")
|
||||
|
||||
#expect(tracker.visiblePeople.count == 1)
|
||||
}
|
||||
|
||||
// MARK: - GeoPerson Tests
|
||||
|
||||
@Test func geoPerson_identifiable() async {
|
||||
let person1 = GeoPerson(id: "abc", displayName: "alice", lastSeen: Date())
|
||||
let person2 = GeoPerson(id: "abc", displayName: "alice", lastSeen: Date())
|
||||
let person3 = GeoPerson(id: "xyz", displayName: "bob", lastSeen: Date())
|
||||
|
||||
#expect(person1.id == person2.id)
|
||||
#expect(person1.id != person3.id)
|
||||
}
|
||||
|
||||
@Test func geoPerson_equatable() async {
|
||||
let date = Date()
|
||||
let person1 = GeoPerson(id: "abc", displayName: "alice", lastSeen: date)
|
||||
let person2 = GeoPerson(id: "abc", displayName: "alice", lastSeen: date)
|
||||
|
||||
#expect(person1 == person2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
//
|
||||
// GeohashPresenceTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for the Geohash Presence (Kind 20001) feature.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import Combine
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - NostrProtocol Presence Event Tests
|
||||
|
||||
struct NostrProtocolPresenceTests {
|
||||
|
||||
@Test func createGeohashPresenceEvent_hasCorrectKind() throws {
|
||||
let identity = try makeTestIdentity()
|
||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
||||
geohash: "u4pruydq",
|
||||
senderIdentity: identity
|
||||
)
|
||||
|
||||
#expect(event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
||||
#expect(event.kind == 20001)
|
||||
}
|
||||
|
||||
@Test func createGeohashPresenceEvent_hasEmptyContent() throws {
|
||||
let identity = try makeTestIdentity()
|
||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
||||
geohash: "u4pruydq",
|
||||
senderIdentity: identity
|
||||
)
|
||||
|
||||
#expect(event.content == "")
|
||||
}
|
||||
|
||||
@Test func createGeohashPresenceEvent_hasOnlyGeohashTag() throws {
|
||||
let identity = try makeTestIdentity()
|
||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
||||
geohash: "u4pruydq",
|
||||
senderIdentity: identity
|
||||
)
|
||||
|
||||
// Should have exactly one tag: ["g", geohash]
|
||||
#expect(event.tags.count == 1)
|
||||
#expect(event.tags[0] == ["g", "u4pruydq"])
|
||||
}
|
||||
|
||||
@Test func createGeohashPresenceEvent_noNicknameTag() throws {
|
||||
let identity = try makeTestIdentity()
|
||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
||||
geohash: "u4pruydq",
|
||||
senderIdentity: identity
|
||||
)
|
||||
|
||||
// Should NOT contain nickname tag
|
||||
let hasNicknameTag = event.tags.contains { $0.first == "n" }
|
||||
#expect(!hasNicknameTag)
|
||||
}
|
||||
|
||||
@Test func createGeohashPresenceEvent_usesSenderPubkey() throws {
|
||||
let identity = try makeTestIdentity()
|
||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
||||
geohash: "u4pruydq",
|
||||
senderIdentity: identity
|
||||
)
|
||||
|
||||
#expect(event.pubkey == identity.publicKeyHex)
|
||||
}
|
||||
|
||||
@Test func createGeohashPresenceEvent_isSigned() throws {
|
||||
let identity = try makeTestIdentity()
|
||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
||||
geohash: "u4pruydq",
|
||||
senderIdentity: identity
|
||||
)
|
||||
|
||||
#expect(event.sig != nil && !event.sig!.isEmpty)
|
||||
#expect(!event.id.isEmpty)
|
||||
}
|
||||
|
||||
@Test func createGeohashPresenceEvent_differentGeohashes() throws {
|
||||
let identity = try makeTestIdentity()
|
||||
|
||||
let event1 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87", senderIdentity: identity)
|
||||
let event2 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87yw", senderIdentity: identity)
|
||||
let event3 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87yw7", senderIdentity: identity)
|
||||
|
||||
#expect(event1.tags[0][1] == "87")
|
||||
#expect(event2.tags[0][1] == "87yw")
|
||||
#expect(event3.tags[0][1] == "87yw7")
|
||||
}
|
||||
|
||||
// MARK: - Helper
|
||||
|
||||
private func makeTestIdentity() throws -> NostrIdentity {
|
||||
// Generate a fresh test identity
|
||||
return try NostrIdentity.generate()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NostrFilter Presence Tests
|
||||
|
||||
struct NostrFilterPresenceTests {
|
||||
|
||||
@Test func geohashEphemeral_includesBothKinds() {
|
||||
let filter = NostrFilter.geohashEphemeral("u4pruydq")
|
||||
|
||||
#expect(filter.kinds?.contains(20000) == true)
|
||||
#expect(filter.kinds?.contains(20001) == true)
|
||||
}
|
||||
|
||||
@Test func geohashEphemeral_hasLimit1000() {
|
||||
let filter = NostrFilter.geohashEphemeral("u4pruydq")
|
||||
|
||||
#expect(filter.limit == 1000)
|
||||
}
|
||||
|
||||
@Test func geohashEphemeral_respectsSinceParameter() {
|
||||
let since = Date(timeIntervalSince1970: 1700000000)
|
||||
let filter = NostrFilter.geohashEphemeral("u4pruydq", since: since)
|
||||
|
||||
#expect(filter.since == 1700000000)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ChatViewModel Presence Handling Tests
|
||||
|
||||
@MainActor
|
||||
struct ChatViewModelPresenceHandlingTests {
|
||||
|
||||
@Test func handleNostrEvent_presenceUpdatesParticipantTracker() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
// Set up the channel
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
// Create a presence event (kind 20001)
|
||||
var event = NostrEvent(
|
||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", geohash]],
|
||||
content: ""
|
||||
)
|
||||
event.id = "presence_evt_1"
|
||||
event.sig = "sig"
|
||||
|
||||
// Handle the event
|
||||
viewModel.handleNostrEvent(event)
|
||||
|
||||
// Allow async processing
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
// Participant should be recorded
|
||||
let count = viewModel.geohashParticipantCount(for: geohash)
|
||||
#expect(count >= 1)
|
||||
}
|
||||
|
||||
@Test func handleNostrEvent_presenceDoesNotAddToTimeline() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
let initialMessageCount = viewModel.messages.count
|
||||
|
||||
// Create a presence event (kind 20001)
|
||||
var event = NostrEvent(
|
||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", geohash]],
|
||||
content: ""
|
||||
)
|
||||
event.id = "presence_evt_2"
|
||||
event.sig = "sig"
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
// Message count should NOT increase
|
||||
#expect(viewModel.messages.count == initialMessageCount)
|
||||
}
|
||||
|
||||
@Test func handleNostrEvent_chatMessageUpdatesParticipant() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
// Create a chat event (kind 20000) - NOT presence
|
||||
var event = NostrEvent(
|
||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Hello world"
|
||||
)
|
||||
event.id = "chat_evt_1"
|
||||
event.sig = "sig"
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
// Chat messages should also update participant count (not just presence)
|
||||
let count = viewModel.geohashParticipantCount(for: geohash)
|
||||
#expect(count >= 1)
|
||||
}
|
||||
|
||||
@Test func presenceEvent_hasDifferentKindThanChat() {
|
||||
// Verify the two event kinds are distinct
|
||||
let presenceKind = NostrProtocol.EventKind.geohashPresence.rawValue
|
||||
let chatKind = NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||
|
||||
#expect(presenceKind != chatKind)
|
||||
#expect(presenceKind == 20001)
|
||||
#expect(chatKind == 20000)
|
||||
}
|
||||
|
||||
@Test func subscribeNostrEvent_acceptsPresenceKind() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
// Create presence event
|
||||
var event = NostrEvent(
|
||||
pubkey: "test1234test1234test1234test1234test1234test1234test1234test1234",
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", geohash]],
|
||||
content: ""
|
||||
)
|
||||
event.id = "subscribe_presence_evt"
|
||||
event.sig = "sig"
|
||||
|
||||
// subscribeNostrEvent should accept kind 20001
|
||||
viewModel.subscribeNostrEvent(event)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
// Should record participant
|
||||
let count = viewModel.geohashParticipantCount(for: geohash)
|
||||
#expect(count >= 1)
|
||||
}
|
||||
|
||||
@Test func subscribeNostrEvent_presenceForNonActiveGeohash() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let activeGeohash = "u4pruydq"
|
||||
let otherGeohash = "87yw7"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash)))
|
||||
|
||||
// Create presence event for a DIFFERENT geohash
|
||||
var event = NostrEvent(
|
||||
pubkey: "other1234other1234other1234other1234other1234other1234other1234",
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", otherGeohash]],
|
||||
content: ""
|
||||
)
|
||||
event.id = "other_geohash_presence"
|
||||
event.sig = "sig"
|
||||
|
||||
// Use subscribeNostrEvent with geohash parameter
|
||||
viewModel.subscribeNostrEvent(event, gh: otherGeohash)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
// Should record for the other geohash
|
||||
let count = viewModel.geohashParticipantCount(for: otherGeohash)
|
||||
#expect(count >= 1)
|
||||
}
|
||||
|
||||
// MARK: - Test Helper
|
||||
|
||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
let keychain = MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let transport = MockTransport()
|
||||
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport
|
||||
)
|
||||
|
||||
return (viewModel, transport)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Presence Privacy Tests
|
||||
|
||||
struct GeohashPresencePrivacyTests {
|
||||
|
||||
@Test func allowedPrecisions_onlyLowPrecision() {
|
||||
// The allowed precisions for presence broadcasting should be:
|
||||
// Region (2), Province (4), City (5)
|
||||
// NOT Neighborhood (6), Block (7), Building (8+)
|
||||
|
||||
let regionPrecision = GeohashChannelLevel.region.precision
|
||||
let provincePrecision = GeohashChannelLevel.province.precision
|
||||
let cityPrecision = GeohashChannelLevel.city.precision
|
||||
let neighborhoodPrecision = GeohashChannelLevel.neighborhood.precision
|
||||
let blockPrecision = GeohashChannelLevel.block.precision
|
||||
let buildingPrecision = GeohashChannelLevel.building.precision
|
||||
|
||||
#expect(regionPrecision == 2)
|
||||
#expect(provincePrecision == 4)
|
||||
#expect(cityPrecision == 5)
|
||||
#expect(neighborhoodPrecision == 6)
|
||||
#expect(blockPrecision == 7)
|
||||
#expect(buildingPrecision == 8)
|
||||
|
||||
// High precision channels should NOT receive presence broadcasts
|
||||
#expect(neighborhoodPrecision > 5)
|
||||
#expect(blockPrecision > 5)
|
||||
#expect(buildingPrecision > 5)
|
||||
}
|
||||
|
||||
@Test func geohashLengthDeterminesPrecision() {
|
||||
// Verify geohash length maps to expected precision
|
||||
#expect("87".count == GeohashChannelLevel.region.precision)
|
||||
#expect("87yw".count == GeohashChannelLevel.province.precision)
|
||||
#expect("87yw7".count == GeohashChannelLevel.city.precision)
|
||||
#expect("87yw7t".count == GeohashChannelLevel.neighborhood.precision)
|
||||
#expect("87yw7tc".count == GeohashChannelLevel.block.precision)
|
||||
#expect("87yw7tcx".count == GeohashChannelLevel.building.precision)
|
||||
}
|
||||
|
||||
@Test func highPrecisionGeohash_isPrivacySensitive() {
|
||||
// Helper to check if a geohash is "high precision" (privacy sensitive)
|
||||
func isHighPrecision(_ geohash: String) -> Bool {
|
||||
geohash.count >= 6
|
||||
}
|
||||
|
||||
// Low precision - OK to broadcast presence
|
||||
#expect(!isHighPrecision("87")) // region
|
||||
#expect(!isHighPrecision("87yw")) // province
|
||||
#expect(!isHighPrecision("87yw7")) // city
|
||||
|
||||
// High precision - should NOT broadcast presence
|
||||
#expect(isHighPrecision("87yw7t")) // neighborhood
|
||||
#expect(isHighPrecision("87yw7tc")) // block
|
||||
#expect(isHighPrecision("87yw7tcx")) // building
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Display Logic Tests
|
||||
|
||||
struct LocationChannelsDisplayLogicTests {
|
||||
|
||||
@Test func displayLogic_highPrecisionZeroCount_showsUnknown() {
|
||||
// Test the logic that determines "?" vs actual count
|
||||
// High precision + count 0 = "?"
|
||||
|
||||
let shouldShowUnknown = shouldShowUnknownCount(
|
||||
level: .neighborhood,
|
||||
count: 0
|
||||
)
|
||||
#expect(shouldShowUnknown)
|
||||
}
|
||||
|
||||
@Test func displayLogic_highPrecisionNonZeroCount_showsActual() {
|
||||
// High precision + count > 0 = show actual
|
||||
let shouldShowUnknown = shouldShowUnknownCount(
|
||||
level: .neighborhood,
|
||||
count: 5
|
||||
)
|
||||
#expect(!shouldShowUnknown)
|
||||
}
|
||||
|
||||
@Test func displayLogic_lowPrecisionZeroCount_showsActual() {
|
||||
// Low precision + count 0 = show "0" (not "?")
|
||||
let shouldShowUnknown = shouldShowUnknownCount(
|
||||
level: .city,
|
||||
count: 0
|
||||
)
|
||||
#expect(!shouldShowUnknown)
|
||||
}
|
||||
|
||||
@Test func displayLogic_lowPrecisionNonZeroCount_showsActual() {
|
||||
// Low precision + count > 0 = show actual
|
||||
let shouldShowUnknown = shouldShowUnknownCount(
|
||||
level: .region,
|
||||
count: 10
|
||||
)
|
||||
#expect(!shouldShowUnknown)
|
||||
}
|
||||
|
||||
@Test func displayLogic_allHighPrecisionLevels() {
|
||||
// All high precision levels with 0 should show "?"
|
||||
let highPrecisionLevels: [GeohashChannelLevel] = [.neighborhood, .block, .building]
|
||||
|
||||
for level in highPrecisionLevels {
|
||||
let shouldShowUnknown = shouldShowUnknownCount(level: level, count: 0)
|
||||
#expect(shouldShowUnknown, "Level \(level) with count 0 should show unknown")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func displayLogic_allLowPrecisionLevels() {
|
||||
// All low precision levels with 0 should show actual count
|
||||
let lowPrecisionLevels: [GeohashChannelLevel] = [.region, .province, .city]
|
||||
|
||||
for level in lowPrecisionLevels {
|
||||
let shouldShowUnknown = shouldShowUnknownCount(level: level, count: 0)
|
||||
#expect(!shouldShowUnknown, "Level \(level) with count 0 should show actual count")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func displayLogic_bookmarkHighPrecision() {
|
||||
// Bookmarks use geohash length to determine precision
|
||||
#expect(shouldShowUnknownForBookmark(geohash: "87yw7t", count: 0)) // len 6
|
||||
#expect(shouldShowUnknownForBookmark(geohash: "87yw7tc", count: 0)) // len 7
|
||||
#expect(shouldShowUnknownForBookmark(geohash: "87yw7tcx", count: 0)) // len 8
|
||||
}
|
||||
|
||||
@Test func displayLogic_bookmarkLowPrecision() {
|
||||
#expect(!shouldShowUnknownForBookmark(geohash: "87", count: 0)) // len 2
|
||||
#expect(!shouldShowUnknownForBookmark(geohash: "87yw", count: 0)) // len 4
|
||||
#expect(!shouldShowUnknownForBookmark(geohash: "87yw7", count: 0)) // len 5
|
||||
}
|
||||
|
||||
// MARK: - Helpers (mirror the logic from LocationChannelsSheet)
|
||||
|
||||
private func shouldShowUnknownCount(level: GeohashChannelLevel, count: Int) -> Bool {
|
||||
let isHighPrecision = (level == .neighborhood || level == .block || level == .building)
|
||||
return isHighPrecision && count == 0
|
||||
}
|
||||
|
||||
private func shouldShowUnknownForBookmark(geohash: String, count: Int) -> Bool {
|
||||
let isHighPrecision = (geohash.count >= 6)
|
||||
return isHighPrecision && count == 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event Kind Tests
|
||||
|
||||
struct NostrEventKindTests {
|
||||
|
||||
@Test func eventKind_geohashPresence_is20001() {
|
||||
#expect(NostrProtocol.EventKind.geohashPresence.rawValue == 20001)
|
||||
}
|
||||
|
||||
@Test func eventKind_ephemeralEvent_is20000() {
|
||||
#expect(NostrProtocol.EventKind.ephemeralEvent.rawValue == 20000)
|
||||
}
|
||||
|
||||
@Test func eventKind_presenceIsEphemeral() {
|
||||
// Both 20000 and 20001 are in the ephemeral range (20000-29999)
|
||||
let presenceKind = NostrProtocol.EventKind.geohashPresence.rawValue
|
||||
let chatKind = NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||
|
||||
#expect(presenceKind >= 20000 && presenceKind < 30000)
|
||||
#expect(chatKind >= 20000 && chatKind < 30000)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Participant Tracker Presence Integration Tests
|
||||
|
||||
@MainActor
|
||||
struct ParticipantTrackerPresenceTests {
|
||||
|
||||
@Test func recordParticipant_fromPresenceEvent_countsParticipant() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = PresenceTestParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
|
||||
let geohash = "87yw7"
|
||||
tracker.setActiveGeohash(geohash)
|
||||
|
||||
// Simulate recording from a presence event
|
||||
tracker.recordParticipant(pubkeyHex: "presence_user_1")
|
||||
|
||||
#expect(tracker.participantCount(for: geohash) == 1)
|
||||
}
|
||||
|
||||
@Test func recordParticipant_multiplePresenceEvents_countsUnique() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = PresenceTestParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
|
||||
let geohash = "87yw7"
|
||||
tracker.setActiveGeohash(geohash)
|
||||
|
||||
// Multiple presence events from same user = 1 participant
|
||||
tracker.recordParticipant(pubkeyHex: "user_a")
|
||||
tracker.recordParticipant(pubkeyHex: "user_a")
|
||||
tracker.recordParticipant(pubkeyHex: "user_a")
|
||||
|
||||
#expect(tracker.participantCount(for: geohash) == 1)
|
||||
|
||||
// Different user = 2 participants
|
||||
tracker.recordParticipant(pubkeyHex: "user_b")
|
||||
|
||||
#expect(tracker.participantCount(for: geohash) == 2)
|
||||
}
|
||||
|
||||
@Test func recordParticipant_nonActiveGeohash_stillCounts() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = PresenceTestParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
|
||||
// Active geohash is different from where we're recording
|
||||
tracker.setActiveGeohash("active_gh")
|
||||
|
||||
// Record to a non-active geohash (like when sampling nearby channels)
|
||||
tracker.recordParticipant(pubkeyHex: "nearby_user", geohash: "other_gh")
|
||||
|
||||
#expect(tracker.participantCount(for: "other_gh") == 1)
|
||||
#expect(tracker.participantCount(for: "active_gh") == 0)
|
||||
}
|
||||
|
||||
@Test func objectWillChange_firesOnNonActiveGeohashUpdate() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = PresenceTestParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
|
||||
tracker.setActiveGeohash("active_gh")
|
||||
|
||||
var changeCount = 0
|
||||
let cancellable = tracker.objectWillChange.sink { _ in
|
||||
changeCount += 1
|
||||
}
|
||||
|
||||
// Record to non-active geohash
|
||||
tracker.recordParticipant(pubkeyHex: "user1", geohash: "other_gh")
|
||||
|
||||
// Should fire objectWillChange even for non-active geohash
|
||||
#expect(changeCount >= 1)
|
||||
|
||||
_ = cancellable // Keep alive
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mock for Participant Context (Presence Tests)
|
||||
|
||||
@MainActor
|
||||
private final class PresenceTestParticipantContext: GeohashParticipantContext {
|
||||
var blockedPubkeys: Set<String> = []
|
||||
var nicknameMap: [String: String] = [:]
|
||||
var selfPubkey: String?
|
||||
|
||||
func displayNameForPubkey(_ pubkeyHex: String) -> String {
|
||||
let suffix = String(pubkeyHex.suffix(4))
|
||||
if let s = selfPubkey, pubkeyHex.lowercased() == s.lowercased() {
|
||||
return "me#\(suffix)"
|
||||
}
|
||||
if let nick = nicknameMap[pubkeyHex.lowercased()] {
|
||||
return "\(nick)#\(suffix)"
|
||||
}
|
||||
return "anon#\(suffix)"
|
||||
}
|
||||
|
||||
func isBlocked(_ pubkeyHexLowercased: String) -> Bool {
|
||||
blockedPubkeys.contains(pubkeyHexLowercased.lowercased())
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,14 @@ struct GossipSyncManagerTests {
|
||||
private let myPeerID = PeerID(str: "0102030405060708")
|
||||
|
||||
@Test func concurrentPacketIntakeAndSyncRequest() async throws {
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID)
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: requestSyncManager)
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
try await confirmation("sync request sent") { sent in
|
||||
delegate.onSend = {
|
||||
delegate.onSend = nil
|
||||
sent()
|
||||
}
|
||||
|
||||
@@ -34,7 +36,7 @@ struct GossipSyncManagerTests {
|
||||
}
|
||||
|
||||
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
|
||||
try await sleep(0.002)
|
||||
try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.shortTimeout)
|
||||
}
|
||||
|
||||
let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
|
||||
@@ -47,7 +49,8 @@ struct GossipSyncManagerTests {
|
||||
config.stalePeerCleanupIntervalSeconds = 0
|
||||
config.stalePeerTimeoutSeconds = 5
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
let peerHex = "0011223344556677"
|
||||
let senderData = try #require(Data(hexString: peerHex))
|
||||
let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
@@ -92,7 +95,8 @@ struct GossipSyncManagerTests {
|
||||
config.stalePeerTimeoutSeconds = 5
|
||||
config.maxMessageAgeSeconds = 100
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
let peerHex = "8899aabbccddeeff"
|
||||
let senderData = try #require(Data(hexString: peerHex))
|
||||
let staleTimestampMs = UInt64(Date().addingTimeInterval(-(config.stalePeerTimeoutSeconds + 1)).timeIntervalSince1970 * 1000)
|
||||
@@ -136,7 +140,8 @@ struct GossipSyncManagerTests {
|
||||
config.fileTransferSyncIntervalSeconds = 1
|
||||
config.maintenanceIntervalSeconds = 0
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
@@ -206,7 +211,8 @@ struct GossipSyncManagerTests {
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
@@ -240,7 +246,7 @@ struct GossipSyncManagerTests {
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
|
||||
manager.handleRequestSync(from: peer, request: request)
|
||||
|
||||
try await sleep(0.01)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
let sentPackets = delegate.packets
|
||||
#expect(sentPackets.count == 1)
|
||||
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
||||
@@ -268,4 +274,8 @@ private final class RecordingDelegate: GossipSyncManager.Delegate {
|
||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
||||
packet
|
||||
}
|
||||
|
||||
func getConnectedPeers() -> [PeerID] {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ struct InputValidatorTests {
|
||||
}
|
||||
|
||||
// MARK: - Timestamp Validation Tests
|
||||
// BCH-01-011: Window reduced from ±1 hour to ±5 minutes
|
||||
|
||||
@Test func currentTimestampIsValid() throws {
|
||||
let now = Date()
|
||||
@@ -138,31 +139,48 @@ struct InputValidatorTests {
|
||||
#expect(result == true)
|
||||
}
|
||||
|
||||
@Test func timestampWithinOneHourIsValid() throws {
|
||||
@Test func timestampWithinFiveMinutesIsValid() throws {
|
||||
// 2 minutes ago should be valid (within 5-minute window)
|
||||
let twoMinutesAgo = Date().addingTimeInterval(-2 * 60)
|
||||
let result = InputValidator.validateTimestamp(twoMinutesAgo)
|
||||
#expect(result == true)
|
||||
}
|
||||
|
||||
@Test func timestampThirtyMinutesAgoIsInvalid() throws {
|
||||
// BCH-01-011: 30 minutes is now outside the 5-minute window
|
||||
let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60)
|
||||
let result = InputValidator.validateTimestamp(thirtyMinutesAgo)
|
||||
#expect(result == true)
|
||||
}
|
||||
|
||||
@Test func timestampTwoHoursAgoIsInvalid() throws {
|
||||
let twoHoursAgo = Date().addingTimeInterval(-2 * 3600)
|
||||
let result = InputValidator.validateTimestamp(twoHoursAgo)
|
||||
#expect(result == false)
|
||||
}
|
||||
|
||||
@Test func timestampTwoHoursInFutureIsInvalid() throws {
|
||||
let twoHoursFromNow = Date().addingTimeInterval(2 * 3600)
|
||||
let result = InputValidator.validateTimestamp(twoHoursFromNow)
|
||||
@Test func timestampTenMinutesAgoIsInvalid() throws {
|
||||
// 10 minutes is outside the 5-minute window
|
||||
let tenMinutesAgo = Date().addingTimeInterval(-10 * 60)
|
||||
let result = InputValidator.validateTimestamp(tenMinutesAgo)
|
||||
#expect(result == false)
|
||||
}
|
||||
|
||||
@Test func timestampAtOneHourBoundaryIsValid() throws {
|
||||
// Just slightly within the one-hour window
|
||||
let almostOneHourAgo = Date().addingTimeInterval(-3599)
|
||||
let result = InputValidator.validateTimestamp(almostOneHourAgo)
|
||||
@Test func timestampTenMinutesInFutureIsInvalid() throws {
|
||||
// 10 minutes in future is outside the 5-minute window
|
||||
let tenMinutesFromNow = Date().addingTimeInterval(10 * 60)
|
||||
let result = InputValidator.validateTimestamp(tenMinutesFromNow)
|
||||
#expect(result == false)
|
||||
}
|
||||
|
||||
@Test func timestampAtFiveMinuteBoundaryIsValid() throws {
|
||||
// Just slightly within the five-minute window (299 seconds)
|
||||
let almostFiveMinutesAgo = Date().addingTimeInterval(-299)
|
||||
let result = InputValidator.validateTimestamp(almostFiveMinutesAgo)
|
||||
#expect(result == true)
|
||||
}
|
||||
|
||||
@Test func timestampJustOutsideFiveMinuteWindowIsInvalid() throws {
|
||||
// Just outside the five-minute window (301 seconds)
|
||||
let justOverFiveMinutesAgo = Date().addingTimeInterval(-301)
|
||||
let result = InputValidator.validateTimestamp(justOverFiveMinutesAgo)
|
||||
#expect(result == false)
|
||||
}
|
||||
|
||||
// MARK: - Edge Cases
|
||||
|
||||
@Test func singleCharacterStringIsAccepted() throws {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//
|
||||
// KeychainErrorHandlingTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
// BCH-01-009: Tests for proper keychain error classification and handling
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct KeychainErrorHandlingTests {
|
||||
|
||||
// MARK: - Error Classification Tests
|
||||
|
||||
@Test func keychainReadResult_successIsNotRecoverable() throws {
|
||||
let result = KeychainReadResult.success(Data([1, 2, 3]))
|
||||
#expect(result.isRecoverableError == false)
|
||||
}
|
||||
|
||||
@Test func keychainReadResult_itemNotFoundIsNotRecoverable() throws {
|
||||
let result = KeychainReadResult.itemNotFound
|
||||
#expect(result.isRecoverableError == false)
|
||||
}
|
||||
|
||||
@Test func keychainReadResult_deviceLockedIsRecoverable() throws {
|
||||
let result = KeychainReadResult.deviceLocked
|
||||
#expect(result.isRecoverableError == true)
|
||||
}
|
||||
|
||||
@Test func keychainReadResult_authenticationFailedIsRecoverable() throws {
|
||||
let result = KeychainReadResult.authenticationFailed
|
||||
#expect(result.isRecoverableError == true)
|
||||
}
|
||||
|
||||
@Test func keychainReadResult_accessDeniedIsNotRecoverable() throws {
|
||||
let result = KeychainReadResult.accessDenied
|
||||
#expect(result.isRecoverableError == false)
|
||||
}
|
||||
|
||||
@Test func keychainSaveResult_successIsNotRecoverable() throws {
|
||||
let result = KeychainSaveResult.success
|
||||
#expect(result.isRecoverableError == false)
|
||||
}
|
||||
|
||||
@Test func keychainSaveResult_duplicateItemIsRecoverable() throws {
|
||||
let result = KeychainSaveResult.duplicateItem
|
||||
#expect(result.isRecoverableError == true)
|
||||
}
|
||||
|
||||
@Test func keychainSaveResult_deviceLockedIsRecoverable() throws {
|
||||
let result = KeychainSaveResult.deviceLocked
|
||||
#expect(result.isRecoverableError == true)
|
||||
}
|
||||
|
||||
@Test func keychainSaveResult_storageFullIsNotRecoverable() throws {
|
||||
let result = KeychainSaveResult.storageFull
|
||||
#expect(result.isRecoverableError == false)
|
||||
}
|
||||
|
||||
// MARK: - Mock Keychain Error Simulation Tests
|
||||
|
||||
@Test func mockKeychain_canSimulateReadErrors() throws {
|
||||
let keychain = MockKeychain()
|
||||
|
||||
// Simulate access denied error
|
||||
keychain.simulatedReadError = .accessDenied
|
||||
let result = keychain.getIdentityKeyWithResult(forKey: "testKey")
|
||||
|
||||
switch result {
|
||||
case .accessDenied:
|
||||
// Expected
|
||||
break
|
||||
default:
|
||||
throw KeychainTestError("Expected accessDenied, got \(result)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func mockKeychain_canSimulateSaveErrors() throws {
|
||||
let keychain = MockKeychain()
|
||||
|
||||
// Simulate storage full error
|
||||
keychain.simulatedSaveError = .storageFull
|
||||
let result = keychain.saveIdentityKeyWithResult(Data([1, 2, 3]), forKey: "testKey")
|
||||
|
||||
switch result {
|
||||
case .storageFull:
|
||||
// Expected
|
||||
break
|
||||
default:
|
||||
throw KeychainTestError("Expected storageFull, got \(result)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func mockKeychain_returnsItemNotFoundForMissingKey() throws {
|
||||
let keychain = MockKeychain()
|
||||
let result = keychain.getIdentityKeyWithResult(forKey: "nonExistentKey")
|
||||
|
||||
switch result {
|
||||
case .itemNotFound:
|
||||
// Expected
|
||||
break
|
||||
default:
|
||||
throw KeychainTestError("Expected itemNotFound, got \(result)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func mockKeychain_returnsSuccessForExistingKey() throws {
|
||||
let keychain = MockKeychain()
|
||||
let testData = Data([1, 2, 3, 4, 5])
|
||||
|
||||
// First save the key
|
||||
_ = keychain.saveIdentityKey(testData, forKey: "existingKey")
|
||||
|
||||
// Now read it back
|
||||
let result = keychain.getIdentityKeyWithResult(forKey: "existingKey")
|
||||
|
||||
switch result {
|
||||
case .success(let data):
|
||||
#expect(data == testData)
|
||||
default:
|
||||
throw KeychainTestError("Expected success, got \(result)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func mockKeychain_saveWithResultStoresData() throws {
|
||||
let keychain = MockKeychain()
|
||||
let testData = Data([10, 20, 30])
|
||||
|
||||
let saveResult = keychain.saveIdentityKeyWithResult(testData, forKey: "newKey")
|
||||
|
||||
switch saveResult {
|
||||
case .success:
|
||||
// Verify data was stored
|
||||
let readResult = keychain.getIdentityKeyWithResult(forKey: "newKey")
|
||||
switch readResult {
|
||||
case .success(let data):
|
||||
#expect(data == testData)
|
||||
default:
|
||||
throw KeychainTestError("Expected to read back saved data")
|
||||
}
|
||||
default:
|
||||
throw KeychainTestError("Expected save success, got \(saveResult)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NoiseEncryptionService Integration Tests
|
||||
|
||||
@Test func noiseEncryptionService_generatesNewIdentityWhenMissing() throws {
|
||||
let keychain = MockKeychain()
|
||||
|
||||
// Create service with empty keychain - should generate new identity
|
||||
let service = NoiseEncryptionService(keychain: keychain)
|
||||
|
||||
// Should have generated and saved keys
|
||||
#expect(service.getStaticPublicKeyData().count == 32)
|
||||
#expect(service.getSigningPublicKeyData().count == 32)
|
||||
|
||||
// Keys should be persisted
|
||||
let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey")
|
||||
switch noiseKeyResult {
|
||||
case .success:
|
||||
// Expected - key was saved
|
||||
break
|
||||
default:
|
||||
throw KeychainTestError("Expected noise key to be saved")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func noiseEncryptionService_loadsExistingIdentity() throws {
|
||||
let keychain = MockKeychain()
|
||||
|
||||
// Create first service to generate identity
|
||||
let service1 = NoiseEncryptionService(keychain: keychain)
|
||||
let originalPublicKey = service1.getStaticPublicKeyData()
|
||||
let originalSigningKey = service1.getSigningPublicKeyData()
|
||||
|
||||
// Create second service - should load same identity
|
||||
let service2 = NoiseEncryptionService(keychain: keychain)
|
||||
|
||||
#expect(service2.getStaticPublicKeyData() == originalPublicKey)
|
||||
#expect(service2.getSigningPublicKeyData() == originalSigningKey)
|
||||
}
|
||||
|
||||
@Test func noiseEncryptionService_handlesAccessDeniedGracefully() throws {
|
||||
let keychain = MockKeychain()
|
||||
keychain.simulatedReadError = .accessDenied
|
||||
|
||||
// Service should still initialize with ephemeral key
|
||||
let service = NoiseEncryptionService(keychain: keychain)
|
||||
|
||||
// Should have an identity (ephemeral)
|
||||
#expect(service.getStaticPublicKeyData().count == 32)
|
||||
#expect(service.getSigningPublicKeyData().count == 32)
|
||||
}
|
||||
|
||||
@Test func noiseEncryptionService_handlesDeviceLockedGracefully() throws {
|
||||
let keychain = MockKeychain()
|
||||
keychain.simulatedReadError = .deviceLocked
|
||||
|
||||
// Service should still initialize with ephemeral key
|
||||
let service = NoiseEncryptionService(keychain: keychain)
|
||||
|
||||
// Should have an identity (ephemeral)
|
||||
#expect(service.getStaticPublicKeyData().count == 32)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper error type for tests
|
||||
private struct KeychainTestError: Error, CustomStringConvertible {
|
||||
let message: String
|
||||
init(_ message: String) { self.message = message }
|
||||
var description: String { message }
|
||||
}
|
||||
@@ -1,530 +0,0 @@
|
||||
//import Foundation
|
||||
//import XCTest
|
||||
//@testable import bitchat
|
||||
//
|
||||
//private let localizationTestsDirectoryURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||
//private let testsRootURL = localizationTestsDirectoryURL.deletingLastPathComponent()
|
||||
//private let repoRootURL = testsRootURL.deletingLastPathComponent()
|
||||
//
|
||||
//final class LocalizationCatalogTests: XCTestCase {
|
||||
// // Ensures every app locale includes exactly the same keys as Base.
|
||||
// func testAppCatalogLocaleParity() throws {
|
||||
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// assertLocaleParity(context: context, catalogName: "App")
|
||||
// }
|
||||
//
|
||||
// // Verifies format placeholders stay consistent across app locales.
|
||||
// func testAppCatalogPlaceholderConsistency() throws {
|
||||
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// assertPlaceholderConsistency(context: context, catalogName: "App")
|
||||
// }
|
||||
//
|
||||
// // Guards a core set of app strings from going empty per locale.
|
||||
// func testAppPrimaryKeysNonEmpty() throws {
|
||||
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// let primaryKeys = try loadPrimaryKeys().app
|
||||
// assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "App")
|
||||
// }
|
||||
//
|
||||
// // Ensures every share extension locale matches Base key coverage.
|
||||
// func testShareExtensionCatalogLocaleParity() throws {
|
||||
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// assertLocaleParity(context: context, catalogName: "ShareExtension")
|
||||
// }
|
||||
//
|
||||
// // Verifies share extension placeholders align across locales.
|
||||
// func testShareExtensionCatalogPlaceholderConsistency() throws {
|
||||
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// assertPlaceholderConsistency(context: context, catalogName: "ShareExtension")
|
||||
// }
|
||||
//
|
||||
// // Confirms critical share extension strings remain non-empty per locale.
|
||||
// func testShareExtensionPrimaryKeysNonEmpty() throws {
|
||||
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// let primaryKeys = try loadPrimaryKeys().shareExtension
|
||||
// assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "ShareExtension")
|
||||
// }
|
||||
//
|
||||
// // Validates that configured locales contain expected string values.
|
||||
// func testLocalizationExpectedValues() throws {
|
||||
// let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// let config = try loadPrimaryKeys()
|
||||
//
|
||||
// guard let testLocales = config.testLocales else {
|
||||
// // If no testLocales specified, skip this test
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// guard let expectedValues = config.expectedValues else {
|
||||
// XCTFail("No expectedValues configured in PrimaryLocalizationKeys.json")
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// // Loop through each locale to test
|
||||
// for locale in testLocales {
|
||||
// guard let localeExpectedValues = expectedValues[locale] else {
|
||||
// XCTFail("No expected values configured for locale '\(locale)' in PrimaryLocalizationKeys.json")
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// // Test each expected key/value pair for this locale
|
||||
// for (key, expectedValue) in localeExpectedValues {
|
||||
// if config.app.contains(key) {
|
||||
// assertLocaleStringValue(context: appContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "App")
|
||||
// } else if config.shareExtension.contains(key) {
|
||||
// assertLocaleStringValue(context: shareContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "ShareExtension")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Ensures configured test locales are present and complete.
|
||||
// func testConfiguredLocalesCompleteness() throws {
|
||||
// let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||
// let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
// let config = try loadPrimaryKeys()
|
||||
//
|
||||
// guard let testLocales = config.testLocales else {
|
||||
// // If no testLocales specified, skip this test
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// let baseLocale = appContext.baseLocale
|
||||
// let baseAppKeys = appContext.keysByLocale[baseLocale] ?? Set()
|
||||
// let baseShareKeys = shareContext.keysByLocale[baseLocale] ?? Set()
|
||||
//
|
||||
// for locale in testLocales {
|
||||
// // Skip base locale comparison with itself
|
||||
// if locale == baseLocale { continue }
|
||||
//
|
||||
// // Verify locale is present in both catalogs
|
||||
// XCTAssertTrue(appContext.locales.contains(locale), "Locale '\(locale)' missing from app catalog")
|
||||
// XCTAssertTrue(shareContext.locales.contains(locale), "Locale '\(locale)' missing from share extension catalog")
|
||||
//
|
||||
// // Verify locale has same number of keys as base locale
|
||||
// let appLocaleKeys = appContext.keysByLocale[locale] ?? Set()
|
||||
// XCTAssertEqual(appLocaleKeys.count, baseAppKeys.count, "Locale '\(locale)' app catalog missing keys compared to \(baseLocale)")
|
||||
//
|
||||
// let shareLocaleKeys = shareContext.keysByLocale[locale] ?? Set()
|
||||
// XCTAssertEqual(shareLocaleKeys.count, baseShareKeys.count, "Locale '\(locale)' share extension catalog missing keys compared to \(baseLocale)")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // MARK: - Assertions
|
||||
//
|
||||
// private func assertLocaleParity(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||
// let baseLocale = context.baseLocale
|
||||
// guard let baseKeys = context.keysByLocale[baseLocale] else {
|
||||
// return XCTFail("Missing base locale \(baseLocale) in \(catalogName) catalog", file: file, line: line)
|
||||
// }
|
||||
//
|
||||
// for (locale, keys) in context.keysByLocale.sorted(by: { $0.key < $1.key }) {
|
||||
// XCTAssertEqual(keys, baseKeys, "Locale \(locale) has key mismatch in \(catalogName) catalog", file: file, line: line)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func assertPlaceholderConsistency(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||
// let baseLocale = context.baseLocale
|
||||
// guard let baseSignatures = context.placeholderSignature[baseLocale] else {
|
||||
// return XCTFail("Missing base placeholder signature for \(catalogName)", file: file, line: line)
|
||||
// }
|
||||
//
|
||||
// for (locale, localeSignatures) in context.placeholderSignature.sorted(by: { $0.key < $1.key }) {
|
||||
// guard locale != baseLocale else { continue }
|
||||
// for key in baseSignatures.keys.sorted() {
|
||||
// guard let baseMap = baseSignatures[key] else {
|
||||
// continue
|
||||
// }
|
||||
// guard let localeMap = localeSignatures[key] else {
|
||||
// return XCTFail("Key \(key) missing for locale \(locale) in \(catalogName) catalog", file: file, line: line)
|
||||
// }
|
||||
// for path in baseMap.keys.sorted() {
|
||||
// let expected = normalizedPlaceholders(baseMap[path, default: []])
|
||||
// let actual = normalizedPlaceholders(localeMap[path, default: []])
|
||||
// XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(path) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// }
|
||||
// for (localePath, localeTokens) in localeMap {
|
||||
// guard baseMap[localePath] == nil else { continue }
|
||||
// guard let fallback = fallbackPath(for: localePath, baseMap: baseMap) else {
|
||||
// XCTFail("Unexpected variation \(localePath) for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// continue
|
||||
// }
|
||||
// let expected = normalizedPlaceholders(baseMap[fallback, default: []])
|
||||
// let actual = normalizedPlaceholders(localeTokens)
|
||||
// XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(localePath) (fallback \(fallback)) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func assertPrimaryKeysPresent(context: CatalogContext, keys: [String], catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||
// for key in keys {
|
||||
// guard let entry = context.catalog.strings[key] else {
|
||||
// XCTFail("Missing primary key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||
// continue
|
||||
// }
|
||||
// for locale in context.locales.sorted() {
|
||||
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||
// XCTFail("Missing localization for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// continue
|
||||
// }
|
||||
// let segments = gatherSegments(from: unit)
|
||||
// XCTAssertFalse(segments.isEmpty, "No content for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// for segment in segments {
|
||||
// let trimmed = segment.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// XCTAssertFalse(trimmed.isEmpty, "Empty translation for key \(key) at \(segment.path) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func assertLocaleStringValue(context: CatalogContext, locale: String, key: String, expectedValue: String, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||
// guard let entry = context.catalog.strings[key] else {
|
||||
// XCTFail("Missing key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||
// XCTFail("Missing \(locale) localization for key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// // For simple strings (non-pluralized)
|
||||
// if let actualValue = unit.value {
|
||||
// XCTAssertEqual(actualValue, expectedValue, "\(locale) translation mismatch for key \(key) in \(catalogName) catalog. Expected: '\(expectedValue)', Actual: '\(actualValue)'", file: file, line: line)
|
||||
// } else {
|
||||
// XCTFail("Key \(key) has no value in \(locale) localization for \(catalogName) catalog", file: file, line: line)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // MARK: - Loading
|
||||
//
|
||||
// private func loadContext(relativePath: String) throws -> CatalogContext {
|
||||
// let catalog = try loadCatalog(relativePath: relativePath)
|
||||
// let locales = catalog.locales
|
||||
// let baseLocale = catalog.sourceLanguage
|
||||
// var keysByLocale: [String: Set<String>] = [:]
|
||||
// var placeholderSignature: [String: [String: [String: [String]]]] = [:]
|
||||
//
|
||||
// for locale in locales {
|
||||
// var localeKeys: Set<String> = []
|
||||
// var localePlaceholders: [String: [String: [String]]] = [:]
|
||||
// for (key, entry) in catalog.strings {
|
||||
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||
// continue
|
||||
// }
|
||||
// localeKeys.insert(key)
|
||||
// let segments = gatherSegments(from: unit)
|
||||
// var pathMap: [String: [String]] = [:]
|
||||
// for segment in segments {
|
||||
// pathMap[segment.path] = placeholders(in: segment.value)
|
||||
// }
|
||||
// localePlaceholders[key] = pathMap
|
||||
// }
|
||||
// keysByLocale[locale] = localeKeys
|
||||
// placeholderSignature[locale] = localePlaceholders
|
||||
// }
|
||||
//
|
||||
// return CatalogContext(catalog: catalog, locales: locales, baseLocale: baseLocale, keysByLocale: keysByLocale, placeholderSignature: placeholderSignature)
|
||||
// }
|
||||
//
|
||||
// private func loadCatalog(relativePath: String) throws -> StringCatalog {
|
||||
// let url = repoRootURL.appendingPathComponent(relativePath)
|
||||
// let data = try Data(contentsOf: url)
|
||||
// return try JSONDecoder().decode(StringCatalog.self, from: data)
|
||||
// }
|
||||
//
|
||||
// private func loadPrimaryKeys() throws -> PrimaryKeyConfig {
|
||||
// let url = localizationTestsDirectoryURL.appendingPathComponent("PrimaryLocalizationKeys.json")
|
||||
// let data = try Data(contentsOf: url)
|
||||
// return try JSONDecoder().decode(PrimaryKeyConfig.self, from: data)
|
||||
// }
|
||||
//
|
||||
// // MARK: - Regression Tests
|
||||
//
|
||||
// /// Helper method to access the app bundle for localization testing.
|
||||
// /// This ensures tests use the actual app's Localizable.xcstrings instead of
|
||||
// /// the test bundle, preventing false positives where localization keys are
|
||||
// /// returned instead of translated strings.
|
||||
// private func appBundle() -> Bundle {
|
||||
// Bundle(for: ChatViewModel.self)
|
||||
// }
|
||||
//
|
||||
// /// Tests to prevent regression of the pluralization format string issue
|
||||
// /// These tests ensure that String(localized:) properly handles
|
||||
// /// pluralized format strings with %#@variable@ syntax
|
||||
// func testPluralizedFormatStringsDoNotCrash() {
|
||||
// // These are the exact calls that were causing runtime errors
|
||||
//
|
||||
// // Test 1: content.accessibility.people_count with Int argument
|
||||
// XCTAssertNoThrow({
|
||||
// let result = String(
|
||||
// localized: "content.accessibility.people_count",
|
||||
// bundle: appBundle(),
|
||||
// comment: "Accessibility label announcing number of people in header"
|
||||
// )
|
||||
// XCTAssertFalse(result.isEmpty, "Should return formatted string")
|
||||
// // Verify we're getting actual localized content, not just the key
|
||||
// XCTAssertNotEqual(result, "content.accessibility.people_count", "Should return localized string, not key")
|
||||
// }(), "People count localization should not throw")
|
||||
//
|
||||
// // Test 2: location_notes.header with String and Int arguments
|
||||
// XCTAssertNoThrow({
|
||||
// let result = String(
|
||||
// localized: "location_notes.header",
|
||||
// bundle: appBundle(),
|
||||
// comment: "Header displaying the geohash and localized note count"
|
||||
// )
|
||||
// XCTAssertFalse(result.isEmpty, "Should return formatted string")
|
||||
// // Verify we're getting actual localized content, not just the key
|
||||
// XCTAssertNotEqual(result, "location_notes.header", "Should return localized string, not key")
|
||||
// }(), "Location notes header localization should not throw")
|
||||
// }
|
||||
//
|
||||
// func testFormatStringArgumentMatching() {
|
||||
// // Verify that the format strings properly handle their expected arguments
|
||||
//
|
||||
// // People count expects: %#@people@ format with Int
|
||||
// let peopleResult = String(
|
||||
// localized: "content.accessibility.people_count",
|
||||
// bundle: appBundle(),
|
||||
// comment: "Test"
|
||||
// )
|
||||
//
|
||||
// // Should not show format specifiers in the base string
|
||||
// XCTAssertFalse(peopleResult.isEmpty, "Should return non-empty string")
|
||||
//
|
||||
// // Location notes expects: #%@ • %#@note_count@ format with String, Int
|
||||
// let notesResult = String(
|
||||
// localized: "location_notes.header",
|
||||
// bundle: appBundle(),
|
||||
// comment: "Test"
|
||||
// )
|
||||
//
|
||||
// XCTAssertFalse(notesResult.isEmpty, "Should return non-empty string")
|
||||
// }
|
||||
//
|
||||
// func testPluralizedStringsWithArguments() {
|
||||
// // Test the pluralized strings with actual format arguments
|
||||
//
|
||||
// // Test people count with different values
|
||||
// let testCases = [0, 1, 2, 5]
|
||||
//
|
||||
// for count in testCases {
|
||||
// let result = String(
|
||||
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// count
|
||||
// )
|
||||
//
|
||||
// // Just verify it doesn't crash and returns a reasonable string
|
||||
// XCTAssertFalse(result.isEmpty,
|
||||
// "People count should return non-empty string for count \(count)")
|
||||
// XCTAssertTrue(result.contains("\(count)"),
|
||||
// "Result should contain the count number for count \(count)")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func testLocationNotesHeaderWithArguments() {
|
||||
// let geohash = "abc123"
|
||||
// let testCases = [0, 1, 2, 10]
|
||||
//
|
||||
// for count in testCases {
|
||||
// let result = String(
|
||||
// format: String(localized: "location_notes.header", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// geohash, count
|
||||
// )
|
||||
//
|
||||
// // Verify basic structure is maintained
|
||||
// XCTAssertTrue(result.contains(geohash),
|
||||
// "Result should contain geohash for count \(count)")
|
||||
// XCTAssertTrue(result.contains("\(count)"),
|
||||
// "Result should contain count for count \(count)")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func testLocationChannelsRowTitleWithArguments() {
|
||||
// let label = "test-channel"
|
||||
// let testCases = [0, 1, 2, 5]
|
||||
//
|
||||
// for count in testCases {
|
||||
// let result = String(
|
||||
// format: String(localized: "location_channels.row_title", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// label, count
|
||||
// )
|
||||
//
|
||||
// // Verify basic structure is maintained
|
||||
// XCTAssertTrue(result.contains(label),
|
||||
// "Result should contain label for count \(count)")
|
||||
// XCTAssertTrue(result.contains("\(count)"),
|
||||
// "Result should contain count for count \(count)")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func testNoArgumentsLocalization() {
|
||||
// // Test that strings without arguments still work
|
||||
// let result = String(
|
||||
// localized: "common.ok",
|
||||
// bundle: appBundle(),
|
||||
// comment: "OK button text"
|
||||
// )
|
||||
//
|
||||
// XCTAssertFalse(result.isEmpty, "Simple localization should work")
|
||||
// }
|
||||
//
|
||||
// func testLocalizationEdgeCases() {
|
||||
// // Test edge cases that might cause issues
|
||||
//
|
||||
// // Large numbers
|
||||
// let largeNumberResult = String(
|
||||
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// 1000000
|
||||
// )
|
||||
// XCTAssertTrue(largeNumberResult.contains("1000000"), "Should handle large numbers")
|
||||
//
|
||||
// // Zero
|
||||
// let zeroResult = String(
|
||||
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// 0
|
||||
// )
|
||||
// XCTAssertTrue(zeroResult.contains("0"), "Should handle zero")
|
||||
//
|
||||
// // Empty geohash (edge case)
|
||||
// let emptyGeohashResult = String(
|
||||
// format: String(localized: "location_notes.header", bundle: appBundle(), comment: "Test"),
|
||||
// locale: .current,
|
||||
// "", 1
|
||||
// )
|
||||
// XCTAssertFalse(emptyGeohashResult.isEmpty, "Should handle empty geohash")
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//// MARK: - Helpers
|
||||
//
|
||||
//private struct CatalogContext {
|
||||
// let catalog: StringCatalog
|
||||
// let locales: [String]
|
||||
// let baseLocale: String
|
||||
// let keysByLocale: [String: Set<String>]
|
||||
// let placeholderSignature: [String: [String: [String: [String]]]]
|
||||
//}
|
||||
//
|
||||
//private struct StringCatalog: Decodable {
|
||||
// let sourceLanguage: String
|
||||
// let strings: [String: CatalogEntry]
|
||||
//
|
||||
// var locales: [String] {
|
||||
// var localeSet: Set<String> = []
|
||||
// for entry in strings.values {
|
||||
// localeSet.formUnion(entry.localizations.keys)
|
||||
// }
|
||||
// return localeSet.sorted()
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//private struct CatalogEntry: Decodable {
|
||||
// let localizations: [String: CatalogLocalization]
|
||||
//}
|
||||
//
|
||||
//private struct CatalogLocalization: Decodable {
|
||||
// let stringUnit: CatalogStringUnit?
|
||||
//}
|
||||
//
|
||||
//private struct CatalogStringUnit: Decodable {
|
||||
// let state: String
|
||||
// let value: String?
|
||||
// let variations: CatalogVariations?
|
||||
// let comment: String?
|
||||
//}
|
||||
//
|
||||
//private struct CatalogVariations: Decodable {
|
||||
// let plural: [String: [String: CatalogVariationValue]]?
|
||||
//}
|
||||
//
|
||||
//private struct CatalogVariationValue: Decodable {
|
||||
// let stringUnit: CatalogStringUnit?
|
||||
//}
|
||||
//
|
||||
//private struct Segment {
|
||||
// let components: [String]
|
||||
// let value: String
|
||||
//
|
||||
// var path: String {
|
||||
// components.isEmpty ? "base" : components.joined(separator: ".")
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//private func gatherSegments(from unit: CatalogStringUnit, prefix: [String] = []) -> [Segment] {
|
||||
// var segments: [Segment] = []
|
||||
// if let value = unit.value {
|
||||
// segments.append(Segment(components: prefix, value: value))
|
||||
// } else if prefix.isEmpty {
|
||||
// segments.append(Segment(components: [], value: ""))
|
||||
// }
|
||||
// if let plural = unit.variations?.plural {
|
||||
// for (variable, categories) in plural.sorted(by: { $0.key < $1.key }) {
|
||||
// for (category, variation) in categories.sorted(by: { $0.key < $1.key }) {
|
||||
// if let nested = variation.stringUnit {
|
||||
// var nextPrefix = prefix
|
||||
// nextPrefix.append("plural")
|
||||
// nextPrefix.append(variable)
|
||||
// nextPrefix.append(category)
|
||||
// segments.append(contentsOf: gatherSegments(from: nested, prefix: nextPrefix))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// return segments
|
||||
//}
|
||||
//
|
||||
//private func normalizedPlaceholders(_ tokens: [String]) -> [String] {
|
||||
// tokens.sorted()
|
||||
//}
|
||||
//
|
||||
//private func fallbackPath(for localePath: String, baseMap: [String: [String]]) -> String? {
|
||||
// let parts = localePath.split(separator: ".")
|
||||
// guard parts.count == 3, parts.first == "plural" else {
|
||||
// return nil
|
||||
// }
|
||||
// let variable = parts[1]
|
||||
// let otherKey = "plural.\(variable).other"
|
||||
// if baseMap[otherKey] != nil {
|
||||
// return otherKey
|
||||
// }
|
||||
// let oneKey = "plural.\(variable).one"
|
||||
// if baseMap[oneKey] != nil {
|
||||
// return oneKey
|
||||
// }
|
||||
// return nil
|
||||
//}
|
||||
//
|
||||
//private let placeholderRegex: NSRegularExpression = {
|
||||
// let pattern = "%(?:\\d+\\$)?#@[A-Za-z0-9_]+@|%(?:\\d+\\$)?[#0\\- +'\"]*(?:\\d+|\\*)?(?:\\.\\d+)?(?:hh|h|ll|l|z|t|L)?[a-zA-Z@]"
|
||||
// return try! NSRegularExpression(pattern: pattern, options: [])
|
||||
//}()
|
||||
//
|
||||
//private func placeholders(in string: String) -> [String] {
|
||||
// let range = NSRange(location: 0, length: (string as NSString).length)
|
||||
// let matches = placeholderRegex.matches(in: string, options: [], range: range)
|
||||
// var tokens: [String] = []
|
||||
// for match in matches {
|
||||
// if let range = Range(match.range, in: string) {
|
||||
// let token = String(string[range])
|
||||
// if token == "%%" { continue }
|
||||
// tokens.append(token)
|
||||
// }
|
||||
// }
|
||||
// return tokens
|
||||
//}
|
||||
//
|
||||
//private struct PrimaryKeyConfig: Decodable {
|
||||
// let app: [String]
|
||||
// let shareExtension: [String]
|
||||
// let expectedValues: [String: [String: String]]?
|
||||
// let testLocales: [String]?
|
||||
//}
|
||||
@@ -0,0 +1,604 @@
|
||||
//
|
||||
// MessageDeduplicationServiceTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for MessageDeduplicationService, LRUDeduplicationCache, and ContentNormalizer.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - LRU Deduplication Cache Tests
|
||||
|
||||
@Suite("LRU Deduplication Cache")
|
||||
@MainActor
|
||||
struct LRUDeduplicationCacheTests {
|
||||
|
||||
// MARK: - Basic Operations
|
||||
|
||||
@Test func emptyCache_containsReturnsFalse() {
|
||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
||||
#expect(!cache.contains("key"))
|
||||
#expect(cache.value(for: "key") == nil)
|
||||
#expect(cache.count == 0)
|
||||
}
|
||||
|
||||
@Test func record_addsEntry() {
|
||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
||||
cache.record("key1", value: 42)
|
||||
|
||||
#expect(cache.contains("key1"))
|
||||
#expect(cache.value(for: "key1") == 42)
|
||||
#expect(cache.count == 1)
|
||||
}
|
||||
|
||||
@Test func record_updatesExistingEntry() {
|
||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
||||
cache.record("key1", value: 42)
|
||||
cache.record("key1", value: 100)
|
||||
|
||||
#expect(cache.value(for: "key1") == 100)
|
||||
#expect(cache.count == 1) // Should not increase count
|
||||
}
|
||||
|
||||
@Test func record_multipleEntries() {
|
||||
let cache = LRUDeduplicationCache<String>(capacity: 10)
|
||||
cache.record("a", value: "alpha")
|
||||
cache.record("b", value: "beta")
|
||||
cache.record("c", value: "gamma")
|
||||
|
||||
#expect(cache.count == 3)
|
||||
#expect(cache.value(for: "a") == "alpha")
|
||||
#expect(cache.value(for: "b") == "beta")
|
||||
#expect(cache.value(for: "c") == "gamma")
|
||||
}
|
||||
|
||||
@Test func remove_removesEntry() {
|
||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
||||
cache.record("key1", value: 42)
|
||||
cache.record("key2", value: 100)
|
||||
|
||||
cache.remove("key1")
|
||||
|
||||
#expect(!cache.contains("key1"))
|
||||
#expect(cache.contains("key2"))
|
||||
}
|
||||
|
||||
@Test func clear_removesAllEntries() {
|
||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
||||
cache.record("a", value: 1)
|
||||
cache.record("b", value: 2)
|
||||
cache.record("c", value: 3)
|
||||
|
||||
cache.clear()
|
||||
|
||||
#expect(cache.count == 0)
|
||||
#expect(!cache.contains("a"))
|
||||
#expect(!cache.contains("b"))
|
||||
#expect(!cache.contains("c"))
|
||||
}
|
||||
|
||||
// MARK: - Eviction Tests
|
||||
|
||||
@Test func eviction_removesOldestWhenOverCapacity() {
|
||||
let cache = LRUDeduplicationCache<Int>(capacity: 3)
|
||||
cache.record("a", value: 1)
|
||||
cache.record("b", value: 2)
|
||||
cache.record("c", value: 3)
|
||||
cache.record("d", value: 4) // Should evict "a"
|
||||
|
||||
#expect(cache.count == 3)
|
||||
#expect(!cache.contains("a")) // Evicted
|
||||
#expect(cache.contains("b"))
|
||||
#expect(cache.contains("c"))
|
||||
#expect(cache.contains("d"))
|
||||
}
|
||||
|
||||
@Test func eviction_maintainsCapacity() {
|
||||
let cache = LRUDeduplicationCache<Int>(capacity: 2)
|
||||
|
||||
for i in 0..<100 {
|
||||
cache.record("key\(i)", value: i)
|
||||
}
|
||||
|
||||
#expect(cache.count == 2)
|
||||
// Most recent entries should be present
|
||||
#expect(cache.contains("key99"))
|
||||
#expect(cache.contains("key98"))
|
||||
// Older entries should be evicted
|
||||
#expect(!cache.contains("key0"))
|
||||
#expect(!cache.contains("key50"))
|
||||
}
|
||||
|
||||
@Test func eviction_capacityOfOne() {
|
||||
let cache = LRUDeduplicationCache<Int>(capacity: 1)
|
||||
cache.record("a", value: 1)
|
||||
cache.record("b", value: 2)
|
||||
|
||||
#expect(cache.count == 1)
|
||||
#expect(!cache.contains("a"))
|
||||
#expect(cache.contains("b"))
|
||||
}
|
||||
|
||||
@Test func eviction_skipsRemovedKeys() {
|
||||
let cache = LRUDeduplicationCache<Int>(capacity: 3)
|
||||
cache.record("a", value: 1)
|
||||
cache.record("b", value: 2)
|
||||
cache.record("c", value: 3)
|
||||
|
||||
// Remove "a" manually
|
||||
cache.remove("a")
|
||||
|
||||
// Add new entry - should evict "b" (next oldest still in map)
|
||||
cache.record("d", value: 4)
|
||||
|
||||
// Cache should have b, c, d (a was removed)
|
||||
// Actually after eviction it should have c, d and maybe b depending on implementation
|
||||
#expect(!cache.contains("a"))
|
||||
#expect(cache.count <= 3)
|
||||
}
|
||||
|
||||
// MARK: - Edge Cases
|
||||
|
||||
@Test func emptyKey_works() {
|
||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
||||
cache.record("", value: 42)
|
||||
|
||||
#expect(cache.contains(""))
|
||||
#expect(cache.value(for: "") == 42)
|
||||
}
|
||||
|
||||
@Test func largeCapacity_works() {
|
||||
let cache = LRUDeduplicationCache<Int>(capacity: 10000)
|
||||
|
||||
for i in 0..<5000 {
|
||||
cache.record("key\(i)", value: i)
|
||||
}
|
||||
|
||||
#expect(cache.count == 5000)
|
||||
#expect(cache.contains("key0"))
|
||||
#expect(cache.contains("key4999"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Normalizer Tests
|
||||
|
||||
struct ContentNormalizerTests {
|
||||
|
||||
@Test func normalizedKey_basicContent() {
|
||||
let key1 = ContentNormalizer.normalizedKey("Hello World")
|
||||
let key2 = ContentNormalizer.normalizedKey("Hello World")
|
||||
#expect(key1 == key2)
|
||||
}
|
||||
|
||||
@Test func normalizedKey_caseInsensitive() {
|
||||
let key1 = ContentNormalizer.normalizedKey("Hello World")
|
||||
let key2 = ContentNormalizer.normalizedKey("hello world")
|
||||
let key3 = ContentNormalizer.normalizedKey("HELLO WORLD")
|
||||
#expect(key1 == key2)
|
||||
#expect(key2 == key3)
|
||||
}
|
||||
|
||||
@Test func normalizedKey_whitespaceCollapsed() {
|
||||
let key1 = ContentNormalizer.normalizedKey("Hello World")
|
||||
let key2 = ContentNormalizer.normalizedKey("Hello World")
|
||||
let key3 = ContentNormalizer.normalizedKey("Hello\t\nWorld")
|
||||
#expect(key1 == key2)
|
||||
#expect(key2 == key3)
|
||||
}
|
||||
|
||||
@Test func normalizedKey_trimmed() {
|
||||
let key1 = ContentNormalizer.normalizedKey("Hello")
|
||||
let key2 = ContentNormalizer.normalizedKey(" Hello ")
|
||||
let key3 = ContentNormalizer.normalizedKey("\nHello\n")
|
||||
#expect(key1 == key2)
|
||||
#expect(key2 == key3)
|
||||
}
|
||||
|
||||
@Test func normalizedKey_urlQueryStripped() {
|
||||
let key1 = ContentNormalizer.normalizedKey("Check https://example.com/page")
|
||||
let key2 = ContentNormalizer.normalizedKey("Check https://example.com/page?query=value")
|
||||
let key3 = ContentNormalizer.normalizedKey("Check https://example.com/page#anchor")
|
||||
#expect(key1 == key2)
|
||||
#expect(key2 == key3)
|
||||
}
|
||||
|
||||
@Test func normalizedKey_httpAndHttpsDistinct() {
|
||||
// URL scheme is preserved
|
||||
let key1 = ContentNormalizer.normalizedKey("http://example.com/page")
|
||||
let key2 = ContentNormalizer.normalizedKey("https://example.com/page")
|
||||
#expect(key1 != key2)
|
||||
}
|
||||
|
||||
@Test func normalizedKey_differentContent() {
|
||||
let key1 = ContentNormalizer.normalizedKey("Hello")
|
||||
let key2 = ContentNormalizer.normalizedKey("Goodbye")
|
||||
#expect(key1 != key2)
|
||||
}
|
||||
|
||||
@Test func normalizedKey_returnsHashFormat() {
|
||||
let key = ContentNormalizer.normalizedKey("Test content")
|
||||
#expect(key.hasPrefix("h:"))
|
||||
#expect(key.count == 18) // "h:" + 16 hex chars
|
||||
}
|
||||
|
||||
@Test func normalizedKey_emptyContent() {
|
||||
let key = ContentNormalizer.normalizedKey("")
|
||||
#expect(key.hasPrefix("h:"))
|
||||
}
|
||||
|
||||
@Test func normalizedKey_longContentTruncated() {
|
||||
let longContent = String(repeating: "a", count: 10000)
|
||||
let key1 = ContentNormalizer.normalizedKey(longContent)
|
||||
let key2 = ContentNormalizer.normalizedKey(longContent + "extra")
|
||||
|
||||
// Both should be the same since content is truncated before hashing
|
||||
#expect(key1 == key2)
|
||||
}
|
||||
|
||||
@Test func normalizedKey_prefixLengthRespected() {
|
||||
let content = "Short"
|
||||
let key1 = ContentNormalizer.normalizedKey(content, prefixLength: 3)
|
||||
let key2 = ContentNormalizer.normalizedKey(content, prefixLength: 100)
|
||||
|
||||
// Different prefix lengths may produce different keys
|
||||
// "sho" vs "short"
|
||||
#expect(key1 != key2)
|
||||
}
|
||||
|
||||
@Test func normalizedKey_urlsInMiddleOfContent() {
|
||||
let content1 = "Check out https://example.com/path?query=1 for more info"
|
||||
let content2 = "Check out https://example.com/path for more info"
|
||||
let key1 = ContentNormalizer.normalizedKey(content1)
|
||||
let key2 = ContentNormalizer.normalizedKey(content2)
|
||||
#expect(key1 == key2)
|
||||
}
|
||||
|
||||
@Test func normalizedKey_multipleUrls() {
|
||||
let content1 = "Links: https://a.com?x=1 and http://b.com#y"
|
||||
let content2 = "Links: https://a.com and http://b.com"
|
||||
let key1 = ContentNormalizer.normalizedKey(content1)
|
||||
let key2 = ContentNormalizer.normalizedKey(content2)
|
||||
#expect(key1 == key2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Deduplication Service Tests
|
||||
|
||||
@Suite("Message Deduplication Service")
|
||||
@MainActor
|
||||
struct MessageDeduplicationServiceTests {
|
||||
|
||||
// MARK: - Content Deduplication
|
||||
|
||||
@Test func recordContent_storesTimestamp() {
|
||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
||||
let now = Date()
|
||||
|
||||
service.recordContent("Hello World", timestamp: now)
|
||||
|
||||
let retrieved = service.contentTimestamp(for: "Hello World")
|
||||
#expect(retrieved == now)
|
||||
}
|
||||
|
||||
@Test func recordContent_updatesTimestamp() {
|
||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
||||
let early = Date(timeIntervalSince1970: 1000)
|
||||
let late = Date(timeIntervalSince1970: 2000)
|
||||
|
||||
service.recordContent("Hello World", timestamp: early)
|
||||
service.recordContent("Hello World", timestamp: late)
|
||||
|
||||
let retrieved = service.contentTimestamp(for: "Hello World")
|
||||
#expect(retrieved == late)
|
||||
}
|
||||
|
||||
@Test func contentTimestamp_nilForUnseen() {
|
||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
||||
|
||||
let timestamp = service.contentTimestamp(for: "Never seen")
|
||||
#expect(timestamp == nil)
|
||||
}
|
||||
|
||||
@Test func recordContentKey_directKeyAccess() {
|
||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
||||
let now = Date()
|
||||
let key = service.normalizedContentKey("Test")
|
||||
|
||||
service.recordContentKey(key, timestamp: now)
|
||||
|
||||
#expect(service.contentTimestamp(forKey: key) == now)
|
||||
}
|
||||
|
||||
@Test func normalizedContentKey_consistentWithNormalizer() {
|
||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
||||
let content = "Hello World"
|
||||
|
||||
let serviceKey = service.normalizedContentKey(content)
|
||||
let normalizerKey = ContentNormalizer.normalizedKey(content)
|
||||
|
||||
#expect(serviceKey == normalizerKey)
|
||||
}
|
||||
|
||||
// MARK: - Nostr Event Deduplication
|
||||
|
||||
@Test func recordNostrEvent_marksAsProcessed() {
|
||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
||||
|
||||
#expect(!service.hasProcessedNostrEvent("event123"))
|
||||
|
||||
service.recordNostrEvent("event123")
|
||||
|
||||
#expect(service.hasProcessedNostrEvent("event123"))
|
||||
}
|
||||
|
||||
@Test func hasProcessedNostrEvent_falseForUnseen() {
|
||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
||||
|
||||
#expect(!service.hasProcessedNostrEvent("never-seen"))
|
||||
}
|
||||
|
||||
@Test func nostrEvent_multipleEvents() {
|
||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
||||
|
||||
service.recordNostrEvent("event1")
|
||||
service.recordNostrEvent("event2")
|
||||
service.recordNostrEvent("event3")
|
||||
|
||||
#expect(service.hasProcessedNostrEvent("event1"))
|
||||
#expect(service.hasProcessedNostrEvent("event2"))
|
||||
#expect(service.hasProcessedNostrEvent("event3"))
|
||||
#expect(!service.hasProcessedNostrEvent("event4"))
|
||||
}
|
||||
|
||||
// MARK: - Nostr ACK Deduplication
|
||||
|
||||
@Test func recordNostrAck_marksAsProcessed() {
|
||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
||||
let ackKey = MessageDeduplicationService.ackKey(
|
||||
messageId: "msg123",
|
||||
ackType: "delivered",
|
||||
senderPubkey: "pubkey456"
|
||||
)
|
||||
|
||||
#expect(!service.hasProcessedNostrAck(ackKey))
|
||||
|
||||
service.recordNostrAck(ackKey)
|
||||
|
||||
#expect(service.hasProcessedNostrAck(ackKey))
|
||||
}
|
||||
|
||||
@Test func ackKey_format() {
|
||||
let key = MessageDeduplicationService.ackKey(
|
||||
messageId: "msg",
|
||||
ackType: "read",
|
||||
senderPubkey: "pub"
|
||||
)
|
||||
#expect(key == "msg:read:pub")
|
||||
}
|
||||
|
||||
@Test func ackKey_differentComponents() {
|
||||
let key1 = MessageDeduplicationService.ackKey(messageId: "a", ackType: "delivered", senderPubkey: "x")
|
||||
let key2 = MessageDeduplicationService.ackKey(messageId: "a", ackType: "read", senderPubkey: "x")
|
||||
let key3 = MessageDeduplicationService.ackKey(messageId: "b", ackType: "delivered", senderPubkey: "x")
|
||||
|
||||
#expect(key1 != key2) // Different ackType
|
||||
#expect(key1 != key3) // Different messageId
|
||||
}
|
||||
|
||||
// MARK: - Clear Operations
|
||||
|
||||
@Test func clearAll_clearsEverything() {
|
||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
||||
let now = Date()
|
||||
|
||||
service.recordContent("Hello", timestamp: now)
|
||||
service.recordNostrEvent("event1")
|
||||
service.recordNostrAck("ack1")
|
||||
|
||||
service.clearAll()
|
||||
|
||||
#expect(service.contentTimestamp(for: "Hello") == nil)
|
||||
#expect(!service.hasProcessedNostrEvent("event1"))
|
||||
#expect(!service.hasProcessedNostrAck("ack1"))
|
||||
}
|
||||
|
||||
@Test func clearNostrCaches_preservesContent() {
|
||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
||||
let now = Date()
|
||||
|
||||
service.recordContent("Hello", timestamp: now)
|
||||
service.recordNostrEvent("event1")
|
||||
service.recordNostrAck("ack1")
|
||||
|
||||
service.clearNostrCaches()
|
||||
|
||||
#expect(service.contentTimestamp(for: "Hello") == now) // Preserved
|
||||
#expect(!service.hasProcessedNostrEvent("event1")) // Cleared
|
||||
#expect(!service.hasProcessedNostrAck("ack1")) // Cleared
|
||||
}
|
||||
|
||||
// MARK: - Capacity Tests
|
||||
|
||||
@Test func contentCache_respectsCapacity() {
|
||||
let service = MessageDeduplicationService(contentCapacity: 3, nostrEventCapacity: 100)
|
||||
|
||||
service.recordContent("a", timestamp: Date())
|
||||
service.recordContent("b", timestamp: Date())
|
||||
service.recordContent("c", timestamp: Date())
|
||||
service.recordContent("d", timestamp: Date())
|
||||
|
||||
// "a" should have been evicted
|
||||
#expect(service.contentTimestamp(for: "a") == nil)
|
||||
#expect(service.contentTimestamp(for: "d") != nil)
|
||||
}
|
||||
|
||||
@Test func nostrEventCache_respectsCapacity() {
|
||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 3)
|
||||
|
||||
service.recordNostrEvent("e1")
|
||||
service.recordNostrEvent("e2")
|
||||
service.recordNostrEvent("e3")
|
||||
service.recordNostrEvent("e4")
|
||||
|
||||
// "e1" should have been evicted
|
||||
#expect(!service.hasProcessedNostrEvent("e1"))
|
||||
#expect(service.hasProcessedNostrEvent("e4"))
|
||||
}
|
||||
|
||||
// MARK: - Integration Tests
|
||||
|
||||
@Test func realWorldDeduplication_similarMessages() {
|
||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
||||
let now = Date()
|
||||
|
||||
// Record original message
|
||||
service.recordContent("Check out https://example.com/page?ref=abc", timestamp: now)
|
||||
|
||||
// Same URL with different query params should match
|
||||
let timestamp = service.contentTimestamp(for: "Check out https://example.com/page?ref=xyz")
|
||||
#expect(timestamp == now)
|
||||
}
|
||||
|
||||
@Test func realWorldDeduplication_caseVariations() {
|
||||
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
|
||||
let now = Date()
|
||||
|
||||
service.recordContent("HELLO WORLD", timestamp: now)
|
||||
|
||||
#expect(service.contentTimestamp(for: "hello world") == now)
|
||||
#expect(service.contentTimestamp(for: "Hello World") == now)
|
||||
}
|
||||
|
||||
// MARK: - Thread Safety Tests (via @MainActor enforcement)
|
||||
|
||||
@Test("Concurrent content recording is safe via MainActor")
|
||||
func concurrentContentRecording() async {
|
||||
let service = MessageDeduplicationService(contentCapacity: 1000, nostrEventCapacity: 1000)
|
||||
let iterations = 100
|
||||
|
||||
// All operations run on MainActor due to @MainActor annotation
|
||||
// This test verifies the pattern works correctly
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for i in 0..<iterations {
|
||||
group.addTask { @MainActor in
|
||||
service.recordContent("Message \(i)", timestamp: Date())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify some entries were recorded
|
||||
#expect(service.contentTimestamp(for: "Message 0") != nil)
|
||||
#expect(service.contentTimestamp(for: "Message 99") != nil)
|
||||
}
|
||||
|
||||
@Test("Concurrent Nostr event recording is safe via MainActor")
|
||||
func concurrentNostrEventRecording() async {
|
||||
let service = MessageDeduplicationService(contentCapacity: 1000, nostrEventCapacity: 1000)
|
||||
let iterations = 100
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for i in 0..<iterations {
|
||||
group.addTask { @MainActor in
|
||||
service.recordNostrEvent("event_\(i)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify events were recorded
|
||||
#expect(service.hasProcessedNostrEvent("event_0"))
|
||||
#expect(service.hasProcessedNostrEvent("event_99"))
|
||||
}
|
||||
|
||||
@Test("Mixed concurrent operations are safe via MainActor")
|
||||
func concurrentMixedOperations() async {
|
||||
let service = MessageDeduplicationService(contentCapacity: 1000, nostrEventCapacity: 1000)
|
||||
let iterations = 50
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
// Content recording tasks
|
||||
for i in 0..<iterations {
|
||||
group.addTask { @MainActor in
|
||||
service.recordContent("Content \(i)", timestamp: Date())
|
||||
}
|
||||
}
|
||||
|
||||
// Event recording tasks
|
||||
for i in 0..<iterations {
|
||||
group.addTask { @MainActor in
|
||||
service.recordNostrEvent("event_\(i)")
|
||||
}
|
||||
}
|
||||
|
||||
// ACK recording tasks
|
||||
for i in 0..<iterations {
|
||||
group.addTask { @MainActor in
|
||||
service.recordNostrAck("ack_\(i)")
|
||||
}
|
||||
}
|
||||
|
||||
// Read tasks
|
||||
for i in 0..<iterations {
|
||||
group.addTask { @MainActor in
|
||||
_ = service.contentTimestamp(for: "Content \(i)")
|
||||
_ = service.hasProcessedNostrEvent("event_\(i)")
|
||||
_ = service.hasProcessedNostrAck("ack_\(i)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here without crashes, the test passes
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - LRU Cache Thread Safety Tests
|
||||
|
||||
@Suite("LRU Cache Thread Safety")
|
||||
@MainActor
|
||||
struct LRUCacheThreadSafetyTests {
|
||||
|
||||
@Test("Concurrent cache access is safe via MainActor")
|
||||
func concurrentCacheAccess() async {
|
||||
let cache = LRUDeduplicationCache<Int>(capacity: 500)
|
||||
let iterations = 100
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
// Write tasks
|
||||
for i in 0..<iterations {
|
||||
group.addTask { @MainActor in
|
||||
cache.record("key_\(i)", value: i)
|
||||
}
|
||||
}
|
||||
|
||||
// Read tasks
|
||||
for i in 0..<iterations {
|
||||
group.addTask { @MainActor in
|
||||
_ = cache.contains("key_\(i)")
|
||||
_ = cache.value(for: "key_\(i)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify cache is in consistent state
|
||||
#expect(cache.count <= 500) // Respects capacity
|
||||
}
|
||||
|
||||
@Test("Cache eviction under concurrent load is safe")
|
||||
func cacheEvictionUnderLoad() async {
|
||||
let cache = LRUDeduplicationCache<Int>(capacity: 10)
|
||||
let iterations = 100
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for i in 0..<iterations {
|
||||
group.addTask { @MainActor in
|
||||
cache.record("key_\(i)", value: i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache should maintain its capacity constraint
|
||||
#expect(cache.count == 10)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//
|
||||
// MessageFormattingEngineTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for MessageFormattingEngine regex patterns and utility functions.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
@testable import bitchat
|
||||
|
||||
struct MessageFormattingEngineTests {
|
||||
|
||||
// MARK: - Mention Extraction Tests
|
||||
|
||||
@Test func extractMentions_singleMention() {
|
||||
let content = "Hello @alice how are you?"
|
||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
||||
#expect(mentions == ["alice"])
|
||||
}
|
||||
|
||||
@Test func extractMentions_multipleMentions() {
|
||||
let content = "@alice and @bob are chatting with @charlie"
|
||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
||||
#expect(mentions.count == 3)
|
||||
#expect(mentions.contains("alice"))
|
||||
#expect(mentions.contains("bob"))
|
||||
#expect(mentions.contains("charlie"))
|
||||
}
|
||||
|
||||
@Test func extractMentions_mentionWithSuffix() {
|
||||
let content = "Hey @alice#a1b2 check this out"
|
||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
||||
#expect(mentions == ["alice#a1b2"])
|
||||
}
|
||||
|
||||
@Test func extractMentions_noMentions() {
|
||||
let content = "Just a regular message with no mentions"
|
||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
||||
#expect(mentions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func extractMentions_unicodeNickname() {
|
||||
let content = "Hello @日本語 and @émile"
|
||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
||||
#expect(mentions.count == 2)
|
||||
#expect(mentions.contains("日本語"))
|
||||
#expect(mentions.contains("émile"))
|
||||
}
|
||||
|
||||
@Test func extractMentions_mentionWithUnderscore() {
|
||||
let content = "Thanks @user_name_123"
|
||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
||||
#expect(mentions == ["user_name_123"])
|
||||
}
|
||||
|
||||
@Test func extractMentions_emailNotCaptured() {
|
||||
// Email addresses should not be captured as mentions
|
||||
let content = "Contact me at test@example.com"
|
||||
let mentions = MessageFormattingEngine.extractMentions(from: content)
|
||||
// The regex will capture "example" after @ in email - this is expected behavior
|
||||
// as the regex doesn't distinguish email addresses
|
||||
#expect(mentions.count == 1)
|
||||
}
|
||||
|
||||
// MARK: - Cashu Token Detection Tests
|
||||
|
||||
@Test func containsCashuToken_validTokenA() {
|
||||
let content = "Here's a token: cashuAeyJwcm9vZnMiOiJIZWxsbyBXb3JsZCEgVGhpcyBpcyBhIHRlc3QgdG9rZW4i"
|
||||
#expect(MessageFormattingEngine.containsCashuToken(content))
|
||||
}
|
||||
|
||||
@Test func containsCashuToken_validTokenB() {
|
||||
let content = "Payment: cashuBeyJwcm9vZnMiOiJIZWxsbyBXb3JsZCEgVGhpcyBpcyBhIHRlc3QgdG9rZW4i"
|
||||
#expect(MessageFormattingEngine.containsCashuToken(content))
|
||||
}
|
||||
|
||||
@Test func containsCashuToken_noToken() {
|
||||
let content = "Just a regular message about cashews"
|
||||
#expect(!MessageFormattingEngine.containsCashuToken(content))
|
||||
}
|
||||
|
||||
@Test func containsCashuToken_tooShort() {
|
||||
let content = "Invalid: cashuAshort"
|
||||
#expect(!MessageFormattingEngine.containsCashuToken(content))
|
||||
}
|
||||
|
||||
// MARK: - Regex Pattern Tests
|
||||
|
||||
@Test func hashtagPattern_standaloneHashtag() {
|
||||
let content = "#bitcoin is great"
|
||||
let nsContent = content as NSString
|
||||
let range = NSRange(location: 0, length: nsContent.length)
|
||||
let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range)
|
||||
#expect(matches.count == 1)
|
||||
}
|
||||
|
||||
@Test func hashtagPattern_multipleHashtags() {
|
||||
let content = "#bitcoin #lightning #nostr"
|
||||
let nsContent = content as NSString
|
||||
let range = NSRange(location: 0, length: nsContent.length)
|
||||
let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range)
|
||||
#expect(matches.count == 3)
|
||||
}
|
||||
|
||||
@Test func hashtagPattern_hashInMiddleOfWord() {
|
||||
let content = "test#notahashtag"
|
||||
let nsContent = content as NSString
|
||||
let range = NSRange(location: 0, length: nsContent.length)
|
||||
let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range)
|
||||
// This will match because the regex doesn't check for word boundaries
|
||||
#expect(matches.count == 1)
|
||||
}
|
||||
|
||||
@Test func bolt11Pattern_mainnet() {
|
||||
let content = "Pay this: lnbc10u1pjexampleinvoice0000000000000000000000000000000000000000000"
|
||||
let nsContent = content as NSString
|
||||
let range = NSRange(location: 0, length: nsContent.length)
|
||||
let matches = MessageFormattingEngine.Patterns.bolt11.matches(in: content, options: [], range: range)
|
||||
#expect(matches.count == 1)
|
||||
}
|
||||
|
||||
@Test func bolt11Pattern_testnet() {
|
||||
let content = "Test: lntb10u1pjexampleinvoice0000000000000000000000000000000000000000000"
|
||||
let nsContent = content as NSString
|
||||
let range = NSRange(location: 0, length: nsContent.length)
|
||||
let matches = MessageFormattingEngine.Patterns.bolt11.matches(in: content, options: [], range: range)
|
||||
#expect(matches.count == 1)
|
||||
}
|
||||
|
||||
@Test func lnurlPattern_valid() {
|
||||
let content = "LNURL: lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserx"
|
||||
let nsContent = content as NSString
|
||||
let range = NSRange(location: 0, length: nsContent.length)
|
||||
let matches = MessageFormattingEngine.Patterns.lnurl.matches(in: content, options: [], range: range)
|
||||
#expect(matches.count == 1)
|
||||
}
|
||||
|
||||
@Test func lightningSchemePattern_valid() {
|
||||
let content = "Click: lightning:lnbc10u1example"
|
||||
let nsContent = content as NSString
|
||||
let range = NSRange(location: 0, length: nsContent.length)
|
||||
let matches = MessageFormattingEngine.Patterns.lightningScheme.matches(in: content, options: [], range: range)
|
||||
#expect(matches.count == 1)
|
||||
}
|
||||
|
||||
@Test func cashuPattern_valid() {
|
||||
let content = "Token: cashuAeyJwcm9vZnMiOlt7ImlkIjoiMDAwMDAwMDAwMDAwMDAwMCJ9XX0="
|
||||
let nsContent = content as NSString
|
||||
let range = NSRange(location: 0, length: nsContent.length)
|
||||
let matches = MessageFormattingEngine.Patterns.cashu.matches(in: content, options: [], range: range)
|
||||
#expect(matches.count == 1)
|
||||
}
|
||||
|
||||
// MARK: - URL Detection Tests
|
||||
|
||||
@Test func linkDetector_httpURL() {
|
||||
let content = "Check out http://example.com"
|
||||
let nsContent = content as NSString
|
||||
let range = NSRange(location: 0, length: nsContent.length)
|
||||
let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? []
|
||||
#expect(matches.count == 1)
|
||||
}
|
||||
|
||||
@Test func linkDetector_httpsURL() {
|
||||
let content = "Visit https://example.com/path?query=value"
|
||||
let nsContent = content as NSString
|
||||
let range = NSRange(location: 0, length: nsContent.length)
|
||||
let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? []
|
||||
#expect(matches.count == 1)
|
||||
}
|
||||
|
||||
@Test func linkDetector_multipleURLs() {
|
||||
let content = "See https://a.com and http://b.com"
|
||||
let nsContent = content as NSString
|
||||
let range = NSRange(location: 0, length: nsContent.length)
|
||||
let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? []
|
||||
#expect(matches.count == 2)
|
||||
}
|
||||
|
||||
// MARK: - String Extension Tests
|
||||
|
||||
@Test func splitSuffix_withSuffix() {
|
||||
let name = "alice#a1b2"
|
||||
let (base, suffix) = name.splitSuffix()
|
||||
#expect(base == "alice")
|
||||
#expect(suffix == "#a1b2")
|
||||
}
|
||||
|
||||
@Test func splitSuffix_withoutSuffix() {
|
||||
let name = "alice"
|
||||
let (base, suffix) = name.splitSuffix()
|
||||
#expect(base == "alice")
|
||||
#expect(suffix == "")
|
||||
}
|
||||
|
||||
@Test func splitSuffix_withAtPrefix() {
|
||||
let name = "@alice#a1b2"
|
||||
let (base, suffix) = name.splitSuffix()
|
||||
#expect(base == "alice")
|
||||
#expect(suffix == "#a1b2")
|
||||
}
|
||||
|
||||
@Test func hasVeryLongToken_noLongToken() {
|
||||
let content = "Short words only here"
|
||||
#expect(!content.hasVeryLongToken(threshold: 50))
|
||||
}
|
||||
|
||||
@Test func hasVeryLongToken_withLongToken() {
|
||||
let longToken = String(repeating: "a", count: 100)
|
||||
let content = "Here is a \(longToken) token"
|
||||
#expect(content.hasVeryLongToken(threshold: 50))
|
||||
}
|
||||
|
||||
@Test func hasVeryLongToken_exactThreshold() {
|
||||
let exactToken = String(repeating: "a", count: 50)
|
||||
let content = "Token: \(exactToken)"
|
||||
// Exactly at threshold DOES trigger (uses >= comparison)
|
||||
#expect(content.hasVeryLongToken(threshold: 50))
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import Foundation
|
||||
|
||||
final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private var blockedFingerprints: Set<String> = []
|
||||
private var blockedNostrPubkeys: Set<String> = []
|
||||
private var socialIdentities: [String: SocialIdentity] = [:]
|
||||
|
||||
init(_ keychain: KeychainManagerProtocol) {
|
||||
self.keychain = keychain
|
||||
@@ -23,7 +26,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
func forceSave() {}
|
||||
|
||||
func getSocialIdentity(for fingerprint: String) -> SocialIdentity? {
|
||||
nil
|
||||
socialIdentities[fingerprint]
|
||||
}
|
||||
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {}
|
||||
@@ -32,7 +35,14 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
[]
|
||||
}
|
||||
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {}
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||
socialIdentities[identity.fingerprint] = identity
|
||||
if identity.isBlocked {
|
||||
blockedFingerprints.insert(identity.fingerprint)
|
||||
} else {
|
||||
blockedFingerprints.remove(identity.fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func getFavorites() -> Set<String> {
|
||||
Set()
|
||||
@@ -45,19 +55,46 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func isBlocked(fingerprint: String) -> Bool {
|
||||
false
|
||||
blockedFingerprints.contains(fingerprint) || socialIdentities[fingerprint]?.isBlocked == true
|
||||
}
|
||||
|
||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {}
|
||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
||||
if var identity = socialIdentities[fingerprint] {
|
||||
identity.isBlocked = isBlocked
|
||||
socialIdentities[fingerprint] = identity
|
||||
} else {
|
||||
let identity = SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: "",
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: isBlocked,
|
||||
notes: nil
|
||||
)
|
||||
socialIdentities[fingerprint] = identity
|
||||
}
|
||||
if isBlocked {
|
||||
blockedFingerprints.insert(fingerprint)
|
||||
} else {
|
||||
blockedFingerprints.remove(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
|
||||
true
|
||||
blockedNostrPubkeys.contains(pubkeyHexLowercased)
|
||||
}
|
||||
|
||||
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {}
|
||||
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
||||
if isBlocked {
|
||||
blockedNostrPubkeys.insert(pubkeyHexLowercased)
|
||||
} else {
|
||||
blockedNostrPubkeys.remove(pubkeyHexLowercased)
|
||||
}
|
||||
}
|
||||
|
||||
func getBlockedNostrPubkeys() -> Set<String> {
|
||||
Set()
|
||||
blockedNostrPubkeys
|
||||
}
|
||||
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}
|
||||
|
||||
@@ -11,54 +11,190 @@ import Foundation
|
||||
|
||||
final class MockKeychain: KeychainManagerProtocol {
|
||||
private var storage: [String: Data] = [:]
|
||||
|
||||
private var serviceStorage: [String: [String: Data]] = [:]
|
||||
|
||||
// BCH-01-009: Configurable error simulation for testing
|
||||
var simulatedReadError: KeychainReadResult?
|
||||
var simulatedSaveError: KeychainSaveResult?
|
||||
|
||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||
storage[key] = keyData
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
func getIdentityKey(forKey key: String) -> Data? {
|
||||
storage[key]
|
||||
}
|
||||
|
||||
|
||||
func deleteIdentityKey(forKey key: String) -> Bool {
|
||||
storage.removeValue(forKey: key)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
func deleteAllKeychainData() -> Bool {
|
||||
storage.removeAll()
|
||||
serviceStorage.removeAll()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
func secureClear(_ data: inout Data) {
|
||||
//
|
||||
data = Data()
|
||||
}
|
||||
|
||||
|
||||
func secureClear(_ string: inout String) {
|
||||
string = ""
|
||||
}
|
||||
|
||||
|
||||
func verifyIdentityKeyExists() -> Bool {
|
||||
storage["identity_noiseStaticKey"] != nil
|
||||
}
|
||||
|
||||
// BCH-01-009: New methods with proper error classification
|
||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
||||
if let simulated = simulatedReadError {
|
||||
return simulated
|
||||
}
|
||||
if let data = storage[key] {
|
||||
return .success(data)
|
||||
}
|
||||
return .itemNotFound
|
||||
}
|
||||
|
||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
||||
if let simulated = simulatedSaveError {
|
||||
return simulated
|
||||
}
|
||||
storage[key] = keyData
|
||||
return .success
|
||||
}
|
||||
|
||||
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
||||
|
||||
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
||||
if serviceStorage[service] == nil {
|
||||
serviceStorage[service] = [:]
|
||||
}
|
||||
serviceStorage[service]?[key] = data
|
||||
}
|
||||
|
||||
func load(key: String, service: String) -> Data? {
|
||||
serviceStorage[service]?[key]
|
||||
}
|
||||
|
||||
func delete(key: String, service: String) {
|
||||
serviceStorage[service]?.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
final class MockKeychainHelper: KeychainHelperProtocol {
|
||||
private typealias Service = String
|
||||
private typealias Key = String
|
||||
private var storage: [Service: [Key: Data]] = [:]
|
||||
|
||||
/// Typealias for backwards compatibility with tests using MockKeychainHelper
|
||||
typealias MockKeychainHelper = MockKeychain
|
||||
|
||||
/// Mock keychain that tracks secureClear calls for testing DH secret clearing
|
||||
final class TrackingMockKeychain: KeychainManagerProtocol {
|
||||
private var storage: [String: Data] = [:]
|
||||
private var serviceStorage: [String: [String: Data]] = [:]
|
||||
|
||||
/// Thread-safe counter for secureClear calls
|
||||
private let lock = NSLock()
|
||||
private var _secureClearDataCallCount = 0
|
||||
private var _secureClearStringCallCount = 0
|
||||
|
||||
// BCH-01-009: Configurable error simulation for testing
|
||||
var simulatedReadError: KeychainReadResult?
|
||||
var simulatedSaveError: KeychainSaveResult?
|
||||
|
||||
var secureClearDataCallCount: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _secureClearDataCallCount
|
||||
}
|
||||
|
||||
var secureClearStringCallCount: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _secureClearStringCallCount
|
||||
}
|
||||
|
||||
var totalSecureClearCallCount: Int {
|
||||
return secureClearDataCallCount + secureClearStringCallCount
|
||||
}
|
||||
|
||||
func resetCounts() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
_secureClearDataCallCount = 0
|
||||
_secureClearStringCallCount = 0
|
||||
}
|
||||
|
||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||
storage[key] = keyData
|
||||
return true
|
||||
}
|
||||
|
||||
func getIdentityKey(forKey key: String) -> Data? {
|
||||
storage[key]
|
||||
}
|
||||
|
||||
func deleteIdentityKey(forKey key: String) -> Bool {
|
||||
storage.removeValue(forKey: key)
|
||||
return true
|
||||
}
|
||||
|
||||
func deleteAllKeychainData() -> Bool {
|
||||
storage.removeAll()
|
||||
serviceStorage.removeAll()
|
||||
return true
|
||||
}
|
||||
|
||||
func secureClear(_ data: inout Data) {
|
||||
lock.lock()
|
||||
_secureClearDataCallCount += 1
|
||||
lock.unlock()
|
||||
data = Data()
|
||||
}
|
||||
|
||||
func secureClear(_ string: inout String) {
|
||||
lock.lock()
|
||||
_secureClearStringCallCount += 1
|
||||
lock.unlock()
|
||||
string = ""
|
||||
}
|
||||
|
||||
func verifyIdentityKeyExists() -> Bool {
|
||||
storage["identity_noiseStaticKey"] != nil
|
||||
}
|
||||
|
||||
// BCH-01-009: New methods with proper error classification
|
||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
||||
if let simulated = simulatedReadError {
|
||||
return simulated
|
||||
}
|
||||
if let data = storage[key] {
|
||||
return .success(data)
|
||||
}
|
||||
return .itemNotFound
|
||||
}
|
||||
|
||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
||||
if let simulated = simulatedSaveError {
|
||||
return simulated
|
||||
}
|
||||
storage[key] = keyData
|
||||
return .success
|
||||
}
|
||||
|
||||
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
||||
storage[service]?[key] = data
|
||||
if serviceStorage[service] == nil {
|
||||
serviceStorage[service] = [:]
|
||||
}
|
||||
serviceStorage[service]?[key] = data
|
||||
}
|
||||
|
||||
|
||||
func load(key: String, service: String) -> Data? {
|
||||
storage[service]?[key]
|
||||
serviceStorage[service]?[key]
|
||||
}
|
||||
|
||||
|
||||
func delete(key: String, service: String) {
|
||||
storage[service]?.removeValue(forKey: key)
|
||||
serviceStorage[service]?.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
//
|
||||
// MockTransport.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Mock Transport implementation for unit testing ChatViewModel.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreBluetooth
|
||||
@testable import bitchat
|
||||
|
||||
/// Mock Transport implementation for testing ChatViewModel in isolation.
|
||||
/// Records all method calls and allows test code to verify interactions.
|
||||
final class MockTransport: Transport {
|
||||
|
||||
// MARK: - Protocol Properties
|
||||
|
||||
weak var delegate: BitchatDelegate?
|
||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||
|
||||
var myPeerID: PeerID = PeerID(str: "TESTPEER")
|
||||
var myNickname: String = "TestUser"
|
||||
|
||||
private let peerSnapshotSubject = CurrentValueSubject<[TransportPeerSnapshot], Never>([])
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||
peerSnapshotSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: - Recording Properties (for test assertions)
|
||||
|
||||
private(set) var sentMessages: [(content: String, mentions: [String], messageID: String?, timestamp: Date?)] = []
|
||||
private(set) var sentPrivateMessages: [(content: String, peerID: PeerID, recipientNickname: String, messageID: String)] = []
|
||||
private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = []
|
||||
private(set) var sentDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
||||
private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
|
||||
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
private(set) var startServicesCallCount = 0
|
||||
private(set) var stopServicesCallCount = 0
|
||||
private(set) var emergencyDisconnectCallCount = 0
|
||||
private(set) var broadcastAnnounceCallCount = 0
|
||||
private(set) var triggeredHandshakes: [PeerID] = []
|
||||
|
||||
// MARK: - Configurable Mock State
|
||||
|
||||
var connectedPeers: Set<PeerID> = []
|
||||
var reachablePeers: Set<PeerID> = []
|
||||
var peerNicknames: [PeerID: String] = [:]
|
||||
var peerFingerprints: [PeerID: String] = [:]
|
||||
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
||||
private let mockKeychain = MockKeychain()
|
||||
|
||||
// MARK: - Transport Protocol Implementation
|
||||
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] {
|
||||
peerSnapshotSubject.value
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String) {
|
||||
myNickname = nickname
|
||||
}
|
||||
|
||||
func startServices() {
|
||||
startServicesCallCount += 1
|
||||
}
|
||||
|
||||
func stopServices() {
|
||||
stopServicesCallCount += 1
|
||||
}
|
||||
|
||||
func emergencyDisconnectAll() {
|
||||
emergencyDisconnectCallCount += 1
|
||||
connectedPeers.removeAll()
|
||||
reachablePeers.removeAll()
|
||||
}
|
||||
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool {
|
||||
connectedPeers.contains(peerID)
|
||||
}
|
||||
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
||||
reachablePeers.contains(peerID) || connectedPeers.contains(peerID)
|
||||
}
|
||||
|
||||
func peerNickname(peerID: PeerID) -> String? {
|
||||
peerNicknames[peerID]
|
||||
}
|
||||
|
||||
func getPeerNicknames() -> [PeerID: String] {
|
||||
peerNicknames
|
||||
}
|
||||
|
||||
func getFingerprint(for peerID: PeerID) -> String? {
|
||||
peerFingerprints[peerID]
|
||||
}
|
||||
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState {
|
||||
peerNoiseStates[peerID] ?? .none
|
||||
}
|
||||
|
||||
func triggerHandshake(with peerID: PeerID) {
|
||||
triggeredHandshakes.append(peerID)
|
||||
}
|
||||
|
||||
func getNoiseService() -> NoiseEncryptionService {
|
||||
NoiseEncryptionService(keychain: mockKeychain)
|
||||
}
|
||||
|
||||
// MARK: - Messaging
|
||||
|
||||
func sendMessage(_ content: String, mentions: [String]) {
|
||||
sentMessages.append((content, mentions, nil, nil))
|
||||
}
|
||||
|
||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||
sentMessages.append((content, mentions, messageID, timestamp))
|
||||
}
|
||||
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
sentPrivateMessages.append((content, peerID, recipientNickname, messageID))
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
sentReadReceipts.append((receipt, peerID))
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
sentFavoriteNotifications.append((peerID, isFavorite))
|
||||
}
|
||||
|
||||
func sendBroadcastAnnounce() {
|
||||
broadcastAnnounceCallCount += 1
|
||||
}
|
||||
|
||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||
sentDeliveryAcks.append((messageID, peerID))
|
||||
}
|
||||
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||
// Not tracked for current tests
|
||||
}
|
||||
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||
// Not tracked for current tests
|
||||
}
|
||||
|
||||
func cancelTransfer(_ transferId: String) {
|
||||
// Not tracked for current tests
|
||||
}
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||
sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA))
|
||||
}
|
||||
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||
sentVerifyResponses.append((peerID, noiseKeyHex, nonceA))
|
||||
}
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
/// Clears all recorded method calls for fresh assertions
|
||||
func resetRecordings() {
|
||||
sentMessages.removeAll()
|
||||
sentPrivateMessages.removeAll()
|
||||
sentReadReceipts.removeAll()
|
||||
sentDeliveryAcks.removeAll()
|
||||
sentFavoriteNotifications.removeAll()
|
||||
sentVerifyChallenges.removeAll()
|
||||
sentVerifyResponses.removeAll()
|
||||
startServicesCallCount = 0
|
||||
stopServicesCallCount = 0
|
||||
emergencyDisconnectCallCount = 0
|
||||
broadcastAnnounceCallCount = 0
|
||||
triggeredHandshakes.removeAll()
|
||||
}
|
||||
|
||||
/// Simulates a peer connecting
|
||||
func simulateConnect(_ peerID: PeerID, nickname: String? = nil) {
|
||||
connectedPeers.insert(peerID)
|
||||
if let nickname = nickname {
|
||||
peerNicknames[peerID] = nickname
|
||||
}
|
||||
delegate?.didConnectToPeer(peerID)
|
||||
delegate?.didUpdatePeerList(Array(connectedPeers))
|
||||
publishPeerSnapshots()
|
||||
}
|
||||
|
||||
/// Simulates a peer disconnecting
|
||||
func simulateDisconnect(_ peerID: PeerID) {
|
||||
connectedPeers.remove(peerID)
|
||||
peerNicknames.removeValue(forKey: peerID)
|
||||
delegate?.didDisconnectFromPeer(peerID)
|
||||
delegate?.didUpdatePeerList(Array(connectedPeers))
|
||||
publishPeerSnapshots()
|
||||
}
|
||||
|
||||
/// Simulates receiving a message
|
||||
func simulateIncomingMessage(_ message: BitchatMessage) {
|
||||
delegate?.didReceiveMessage(message)
|
||||
}
|
||||
|
||||
/// Simulates receiving a public message
|
||||
func simulateIncomingPublicMessage(
|
||||
from peerID: PeerID,
|
||||
nickname: String,
|
||||
content: String,
|
||||
timestamp: Date = Date(),
|
||||
messageID: String? = nil
|
||||
) {
|
||||
delegate?.didReceivePublicMessage(
|
||||
from: peerID,
|
||||
nickname: nickname,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
messageID: messageID
|
||||
)
|
||||
}
|
||||
|
||||
/// Simulates Bluetooth state change
|
||||
func simulateBluetoothStateChange(_ state: CBManagerState) {
|
||||
delegate?.didUpdateBluetoothState(state)
|
||||
}
|
||||
|
||||
/// Updates the peer snapshot publisher
|
||||
func updatePeerSnapshots(_ snapshots: [TransportPeerSnapshot]) {
|
||||
peerSnapshotSubject.send(snapshots)
|
||||
Task { @MainActor [weak self] in
|
||||
self?.peerEventsDelegate?.didUpdatePeerSnapshots(snapshots)
|
||||
}
|
||||
}
|
||||
|
||||
private func publishPeerSnapshots() {
|
||||
let now = Date()
|
||||
let snapshots = connectedPeers.map { peerID in
|
||||
TransportPeerSnapshot(
|
||||
peerID: peerID,
|
||||
nickname: peerNicknames[peerID] ?? "",
|
||||
isConnected: true,
|
||||
noisePublicKey: Data(hexString: peerID.bare),
|
||||
lastSeen: now
|
||||
)
|
||||
}
|
||||
updatePeerSnapshots(snapshots)
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,53 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - Test Vector Support
|
||||
|
||||
struct NoiseTestVector: Codable {
|
||||
let protocol_name: String
|
||||
let init_prologue: String
|
||||
let init_static: String
|
||||
let init_ephemeral: String
|
||||
let init_psks: [String]?
|
||||
let resp_prologue: String
|
||||
let resp_static: String
|
||||
let resp_ephemeral: String
|
||||
let resp_psks: [String]?
|
||||
let handshake_hash: String?
|
||||
let messages: [TestMessage]
|
||||
|
||||
struct TestMessage: Codable {
|
||||
let payload: String
|
||||
let ciphertext: String
|
||||
}
|
||||
}
|
||||
|
||||
extension Data {
|
||||
init?(hex: String) {
|
||||
let cleaned = hex.replacingOccurrences(of: " ", with: "")
|
||||
guard cleaned.count % 2 == 0 else { return nil }
|
||||
var data = Data(capacity: cleaned.count / 2)
|
||||
var index = cleaned.startIndex
|
||||
while index < cleaned.endIndex {
|
||||
let nextIndex = cleaned.index(index, offsetBy: 2)
|
||||
guard let byte = UInt8(cleaned[index..<nextIndex], radix: 16) else { return nil }
|
||||
data.append(byte)
|
||||
index = nextIndex
|
||||
}
|
||||
self = data
|
||||
}
|
||||
|
||||
func hexString() -> String {
|
||||
map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
struct NoiseProtocolTests {
|
||||
|
||||
private let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
@@ -61,7 +103,7 @@ struct NoiseProtocolTests {
|
||||
|
||||
// Bob processes message 3 and completes handshake
|
||||
let finalMessage = try bobSession.processHandshakeMessage(message3!)
|
||||
#expect(finalMessage == nil) // No more messages needed
|
||||
#expect(finalMessage == nil) // No more messages needed
|
||||
#expect(bobSession.getState() == .established)
|
||||
|
||||
// Verify both sessions are established
|
||||
@@ -69,8 +111,12 @@ struct NoiseProtocolTests {
|
||||
#expect(bobSession.isEstablished())
|
||||
|
||||
// Verify they have each other's static keys
|
||||
#expect(aliceSession.getRemoteStaticPublicKey()?.rawRepresentation == bobKey.publicKey.rawRepresentation)
|
||||
#expect(bobSession.getRemoteStaticPublicKey()?.rawRepresentation == aliceKey.publicKey.rawRepresentation)
|
||||
#expect(
|
||||
aliceSession.getRemoteStaticPublicKey()?.rawRepresentation
|
||||
== bobKey.publicKey.rawRepresentation)
|
||||
#expect(
|
||||
bobSession.getRemoteStaticPublicKey()?.rawRepresentation
|
||||
== aliceKey.publicKey.rawRepresentation)
|
||||
}
|
||||
|
||||
@Test func handshakeStateValidation() throws {
|
||||
@@ -98,7 +144,7 @@ struct NoiseProtocolTests {
|
||||
// Alice encrypts
|
||||
let ciphertext = try aliceSession.encrypt(plaintext)
|
||||
#expect(ciphertext != plaintext)
|
||||
#expect(ciphertext.count > plaintext.count) // Should have overhead
|
||||
#expect(ciphertext.count > plaintext.count) // Should have overhead
|
||||
|
||||
// Bob decrypts
|
||||
let decrypted = try bobSession.decrypt(ciphertext)
|
||||
@@ -150,16 +196,16 @@ struct NoiseProtocolTests {
|
||||
|
||||
@Test func sessionManagerBasicOperations() throws {
|
||||
let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
|
||||
|
||||
#expect(manager.getSession(for: alicePeerID) == nil)
|
||||
|
||||
|
||||
_ = try manager.initiateHandshake(with: alicePeerID)
|
||||
#expect(manager.getSession(for: alicePeerID) != nil)
|
||||
|
||||
|
||||
// Get session
|
||||
let retrieved = manager.getSession(for: alicePeerID)
|
||||
#expect(retrieved != nil)
|
||||
|
||||
|
||||
// Remove session
|
||||
manager.removeSession(for: alicePeerID)
|
||||
#expect(manager.getSession(for: alicePeerID) == nil)
|
||||
@@ -190,11 +236,13 @@ struct NoiseProtocolTests {
|
||||
#expect(message2 != nil)
|
||||
|
||||
// Continue handshake
|
||||
let message3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message2!)
|
||||
let message3 = try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID, message: message2!)
|
||||
#expect(message3 != nil)
|
||||
|
||||
// Complete handshake
|
||||
let finalMessage = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message3!)
|
||||
let finalMessage = try bobManager.handleIncomingHandshake(
|
||||
from: bobPeerID, message: message3!)
|
||||
#expect(finalMessage == nil)
|
||||
|
||||
// Both should have established sessions
|
||||
@@ -258,11 +306,19 @@ struct NoiseProtocolTests {
|
||||
|
||||
@Test func sessionIsolation() throws {
|
||||
// Create two separate session pairs
|
||||
let aliceSession1 = NoiseSession(peerID: PeerID(str: "peer1"), role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
|
||||
let bobSession1 = NoiseSession(peerID: PeerID(str: "alice1"), role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
|
||||
let aliceSession1 = NoiseSession(
|
||||
peerID: PeerID(str: "peer1"), role: .initiator, keychain: mockKeychain,
|
||||
localStaticKey: aliceKey)
|
||||
let bobSession1 = NoiseSession(
|
||||
peerID: PeerID(str: "alice1"), role: .responder, keychain: mockKeychain,
|
||||
localStaticKey: bobKey)
|
||||
|
||||
let aliceSession2 = NoiseSession(peerID: PeerID(str: "peer2"), role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
|
||||
let bobSession2 = NoiseSession(peerID: PeerID(str: "alice2"), role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
|
||||
let aliceSession2 = NoiseSession(
|
||||
peerID: PeerID(str: "peer2"), role: .initiator, keychain: mockKeychain,
|
||||
localStaticKey: aliceKey)
|
||||
let bobSession2 = NoiseSession(
|
||||
peerID: PeerID(str: "alice2"), role: .responder, keychain: mockKeychain,
|
||||
localStaticKey: bobKey)
|
||||
|
||||
// Establish both pairs
|
||||
try performHandshake(initiator: aliceSession1, responder: bobSession1)
|
||||
@@ -305,17 +361,20 @@ struct NoiseProtocolTests {
|
||||
_ = try aliceManager.decrypt(message2, from: alicePeerID)
|
||||
|
||||
// Simulate Bob restart by creating new manager with same key
|
||||
let bobManagerRestarted = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
let bobManagerRestarted = NoiseSessionManager(
|
||||
localStaticKey: bobKey, keychain: mockKeychain)
|
||||
|
||||
// Bob initiates new handshake after restart
|
||||
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID)
|
||||
|
||||
// Alice should accept the new handshake (clearing old session)
|
||||
let newHandshake2 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake1)
|
||||
let newHandshake2 = try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID, message: newHandshake1)
|
||||
#expect(newHandshake2 != nil)
|
||||
|
||||
// Complete the new handshake
|
||||
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(from: bobPeerID, message: newHandshake2!)
|
||||
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(
|
||||
from: bobPeerID, message: newHandshake2!)
|
||||
#expect(newHandshake3 != nil)
|
||||
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
|
||||
|
||||
@@ -328,8 +387,10 @@ struct NoiseProtocolTests {
|
||||
|
||||
@Test func nonceDesynchronizationRecovery() throws {
|
||||
// Create two sessions
|
||||
let aliceSession = NoiseSession(peerID: alicePeerID, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
|
||||
let bobSession = NoiseSession(peerID: bobPeerID, role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
|
||||
let aliceSession = NoiseSession(
|
||||
peerID: alicePeerID, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
|
||||
let bobSession = NoiseSession(
|
||||
peerID: bobPeerID, role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
|
||||
|
||||
// Establish sessions
|
||||
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||
@@ -361,7 +422,8 @@ struct NoiseProtocolTests {
|
||||
|
||||
let messageCount = 100
|
||||
|
||||
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) { completion in
|
||||
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount)
|
||||
{ completion in
|
||||
var encryptedMessages: [Int: Data] = [:]
|
||||
// Encrypt messages sequentially to avoid nonce races in manager
|
||||
for i in 0..<messageCount {
|
||||
@@ -412,7 +474,7 @@ struct NoiseProtocolTests {
|
||||
|
||||
// Create a corrupted message
|
||||
var encrypted = try aliceManager.encrypt("Test".data(using: .utf8)!, for: alicePeerID)
|
||||
encrypted[10] ^= 0xFF // Corrupt the data
|
||||
encrypted[10] ^= 0xFF // Corrupt the data
|
||||
|
||||
// Decryption should fail
|
||||
if #available(macOS 14.4, iOS 17.4, *) {
|
||||
@@ -454,11 +516,13 @@ struct NoiseProtocolTests {
|
||||
let newHandshake1 = try aliceManager.initiateHandshake(with: alicePeerID)
|
||||
|
||||
// Bob should accept the new handshake even though he has a valid session
|
||||
let newHandshake2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake1)
|
||||
let newHandshake2 = try bobManager.handleIncomingHandshake(
|
||||
from: bobPeerID, message: newHandshake1)
|
||||
#expect(newHandshake2 != nil, "Bob should accept handshake despite having valid session")
|
||||
|
||||
// Complete the handshake
|
||||
let newHandshake3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake2!)
|
||||
let newHandshake3 = try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID, message: newHandshake2!)
|
||||
#expect(newHandshake3 != nil)
|
||||
_ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!)
|
||||
|
||||
@@ -489,7 +553,8 @@ struct NoiseProtocolTests {
|
||||
}
|
||||
|
||||
// With nonce carried in packet, decryption should not throw here
|
||||
let desyncMessage = try aliceManager.encrypt("This now succeeds".data(using: .utf8)!, for: alicePeerID)
|
||||
let desyncMessage = try aliceManager.encrypt(
|
||||
"This now succeeds".data(using: .utf8)!, for: alicePeerID)
|
||||
#expect(throws: Never.self) {
|
||||
try bobManager.decrypt(desyncMessage, from: bobPeerID)
|
||||
}
|
||||
@@ -499,11 +564,13 @@ struct NoiseProtocolTests {
|
||||
let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID)
|
||||
|
||||
// Alice should accept despite having a "valid" (but desynced) session
|
||||
let rehandshake2 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake1)
|
||||
let rehandshake2 = try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID, message: rehandshake1)
|
||||
#expect(rehandshake2 != nil, "Alice should accept handshake to fix desync")
|
||||
|
||||
// Complete handshake
|
||||
let rehandshake3 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: rehandshake2!)
|
||||
let rehandshake3 = try bobManager.handleIncomingHandshake(
|
||||
from: bobPeerID, message: rehandshake2!)
|
||||
#expect(rehandshake3 != nil)
|
||||
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
|
||||
|
||||
@@ -514,6 +581,18 @@ struct NoiseProtocolTests {
|
||||
#expect(decryptedResync == testResynced)
|
||||
}
|
||||
|
||||
// MARK: - Test Vector Tests
|
||||
|
||||
@Test func noiseTestVectors() throws {
|
||||
// Load test vectors from bundle
|
||||
let testVectors = try loadTestVectors()
|
||||
|
||||
for (index, testVector) in testVectors.enumerated() {
|
||||
print("Running test vector \(index + 1): \(testVector.protocol_name)")
|
||||
try runTestVector(testVector)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func performHandshake(initiator: NoiseSession, responder: NoiseSession) throws {
|
||||
@@ -523,10 +602,346 @@ struct NoiseProtocolTests {
|
||||
_ = try responder.processHandshakeMessage(msg3)
|
||||
}
|
||||
|
||||
private func establishManagerSessions(aliceManager: NoiseSessionManager, bobManager: NoiseSessionManager) throws {
|
||||
private func establishManagerSessions(
|
||||
aliceManager: NoiseSessionManager, bobManager: NoiseSessionManager
|
||||
) throws {
|
||||
let msg1 = try aliceManager.initiateHandshake(with: alicePeerID)
|
||||
let msg2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg1)!
|
||||
let msg3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: msg2)!
|
||||
_ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg3)
|
||||
}
|
||||
|
||||
private func loadTestVectors() throws -> [NoiseTestVector] {
|
||||
// Try to load from test bundle
|
||||
let testBundle = Bundle(for: MockKeychain.self)
|
||||
guard let url = testBundle.url(forResource: "NoiseTestVectors", withExtension: "json")
|
||||
else {
|
||||
throw NSError(
|
||||
domain: "NoiseTests", code: 1,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Could not find NoiseTestVectors.json in test bundle"
|
||||
])
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
return try JSONDecoder().decode([NoiseTestVector].self, from: data)
|
||||
}
|
||||
|
||||
private func runTestVector(_ testVector: NoiseTestVector) throws {
|
||||
// Parse test inputs
|
||||
guard let initStatic = Data(hex: testVector.init_static),
|
||||
let initEphemeral = Data(hex: testVector.init_ephemeral),
|
||||
let respStatic = Data(hex: testVector.resp_static),
|
||||
let respEphemeral = Data(hex: testVector.resp_ephemeral),
|
||||
let prologue = Data(hex: testVector.init_prologue)
|
||||
else {
|
||||
throw NSError(
|
||||
domain: "NoiseTests", code: 2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Failed to parse test vector hex strings"])
|
||||
}
|
||||
|
||||
let expectedHash = testVector.handshake_hash.flatMap { Data(hex: $0) }
|
||||
|
||||
// Create keys
|
||||
guard
|
||||
let initStaticKey = try? Curve25519.KeyAgreement.PrivateKey(
|
||||
rawRepresentation: initStatic),
|
||||
let initEphemeralKey = try? Curve25519.KeyAgreement.PrivateKey(
|
||||
rawRepresentation: initEphemeral),
|
||||
let respStaticKey = try? Curve25519.KeyAgreement.PrivateKey(
|
||||
rawRepresentation: respStatic),
|
||||
let respEphemeralKey = try? Curve25519.KeyAgreement.PrivateKey(
|
||||
rawRepresentation: respEphemeral)
|
||||
else {
|
||||
throw NSError(
|
||||
domain: "NoiseTests", code: 3,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Failed to create keys from test vectors"])
|
||||
}
|
||||
|
||||
let keychain = MockKeychain()
|
||||
|
||||
// Create handshake states
|
||||
let initiatorHandshake = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: initStaticKey,
|
||||
prologue: prologue,
|
||||
predeterminedEphemeralKey: initEphemeralKey
|
||||
)
|
||||
|
||||
let responderHandshake = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: respStaticKey,
|
||||
prologue: prologue,
|
||||
predeterminedEphemeralKey: respEphemeralKey
|
||||
)
|
||||
|
||||
// For XX pattern, we have 3 handshake messages, then transport messages
|
||||
// The test vector messages are ordered as: [msg1, msg2, msg3, transport1, transport2, ...]
|
||||
|
||||
guard testVector.messages.count >= 3 else {
|
||||
throw NSError(
|
||||
domain: "NoiseTests", code: 5,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Test vector must have at least 3 messages for XX pattern"])
|
||||
}
|
||||
|
||||
// Message 1: Initiator -> Responder (e)
|
||||
guard let payload1 = Data(hex: testVector.messages[0].payload),
|
||||
let expectedCiphertext1 = Data(hex: testVector.messages[0].ciphertext) else {
|
||||
throw NSError(
|
||||
domain: "NoiseTests", code: 4,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Message 1: Failed to parse hex"])
|
||||
}
|
||||
|
||||
let msg1 = try initiatorHandshake.writeMessage(payload: payload1)
|
||||
#expect(!msg1.isEmpty, "Message 1 should not be empty")
|
||||
#expect(msg1 == expectedCiphertext1, "Message 1 ciphertext should match expected value. Got: \(msg1.hexString()), Expected: \(expectedCiphertext1.hexString())")
|
||||
|
||||
let decrypted1 = try responderHandshake.readMessage(msg1)
|
||||
#expect(decrypted1 == payload1, "Message 1: Decrypted payload should match original")
|
||||
|
||||
// Message 2: Responder -> Initiator (e, ee, s, es)
|
||||
guard let payload2 = Data(hex: testVector.messages[1].payload),
|
||||
let expectedCiphertext2 = Data(hex: testVector.messages[1].ciphertext) else {
|
||||
throw NSError(
|
||||
domain: "NoiseTests", code: 4,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Message 2: Failed to parse hex"])
|
||||
}
|
||||
|
||||
let msg2 = try responderHandshake.writeMessage(payload: payload2)
|
||||
#expect(!msg2.isEmpty, "Message 2 should not be empty")
|
||||
#expect(msg2 == expectedCiphertext2, "Message 2 ciphertext should match expected value. Got: \(msg2.hexString()), Expected: \(expectedCiphertext2.hexString())")
|
||||
|
||||
let decrypted2 = try initiatorHandshake.readMessage(msg2)
|
||||
#expect(decrypted2 == payload2, "Message 2: Decrypted payload should match original")
|
||||
|
||||
// Message 3: Initiator -> Responder (s, se)
|
||||
guard let payload3 = Data(hex: testVector.messages[2].payload),
|
||||
let expectedCiphertext3 = Data(hex: testVector.messages[2].ciphertext) else {
|
||||
throw NSError(
|
||||
domain: "NoiseTests", code: 4,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Message 3: Failed to parse hex"])
|
||||
}
|
||||
|
||||
let msg3 = try initiatorHandshake.writeMessage(payload: payload3)
|
||||
#expect(!msg3.isEmpty, "Message 3 should not be empty")
|
||||
#expect(msg3 == expectedCiphertext3, "Message 3 ciphertext should match expected value. Got: \(msg3.hexString()), Expected: \(expectedCiphertext3.hexString())")
|
||||
|
||||
let decrypted3 = try responderHandshake.readMessage(msg3)
|
||||
#expect(decrypted3 == payload3, "Message 3: Decrypted payload should match original")
|
||||
|
||||
// Verify handshake hash
|
||||
let initiatorHash = initiatorHandshake.getHandshakeHash()
|
||||
let responderHash = responderHandshake.getHandshakeHash()
|
||||
|
||||
#expect(initiatorHash == responderHash, "Initiator and responder hashes should match")
|
||||
|
||||
if let expectedHash = expectedHash {
|
||||
#expect(
|
||||
initiatorHash == expectedHash,
|
||||
"Handshake hash should match expected value from test vector. Got: \(initiatorHash.hexString()), Expected: \(expectedHash.hexString())")
|
||||
}
|
||||
|
||||
// Get transport ciphers
|
||||
let (initSend, initRecv, _) = try initiatorHandshake.getTransportCiphers(useExtractedNonce: false)
|
||||
let (respSend, respRecv, _) = try responderHandshake.getTransportCiphers(useExtractedNonce: false)
|
||||
|
||||
// Test transport messages (messages after the 3 handshake messages)
|
||||
for index in 3..<testVector.messages.count {
|
||||
let testMsg = testVector.messages[index]
|
||||
guard let payload = Data(hex: testMsg.payload),
|
||||
let expectedCiphertext = Data(hex: testMsg.ciphertext) else {
|
||||
throw NSError(
|
||||
domain: "NoiseTests", code: 4,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey:
|
||||
"Message \(index + 1): Failed to parse payload hex"
|
||||
])
|
||||
}
|
||||
|
||||
// Alternate between responder and initiator sending
|
||||
// Responder sends first transport message (since initiator sent last handshake message)
|
||||
let (sender, receiver): (NoiseCipherState, NoiseCipherState)
|
||||
let transportIndex = index - 3
|
||||
if transportIndex % 2 == 0 {
|
||||
// Even transport messages: responder sends
|
||||
sender = respSend
|
||||
receiver = initRecv
|
||||
} else {
|
||||
// Odd transport messages: initiator sends
|
||||
sender = initSend
|
||||
receiver = respRecv
|
||||
}
|
||||
|
||||
// Encrypt and validate ciphertext matches expected value
|
||||
let ciphertext = try sender.encrypt(plaintext: payload)
|
||||
#expect(
|
||||
ciphertext == expectedCiphertext,
|
||||
"Message \(index + 1) ciphertext should match expected value. Got: \(ciphertext.hexString()), Expected: \(expectedCiphertext.hexString())")
|
||||
|
||||
// Decrypt and validate payload
|
||||
let decrypted = try receiver.decrypt(ciphertext: ciphertext)
|
||||
#expect(
|
||||
decrypted == payload,
|
||||
"Message \(index + 1): Decrypted payload should match original")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DH Shared Secret Clearing Tests
|
||||
|
||||
@Test func secureClearCalledDuringHandshake() throws {
|
||||
// Use TrackingMockKeychain to verify secureClear is called
|
||||
let trackingKeychain = TrackingMockKeychain()
|
||||
|
||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
let alice = NoiseSession(
|
||||
peerID: PeerID(str: "alice-test"),
|
||||
role: .initiator,
|
||||
keychain: trackingKeychain,
|
||||
localStaticKey: aliceKey
|
||||
)
|
||||
|
||||
let bob = NoiseSession(
|
||||
peerID: PeerID(str: "bob-test"),
|
||||
role: .responder,
|
||||
keychain: trackingKeychain,
|
||||
localStaticKey: bobKey
|
||||
)
|
||||
|
||||
// Perform handshake
|
||||
let msg1 = try alice.startHandshake()
|
||||
let msg2 = try bob.processHandshakeMessage(msg1)!
|
||||
let msg3 = try alice.processHandshakeMessage(msg2)!
|
||||
_ = try bob.processHandshakeMessage(msg3)
|
||||
|
||||
// In Noise XX pattern handshake:
|
||||
// - Message 1 (initiator): e token only (no DH)
|
||||
// - Message 2 (responder): e, ee, s, es tokens (2 DH operations: ee, es)
|
||||
// - Message 3 (initiator): s, se tokens (1 DH operation: se)
|
||||
// Total in writeMessage: 3 DH operations (ee, es, se)
|
||||
//
|
||||
// In readMessage (performDHOperation):
|
||||
// - After msg1: no DH
|
||||
// - After msg2: ee, es (2 DH operations)
|
||||
// - After msg3: se (1 DH operation)
|
||||
// Total in performDHOperation: 3 DH operations
|
||||
//
|
||||
// Grand total: 6 DH operations requiring secureClear
|
||||
//
|
||||
// Note: .ss pattern is only used in certain handshake patterns, not XX
|
||||
let expectedMinimumCalls = 6
|
||||
#expect(
|
||||
trackingKeychain.secureClearDataCallCount >= expectedMinimumCalls,
|
||||
"Expected at least \(expectedMinimumCalls) secureClear calls for DH secrets, got \(trackingKeychain.secureClearDataCallCount)"
|
||||
)
|
||||
}
|
||||
|
||||
@Test func encryptionWorksAfterSecureClear() throws {
|
||||
// Verify that encryption/decryption still works correctly after adding secureClear
|
||||
let trackingKeychain = TrackingMockKeychain()
|
||||
|
||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
let alice = NoiseSession(
|
||||
peerID: PeerID(str: "alice-test-enc"),
|
||||
role: .initiator,
|
||||
keychain: trackingKeychain,
|
||||
localStaticKey: aliceKey
|
||||
)
|
||||
|
||||
let bob = NoiseSession(
|
||||
peerID: PeerID(str: "bob-test-enc"),
|
||||
role: .responder,
|
||||
keychain: trackingKeychain,
|
||||
localStaticKey: bobKey
|
||||
)
|
||||
|
||||
// Perform handshake
|
||||
let msg1 = try alice.startHandshake()
|
||||
let msg2 = try bob.processHandshakeMessage(msg1)!
|
||||
let msg3 = try alice.processHandshakeMessage(msg2)!
|
||||
_ = try bob.processHandshakeMessage(msg3)
|
||||
|
||||
// Verify both sessions are established
|
||||
#expect(alice.isEstablished())
|
||||
#expect(bob.isEstablished())
|
||||
|
||||
// Verify secureClear was called (basic sanity check)
|
||||
#expect(trackingKeychain.secureClearDataCallCount > 0)
|
||||
|
||||
// Test encryption from Alice to Bob
|
||||
let plaintext1 = "Hello from Alice after secureClear!".data(using: .utf8)!
|
||||
let ciphertext1 = try alice.encrypt(plaintext1)
|
||||
let decrypted1 = try bob.decrypt(ciphertext1)
|
||||
#expect(decrypted1 == plaintext1)
|
||||
|
||||
// Test encryption from Bob to Alice
|
||||
let plaintext2 = "Hello from Bob after secureClear!".data(using: .utf8)!
|
||||
let ciphertext2 = try bob.encrypt(plaintext2)
|
||||
let decrypted2 = try alice.decrypt(ciphertext2)
|
||||
#expect(decrypted2 == plaintext2)
|
||||
|
||||
// Test multiple messages to verify cipher state is correct
|
||||
for i in 1...10 {
|
||||
let msg = "Message \(i) from Alice".data(using: .utf8)!
|
||||
let cipher = try alice.encrypt(msg)
|
||||
let dec = try bob.decrypt(cipher)
|
||||
#expect(dec == msg)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func secureClearCalledInBothWriteAndReadPaths() throws {
|
||||
// Verify secureClear is called in both writeMessage and readMessage paths
|
||||
// We do this by checking the count increases at each step
|
||||
|
||||
let aliceKeychain = TrackingMockKeychain()
|
||||
let bobKeychain = TrackingMockKeychain()
|
||||
|
||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
let alice = NoiseSession(
|
||||
peerID: PeerID(str: "alice-paths"),
|
||||
role: .initiator,
|
||||
keychain: aliceKeychain,
|
||||
localStaticKey: aliceKey
|
||||
)
|
||||
|
||||
let bob = NoiseSession(
|
||||
peerID: PeerID(str: "bob-paths"),
|
||||
role: .responder,
|
||||
keychain: bobKeychain,
|
||||
localStaticKey: bobKey
|
||||
)
|
||||
|
||||
// Message 1: Alice writes (e token only, no DH)
|
||||
let msg1 = try alice.startHandshake()
|
||||
let aliceCountAfterMsg1 = aliceKeychain.secureClearDataCallCount
|
||||
// No DH in message 1 for initiator
|
||||
#expect(aliceCountAfterMsg1 == 0, "No DH secrets in message 1 write")
|
||||
|
||||
// Bob reads message 1 (no DH) and writes message 2 (ee, es DH operations)
|
||||
let msg2 = try bob.processHandshakeMessage(msg1)!
|
||||
let bobCountAfterMsg2 = bobKeychain.secureClearDataCallCount
|
||||
// Bob should have cleared secrets for: ee (read), es (read), ee (write), es (write)
|
||||
#expect(bobCountAfterMsg2 >= 2, "Bob should clear DH secrets when processing/writing message 2")
|
||||
|
||||
// Alice reads message 2 (ee, es) and writes message 3 (se)
|
||||
let msg3 = try alice.processHandshakeMessage(msg2)!
|
||||
let aliceCountAfterMsg3 = aliceKeychain.secureClearDataCallCount
|
||||
// Alice should have cleared: ee (read), es (read), se (write)
|
||||
#expect(aliceCountAfterMsg3 >= 3, "Alice should clear DH secrets when processing/writing message 3")
|
||||
|
||||
// Bob reads message 3 (se)
|
||||
_ = try bob.processHandshakeMessage(msg3)
|
||||
let bobFinalCount = bobKeychain.secureClearDataCallCount
|
||||
// Bob should have additionally cleared: se (read)
|
||||
#expect(bobFinalCount > bobCountAfterMsg2, "Bob should clear DH secrets when processing message 3")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
[
|
||||
{
|
||||
"protocol_name": "Noise_XX_25519_ChaChaPoly_SHA256",
|
||||
"init_prologue": "4a6f686e2047616c74",
|
||||
"init_static": "e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1",
|
||||
"init_ephemeral": "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a",
|
||||
"resp_prologue": "4a6f686e2047616c74",
|
||||
"resp_static": "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893",
|
||||
"resp_ephemeral": "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b",
|
||||
"handshake_hash": "c8e5f64e846193be2a834104c2a009868d6c9f3bd3c186299888b488b2f1f58e",
|
||||
"messages": [
|
||||
{
|
||||
"payload": "4c756477696720766f6e204d69736573",
|
||||
"ciphertext": "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c79444c756477696720766f6e204d69736573"
|
||||
},
|
||||
{
|
||||
"payload": "4d757272617920526f746862617264",
|
||||
"ciphertext": "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f14480884381cbad1f276e038c48378ffce2b65285e08d6b68aaa3629a5a8639392490e5b9bd5269c2f1e4f488ed8831161f19b7815528f8982ffe09be9b5c412f8a0db50f8814c7194e83f23dbd8d162c9326ad"
|
||||
},
|
||||
{
|
||||
"payload": "462e20412e20486179656b",
|
||||
"ciphertext": "c7195ffacac1307ff99046f219750fc47693e23c3cb08b89c2af808b444850a80ae475b9df0f169ae80a89be0865b57f58c9fea0d4ec82a286427402f113e4b6ae769a1d95941d49b25030"
|
||||
},
|
||||
{
|
||||
"payload": "4361726c204d656e676572",
|
||||
"ciphertext": "96763ed773f8e47bb3712f0e29b3060ffc956ffc146cee53d5e1df"
|
||||
},
|
||||
{
|
||||
"payload": "4a65616e2d426170746973746520536179",
|
||||
"ciphertext": "3e40f15f6f3a46ae446b253bf8b1d9ffb6ed9b174d272328ff91a7e2e5c79c07f5"
|
||||
},
|
||||
{
|
||||
"payload": "457567656e2042f6686d20766f6e2042617765726b",
|
||||
"ciphertext": "eb3f3515110702e047a6c9da4478b6ead94873c11c0f2d710ddb3f09fce024b3a58502ae3f"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"protocol_name": "Noise_XX_25519_ChaChaPoly_SHA256",
|
||||
"init_prologue": "5468657265206973206e6f20726967687420616e642077726f6e672e2054686572652773206f6e6c792066756e20616e6420626f72696e672e",
|
||||
"init_psks": [],
|
||||
"init_static": "7dec208517a3b81a2861d7a71266d5d6dc944c5a8816634a86fe63198a0148ee",
|
||||
"init_ephemeral": "a32daf21e93c0131495ce1d903181fde81cc46937daaeb990bae7c992709421e",
|
||||
"resp_prologue": "5468657265206973206e6f20726967687420616e642077726f6e672e2054686572652773206f6e6c792066756e20616e6420626f72696e672e",
|
||||
"resp_psks": [],
|
||||
"resp_static": "4d0aed5098e3b4ef20357e9f686ce66204c792b358da2e475017d6c485304881",
|
||||
"resp_ephemeral": "4eece0f195d026db035ff987597c429d3ad3bcc2944df37d649528951b2a27c5",
|
||||
"messages": [
|
||||
{
|
||||
"payload": "d03c489139e645d0711a3c9e810d776b46a84912463fafa87b884eebf242dc34",
|
||||
"ciphertext": "f9fa868ba97ab8a2686deccfaad5a484ee10a5bb85e3d1dce015a84797f92818d03c489139e645d0711a3c9e810d776b46a84912463fafa87b884eebf242dc34"
|
||||
},
|
||||
{
|
||||
"payload": "d8190a92f7dc0c93dbea9118ba8055751fb7c6590c416ffbd419964132b99a85",
|
||||
"ciphertext": "8c4e6fdb7d09d501a86f7eca5c234522751706ed409182c05cdf5f827d4dae47b81c6c5f43b025692c24391eefee725c17d8cb0fbe3e4abb8aedf42c4fd2592d4ea48ac08989d6ae8b4adae08b2c34087c808c7aa55a63c02b0fab9e930612336bd43eaea04d3c670a0a146691aa9cc9d357872320dc735dbc48580cffb553db"
|
||||
},
|
||||
{
|
||||
"payload": "77891b19dcb92ef7c055b672c4a5aa7fdf1c84146b8b303459022729473ce254",
|
||||
"ciphertext": "933ca6b5ed60df3df66121f0ab49a09e49efa45c613a86a3cecbf4c535cef2f83f72b42837b18e3572f2fdc2b74c331e2368a545cef54bdca081678ab0e9dd5348122459e0c034c851984d88ce610963d43cde6cfe73a67fbd5a63e8bfca96d0"
|
||||
},
|
||||
{
|
||||
"payload": "d7efdf988072881941db045a42882433817555128fbf5663e56081712ec7d212",
|
||||
"ciphertext": "54ef0ff0629e1aaa7685a2806ab111cba76b52331f2642276736f415868eacb69ab2577f3bda0cbf72f879685f6ed25f"
|
||||
},
|
||||
{
|
||||
"payload": "dd7bf01a588bafb52c6cfba952e5d8fe35cc2b3f92b4730ae2474615157345ce",
|
||||
"ciphertext": "356be70f110306d5c699bb834bb9d58d909e325924dfbec972e406e6f294dc63e1daebefe8a62a334facc8048ab4ad66"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,111 @@
|
||||
//
|
||||
// NotificationBlockingTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
// BCH-01-012: Tests for notification blocking feature
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct NotificationBlockingTests {
|
||||
|
||||
// MARK: - Nostr Blocking Tests
|
||||
|
||||
@Test("isNostrBlocked returns true for blocked pubkeys")
|
||||
func isNostrBlocked_returnsTrueForBlockedPubkey() {
|
||||
let keychain = MockKeychain()
|
||||
let manager = MockIdentityManager(keychain)
|
||||
|
||||
let testPubkey = "abc123def456".lowercased()
|
||||
|
||||
// Initially not blocked
|
||||
#expect(manager.isNostrBlocked(pubkeyHexLowercased: testPubkey) == false)
|
||||
|
||||
// Block the pubkey
|
||||
manager.setNostrBlocked(testPubkey, isBlocked: true)
|
||||
|
||||
// Now should be blocked
|
||||
#expect(manager.isNostrBlocked(pubkeyHexLowercased: testPubkey) == true)
|
||||
|
||||
// Unblock
|
||||
manager.setNostrBlocked(testPubkey, isBlocked: false)
|
||||
#expect(manager.isNostrBlocked(pubkeyHexLowercased: testPubkey) == false)
|
||||
}
|
||||
|
||||
@Test("isBlocked returns true for blocked fingerprints")
|
||||
func isBlocked_returnsTrueForBlockedFingerprint() {
|
||||
let keychain = MockKeychain()
|
||||
let manager = MockIdentityManager(keychain)
|
||||
|
||||
let testFingerprint = "fingerprint123"
|
||||
|
||||
// Initially not blocked
|
||||
#expect(manager.isBlocked(fingerprint: testFingerprint) == false)
|
||||
|
||||
// Block the fingerprint
|
||||
manager.setBlocked(testFingerprint, isBlocked: true)
|
||||
|
||||
// Now should be blocked
|
||||
#expect(manager.isBlocked(fingerprint: testFingerprint) == true)
|
||||
|
||||
// Unblock
|
||||
manager.setBlocked(testFingerprint, isBlocked: false)
|
||||
#expect(manager.isBlocked(fingerprint: testFingerprint) == false)
|
||||
}
|
||||
|
||||
@Test("getBlockedNostrPubkeys returns all blocked pubkeys")
|
||||
func getBlockedNostrPubkeys_returnsAllBlocked() {
|
||||
let keychain = MockKeychain()
|
||||
let manager = MockIdentityManager(keychain)
|
||||
|
||||
let pubkey1 = "pubkey1".lowercased()
|
||||
let pubkey2 = "pubkey2".lowercased()
|
||||
let pubkey3 = "pubkey3".lowercased()
|
||||
|
||||
manager.setNostrBlocked(pubkey1, isBlocked: true)
|
||||
manager.setNostrBlocked(pubkey2, isBlocked: true)
|
||||
manager.setNostrBlocked(pubkey3, isBlocked: true)
|
||||
|
||||
let blocked = manager.getBlockedNostrPubkeys()
|
||||
|
||||
#expect(blocked.count == 3)
|
||||
#expect(blocked.contains(pubkey1))
|
||||
#expect(blocked.contains(pubkey2))
|
||||
#expect(blocked.contains(pubkey3))
|
||||
}
|
||||
|
||||
// MARK: - Message Blocking Tests
|
||||
|
||||
@Test("BitchatMessage with blocked sender is identified")
|
||||
func bitchatMessage_blockedSenderIdentified() {
|
||||
let keychain = MockKeychain()
|
||||
let manager = MockIdentityManager(keychain)
|
||||
|
||||
let blockedFingerprint = "blocked_fingerprint_123"
|
||||
manager.setBlocked(blockedFingerprint, isBlocked: true)
|
||||
|
||||
#expect(manager.isBlocked(fingerprint: blockedFingerprint) == true)
|
||||
}
|
||||
|
||||
@Test("Case insensitive blocking for Nostr pubkeys")
|
||||
func nostrBlocking_caseInsensitive() {
|
||||
let keychain = MockKeychain()
|
||||
let manager = MockIdentityManager(keychain)
|
||||
|
||||
let pubkeyLower = "abc123def456"
|
||||
|
||||
// Block lowercase
|
||||
manager.setNostrBlocked(pubkeyLower, isBlocked: true)
|
||||
|
||||
// Check lowercase is blocked
|
||||
#expect(manager.isNostrBlocked(pubkeyHexLowercased: pubkeyLower) == true)
|
||||
|
||||
// Note: The API expects lowercased input, so callers must normalize
|
||||
// This test verifies the contract that pubkeys should be lowercased before checking
|
||||
// The fix in ChatViewModel+Nostr.swift normalizes via event.pubkey.lowercased()
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,8 @@ struct BinaryProtocolTests {
|
||||
#expect(decodedPacket.signature == TestConstants.testSignature)
|
||||
}
|
||||
|
||||
// MARK: - Source-Based Routing Tests (v2 only)
|
||||
|
||||
@Test func packetWithRouteRoundTrip() throws {
|
||||
let route: [Data] = [
|
||||
try #require(Data(hexString: "0102030405060708")),
|
||||
@@ -62,6 +64,7 @@ struct BinaryProtocolTests {
|
||||
try #require(Data(hexString: "2122232425262728"))
|
||||
]
|
||||
|
||||
// Route is only supported for v2+ packets
|
||||
var packet = BitchatPacket(
|
||||
type: 0x01,
|
||||
senderID: route[0],
|
||||
@@ -69,7 +72,8 @@ struct BinaryProtocolTests {
|
||||
timestamp: 1_720_000_000_000,
|
||||
payload: Data("route-test".utf8),
|
||||
signature: nil,
|
||||
ttl: 6
|
||||
ttl: 6,
|
||||
version: 2
|
||||
)
|
||||
packet.route = route
|
||||
|
||||
@@ -78,6 +82,7 @@ struct BinaryProtocolTests {
|
||||
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) != 0)
|
||||
|
||||
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with route")
|
||||
#expect(decoded.version == 2)
|
||||
let decodedRoute = try #require(decoded.route)
|
||||
#expect(decodedRoute.count == route.count)
|
||||
for (expected, actual) in zip(route, decodedRoute) {
|
||||
@@ -90,6 +95,7 @@ struct BinaryProtocolTests {
|
||||
let destination = try #require(Data(hexString: "8899aabbccddeeff"))
|
||||
let shortHop = Data([0xAA, 0xBB, 0xCC])
|
||||
|
||||
// Route is only supported for v2+ packets
|
||||
var packet = BitchatPacket(
|
||||
type: 0x02,
|
||||
senderID: sender,
|
||||
@@ -97,7 +103,8 @@ struct BinaryProtocolTests {
|
||||
timestamp: 1_730_000_000_000,
|
||||
payload: Data("pad-test".utf8),
|
||||
signature: nil,
|
||||
ttl: 5
|
||||
ttl: 5,
|
||||
version: 2
|
||||
)
|
||||
packet.route = [shortHop, destination]
|
||||
|
||||
@@ -117,6 +124,7 @@ struct BinaryProtocolTests {
|
||||
try #require(Data(hexString: "0202020202020202"))
|
||||
]
|
||||
let repeatedString = String(repeating: "compress-me", count: 150)
|
||||
// Route is only supported for v2+ packets
|
||||
var packet = BitchatPacket(
|
||||
type: 0x03,
|
||||
senderID: route[0],
|
||||
@@ -124,7 +132,8 @@ struct BinaryProtocolTests {
|
||||
timestamp: 1_740_000_000_000,
|
||||
payload: Data(repeatedString.utf8),
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
ttl: 7,
|
||||
version: 2
|
||||
)
|
||||
packet.route = route
|
||||
|
||||
@@ -135,6 +144,146 @@ struct BinaryProtocolTests {
|
||||
#expect(decodedRoute == route)
|
||||
}
|
||||
|
||||
@Test func v1PacketIgnoresRouteOnEncode() throws {
|
||||
// v1 packets should NOT include route even if route is set on the packet object
|
||||
let route: [Data] = [
|
||||
try #require(Data(hexString: "0102030405060708")),
|
||||
try #require(Data(hexString: "1112131415161718"))
|
||||
]
|
||||
|
||||
var packet = BitchatPacket(
|
||||
type: 0x01,
|
||||
senderID: route[0],
|
||||
recipientID: route.last,
|
||||
timestamp: 1_720_000_000_000,
|
||||
payload: Data("v1-no-route".utf8),
|
||||
signature: nil,
|
||||
ttl: 6
|
||||
// version defaults to 1 (v1 packet)
|
||||
)
|
||||
packet.route = route // route is set but should be ignored for v1
|
||||
|
||||
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode v1 packet")
|
||||
|
||||
// HAS_ROUTE flag should NOT be set for v1 packets
|
||||
let flagsByte = encoded[BinaryProtocol.Offsets.flags]
|
||||
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) == 0, "v1 packet should not have HAS_ROUTE flag set")
|
||||
|
||||
// Decoded packet should have no route
|
||||
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode v1 packet")
|
||||
#expect(decoded.version == 1)
|
||||
#expect(decoded.route == nil, "v1 packet should decode with nil route")
|
||||
#expect(decoded.payload == Data("v1-no-route".utf8))
|
||||
}
|
||||
|
||||
@Test func v2PacketIncludesRouteOnEncode() throws {
|
||||
// v2 packets SHOULD include route when route is set
|
||||
let route: [Data] = [
|
||||
try #require(Data(hexString: "0102030405060708")),
|
||||
try #require(Data(hexString: "1112131415161718"))
|
||||
]
|
||||
|
||||
var packet = BitchatPacket(
|
||||
type: 0x01,
|
||||
senderID: route[0],
|
||||
recipientID: route.last,
|
||||
timestamp: 1_720_000_000_000,
|
||||
payload: Data("v2-with-route".utf8),
|
||||
signature: nil,
|
||||
ttl: 6,
|
||||
version: 2
|
||||
)
|
||||
packet.route = route
|
||||
|
||||
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode v2 packet")
|
||||
|
||||
// HAS_ROUTE flag SHOULD be set for v2 packets with route
|
||||
let flagsByte = encoded[BinaryProtocol.Offsets.flags]
|
||||
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) != 0, "v2 packet should have HAS_ROUTE flag set")
|
||||
|
||||
// Decoded packet should have route
|
||||
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode v2 packet")
|
||||
#expect(decoded.version == 2)
|
||||
let decodedRoute = try #require(decoded.route, "v2 packet should decode with route")
|
||||
#expect(decodedRoute.count == route.count)
|
||||
#expect(decoded.payload == Data("v2-with-route".utf8))
|
||||
}
|
||||
|
||||
@Test func v2PacketWithoutRouteDecodesCorrectly() throws {
|
||||
// v2 packet without route should still work
|
||||
let sender = try #require(Data(hexString: "0011223344556677"))
|
||||
let recipient = try #require(Data(hexString: "8899aabbccddeeff"))
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: 0x02,
|
||||
senderID: sender,
|
||||
recipientID: recipient,
|
||||
timestamp: 1_750_000_000_000,
|
||||
payload: Data("v2-no-route".utf8),
|
||||
signature: nil,
|
||||
ttl: 5,
|
||||
version: 2
|
||||
)
|
||||
// route is nil by default
|
||||
|
||||
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode v2 packet without route")
|
||||
|
||||
// HAS_ROUTE flag should NOT be set when no route
|
||||
let flagsByte = encoded[BinaryProtocol.Offsets.flags]
|
||||
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) == 0, "v2 packet without route should not have HAS_ROUTE flag")
|
||||
|
||||
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode v2 packet without route")
|
||||
#expect(decoded.version == 2)
|
||||
#expect(decoded.route == nil)
|
||||
#expect(decoded.payload == Data("v2-no-route".utf8))
|
||||
}
|
||||
|
||||
@Test func v1AndV2PayloadLengthDifference() throws {
|
||||
// Verify that payloadLength does NOT include route bytes
|
||||
// by comparing encoded sizes
|
||||
let route: [Data] = [
|
||||
try #require(Data(hexString: "0102030405060708"))
|
||||
]
|
||||
let payloadData = Data("test-payload".utf8)
|
||||
|
||||
// v1 packet (route ignored)
|
||||
var v1Packet = BitchatPacket(
|
||||
type: 0x01,
|
||||
senderID: route[0],
|
||||
recipientID: nil,
|
||||
timestamp: 1_720_000_000_000,
|
||||
payload: payloadData,
|
||||
signature: nil,
|
||||
ttl: 6
|
||||
// version defaults to 1
|
||||
)
|
||||
v1Packet.route = route // will be ignored for v1
|
||||
|
||||
// v2 packet with same payload but route included
|
||||
var v2Packet = BitchatPacket(
|
||||
type: 0x01,
|
||||
senderID: route[0],
|
||||
recipientID: nil,
|
||||
timestamp: 1_720_000_000_000,
|
||||
payload: payloadData,
|
||||
signature: nil,
|
||||
ttl: 6,
|
||||
version: 2
|
||||
)
|
||||
v2Packet.route = route
|
||||
|
||||
let v1Encoded = try #require(BinaryProtocol.encode(v1Packet, padding: false))
|
||||
let v2Encoded = try #require(BinaryProtocol.encode(v2Packet, padding: false))
|
||||
|
||||
// v2 should be larger by: 2 bytes (header length field difference) + 1 byte (route count) + 8 bytes (one hop)
|
||||
// Header: v1=14, v2=16 -> +2 bytes
|
||||
// Route: 1 + 8 = 9 bytes
|
||||
// Total expected difference: 11 bytes
|
||||
let expectedDiff = 2 + 1 + 8 // header diff + route count + one hop
|
||||
#expect(v2Encoded.count - v1Encoded.count == expectedDiff,
|
||||
"v2 packet should be \(expectedDiff) bytes larger than v1 (actual diff: \(v2Encoded.count - v1Encoded.count))")
|
||||
}
|
||||
|
||||
// MARK: - Compression Tests
|
||||
|
||||
@Test("Create a large, compressible payload above current threshold (2048B)")
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//
|
||||
// PublicMessagePipelineTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for PublicMessagePipeline ordering and deduplication.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
private final class TestPipelineDelegate: PublicMessagePipelineDelegate {
|
||||
private let dedupService = MessageDeduplicationService()
|
||||
var messages: [BitchatMessage] = []
|
||||
|
||||
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] {
|
||||
messages
|
||||
}
|
||||
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) {
|
||||
self.messages = messages
|
||||
}
|
||||
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {
|
||||
dedupService.normalizedContentKey(content)
|
||||
}
|
||||
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? {
|
||||
dedupService.contentTimestamp(forKey: key)
|
||||
}
|
||||
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) {
|
||||
dedupService.recordContentKey(key, timestamp: timestamp)
|
||||
}
|
||||
|
||||
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {}
|
||||
|
||||
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {}
|
||||
|
||||
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {}
|
||||
}
|
||||
|
||||
struct PublicMessagePipelineTests {
|
||||
|
||||
@Test @MainActor
|
||||
func flush_sortsByTimestamp() async {
|
||||
let pipeline = PublicMessagePipeline()
|
||||
let delegate = TestPipelineDelegate()
|
||||
pipeline.delegate = delegate
|
||||
|
||||
let earlier = Date().addingTimeInterval(-10)
|
||||
let later = Date()
|
||||
|
||||
let messageA = BitchatMessage(
|
||||
id: "a",
|
||||
sender: "A",
|
||||
content: "Later",
|
||||
timestamp: later,
|
||||
isRelay: false
|
||||
)
|
||||
let messageB = BitchatMessage(
|
||||
id: "b",
|
||||
sender: "A",
|
||||
content: "Earlier",
|
||||
timestamp: earlier,
|
||||
isRelay: false
|
||||
)
|
||||
|
||||
pipeline.enqueue(messageA)
|
||||
pipeline.enqueue(messageB)
|
||||
pipeline.flushIfNeeded()
|
||||
|
||||
#expect(delegate.messages.map { $0.id } == ["b", "a"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func flush_deduplicatesByContentWithinWindow() async {
|
||||
let pipeline = PublicMessagePipeline()
|
||||
let delegate = TestPipelineDelegate()
|
||||
pipeline.delegate = delegate
|
||||
|
||||
let now = Date()
|
||||
let messageA = BitchatMessage(
|
||||
id: "a",
|
||||
sender: "A",
|
||||
content: "Same",
|
||||
timestamp: now,
|
||||
isRelay: false
|
||||
)
|
||||
let messageB = BitchatMessage(
|
||||
id: "b",
|
||||
sender: "A",
|
||||
content: "Same",
|
||||
timestamp: now.addingTimeInterval(0.2),
|
||||
isRelay: false
|
||||
)
|
||||
|
||||
pipeline.enqueue(messageA)
|
||||
pipeline.enqueue(messageB)
|
||||
pipeline.flushIfNeeded()
|
||||
|
||||
#expect(delegate.messages.count == 1)
|
||||
#expect(delegate.messages.first?.content == "Same")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func lateInsert_meshAppendsRecentOlderMessage() async {
|
||||
let pipeline = PublicMessagePipeline()
|
||||
let delegate = TestPipelineDelegate()
|
||||
pipeline.delegate = delegate
|
||||
pipeline.updateActiveChannel(.mesh)
|
||||
|
||||
let base = Date()
|
||||
let newer = BitchatMessage(
|
||||
id: "new",
|
||||
sender: "A",
|
||||
content: "New",
|
||||
timestamp: base,
|
||||
isRelay: false
|
||||
)
|
||||
let older = BitchatMessage(
|
||||
id: "old",
|
||||
sender: "A",
|
||||
content: "Old",
|
||||
timestamp: base.addingTimeInterval(-5),
|
||||
isRelay: false
|
||||
)
|
||||
|
||||
delegate.messages = [newer]
|
||||
pipeline.enqueue(older)
|
||||
pipeline.flushIfNeeded()
|
||||
|
||||
#expect(delegate.messages.map { $0.id } == ["new", "old"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func lateInsert_locationInsertsByTimestamp() async {
|
||||
let pipeline = PublicMessagePipeline()
|
||||
let delegate = TestPipelineDelegate()
|
||||
pipeline.delegate = delegate
|
||||
pipeline.updateActiveChannel(.location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
||||
|
||||
let base = Date()
|
||||
let newer = BitchatMessage(
|
||||
id: "new",
|
||||
sender: "A",
|
||||
content: "New",
|
||||
timestamp: base,
|
||||
isRelay: false
|
||||
)
|
||||
let older = BitchatMessage(
|
||||
id: "old",
|
||||
sender: "A",
|
||||
content: "Old",
|
||||
timestamp: base.addingTimeInterval(-5),
|
||||
isRelay: false
|
||||
)
|
||||
|
||||
delegate.messages = [newer]
|
||||
pipeline.enqueue(older)
|
||||
pipeline.flushIfNeeded()
|
||||
|
||||
#expect(delegate.messages.map { $0.id } == ["old", "new"])
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,13 @@ struct MeshTopologyTrackerTests {
|
||||
let a = try hex("0102030405060708")
|
||||
let b = try hex("1112131415161718")
|
||||
|
||||
tracker.recordDirectLink(between: a, and: b)
|
||||
// Bidirectional announcement
|
||||
tracker.updateNeighbors(for: a, neighbors: [b])
|
||||
tracker.updateNeighbors(for: b, neighbors: [a])
|
||||
|
||||
let route = try #require(tracker.computeRoute(from: a, to: b))
|
||||
#expect(route == [a, b])
|
||||
// Direct connection returns empty route (no intermediate hops)
|
||||
#expect(route == [])
|
||||
}
|
||||
|
||||
@Test func multiHopRouteComputation() throws {
|
||||
@@ -32,41 +36,38 @@ struct MeshTopologyTrackerTests {
|
||||
let c = try hex("2021222324252627")
|
||||
let d = try hex("3031323334353637")
|
||||
|
||||
tracker.recordDirectLink(between: a, and: b)
|
||||
tracker.recordDirectLink(between: b, and: c)
|
||||
tracker.recordDirectLink(between: c, and: d)
|
||||
// Bidirectional announcements for A-B, B-C, C-D
|
||||
tracker.updateNeighbors(for: a, neighbors: [b])
|
||||
tracker.updateNeighbors(for: b, neighbors: [a, c])
|
||||
tracker.updateNeighbors(for: c, neighbors: [b, d])
|
||||
tracker.updateNeighbors(for: d, neighbors: [c])
|
||||
|
||||
let route = try #require(tracker.computeRoute(from: a, to: d))
|
||||
#expect(route == [a, b, c, d])
|
||||
// Route should only contain intermediate hops (b, c), excluding start (a) and end (d)
|
||||
#expect(route == [b, c])
|
||||
}
|
||||
|
||||
@Test func recordRouteAddsEdges() throws {
|
||||
let tracker = MeshTopologyTracker()
|
||||
var a = Data([0xAA, 0xBB, 0xCC])
|
||||
let b = try hex("4445464748494A4B")
|
||||
let c = try hex("5455565758595A5B")
|
||||
|
||||
tracker.recordRoute([a, b, c])
|
||||
|
||||
a.append(Data(repeating: 0, count: BinaryProtocol.senderIDSize - a.count))
|
||||
let route = try #require(tracker.computeRoute(from: a, to: c))
|
||||
#expect(route.first == a)
|
||||
#expect(route.last == c)
|
||||
}
|
||||
|
||||
@Test func removingDirectLinkBreaksRoute() throws {
|
||||
@Test func unconfirmedEdgeDoesNotRoute() throws {
|
||||
let tracker = MeshTopologyTracker()
|
||||
let a = try hex("0101010101010101")
|
||||
let b = try hex("0202020202020202")
|
||||
let c = try hex("0303030303030303")
|
||||
|
||||
tracker.recordDirectLink(between: a, and: b)
|
||||
tracker.recordDirectLink(between: b, and: c)
|
||||
let initialRoute = try #require(tracker.computeRoute(from: a, to: c))
|
||||
#expect(initialRoute == [a, b, c])
|
||||
// A announces B (confirmed)
|
||||
// B announces A, C (confirmed A-B, unconfirmed B-C)
|
||||
// C does NOT announce B
|
||||
tracker.updateNeighbors(for: a, neighbors: [b])
|
||||
tracker.updateNeighbors(for: b, neighbors: [a, c])
|
||||
// C is silent or announces empty
|
||||
|
||||
tracker.removeDirectLink(between: b, and: c)
|
||||
// Should NOT find route A->C because B->C is unconfirmed (C didn't announce B)
|
||||
#expect(tracker.computeRoute(from: a, to: c) == nil)
|
||||
|
||||
// Now C announces B
|
||||
tracker.updateNeighbors(for: c, neighbors: [b])
|
||||
// Should find route
|
||||
let route = try #require(tracker.computeRoute(from: a, to: c))
|
||||
#expect(route == [b])
|
||||
}
|
||||
|
||||
@Test func removingPeerClearsEdges() throws {
|
||||
@@ -75,12 +76,28 @@ struct MeshTopologyTrackerTests {
|
||||
let b = try hex("0A0B0C0D0E0F0001")
|
||||
let c = try hex("0011223344556677")
|
||||
|
||||
tracker.recordRoute([a, b, c])
|
||||
tracker.updateNeighbors(for: a, neighbors: [b])
|
||||
tracker.updateNeighbors(for: b, neighbors: [a, c])
|
||||
tracker.updateNeighbors(for: c, neighbors: [b])
|
||||
|
||||
let initialRoute = try #require(tracker.computeRoute(from: a, to: c))
|
||||
#expect(initialRoute == [a, b, c])
|
||||
#expect(initialRoute == [b])
|
||||
|
||||
tracker.removePeer(b)
|
||||
#expect(tracker.computeRoute(from: a, to: c) == nil)
|
||||
}
|
||||
|
||||
@Test func sameStartAndEndReturnsEmptyRoute() throws {
|
||||
let tracker = MeshTopologyTracker()
|
||||
let a = try hex("0102030405060708")
|
||||
let b = try hex("1112131415161718")
|
||||
|
||||
tracker.updateNeighbors(for: a, neighbors: [b])
|
||||
tracker.updateNeighbors(for: b, neighbors: [a])
|
||||
|
||||
// When start == end, route should be empty (no intermediate hops needed)
|
||||
let route = try #require(tracker.computeRoute(from: a, to: a))
|
||||
#expect(route == [])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// MessageRouterTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for MessageRouter transport selection and outbox behavior.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct MessageRouterTests {
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_usesReachableTransport() async {
|
||||
let peerID = PeerID(str: "0000000000000001")
|
||||
let transportA = MockTransport()
|
||||
let transportB = MockTransport()
|
||||
transportB.reachablePeers.insert(peerID)
|
||||
|
||||
let router = MessageRouter(transports: [transportA, transportB])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m1")
|
||||
|
||||
#expect(transportA.sentPrivateMessages.isEmpty)
|
||||
#expect(transportB.sentPrivateMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_queuesThenFlushesWhenReachable() async {
|
||||
let peerID = PeerID(str: "0000000000000002")
|
||||
let transport = MockTransport()
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Queued", to: peerID, recipientNickname: "Peer", messageID: "m2")
|
||||
|
||||
#expect(transport.sentPrivateMessages.isEmpty)
|
||||
|
||||
transport.reachablePeers.insert(peerID)
|
||||
router.flushOutbox(for: peerID)
|
||||
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendReadReceipt_usesReachableTransport() async {
|
||||
let peerID = PeerID(str: "0000000000000003")
|
||||
let transport = MockTransport()
|
||||
transport.reachablePeers.insert(peerID)
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
let receipt = ReadReceipt(originalMessageID: "m3", readerID: transport.myPeerID, readerNickname: "Me")
|
||||
router.sendReadReceipt(receipt, to: peerID)
|
||||
|
||||
#expect(transport.sentReadReceipts.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendFavoriteNotification_usesConnectedOrReachable() async {
|
||||
let peerID = PeerID(str: "0000000000000004")
|
||||
let transport = MockTransport()
|
||||
transport.reachablePeers.insert(peerID)
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||
|
||||
#expect(transport.sentFavoriteNotifications.count == 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// NostrTransportTests.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("NostrTransport Thread Safety Tests")
|
||||
struct NostrTransportTests {
|
||||
|
||||
@Test("Concurrent read receipt enqueue does not crash")
|
||||
@MainActor
|
||||
func concurrentReadReceiptEnqueue() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
|
||||
// Create 100 concurrent read receipt submissions
|
||||
let iterations = 100
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for i in 0..<iterations {
|
||||
group.addTask {
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: UUID().uuidString,
|
||||
readerID: PeerID(str: String(format: "%016x", i)),
|
||||
readerNickname: "Reader\(i)"
|
||||
)
|
||||
let peerID = PeerID(str: String(format: "%016x", i))
|
||||
transport.sendReadReceipt(receipt, to: peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here without crashing, the test passes
|
||||
// The concurrent enqueue operations completed without data races
|
||||
}
|
||||
|
||||
@Test("Read queue processes under concurrent load")
|
||||
@MainActor
|
||||
func readQueueProcessingUnderLoad() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
|
||||
// Rapidly enqueue many receipts from multiple concurrent sources
|
||||
let iterations = 50
|
||||
|
||||
// First batch - rapid fire
|
||||
for i in 0..<iterations {
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: UUID().uuidString,
|
||||
readerID: PeerID(str: String(format: "%016x", i)),
|
||||
readerNickname: "Reader\(i)"
|
||||
)
|
||||
transport.sendReadReceipt(receipt, to: PeerID(str: String(format: "%016x", i)))
|
||||
}
|
||||
|
||||
// Give some time for processing to start
|
||||
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
||||
|
||||
// Second batch - while first might be processing
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for i in iterations..<(iterations * 2) {
|
||||
group.addTask {
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: UUID().uuidString,
|
||||
readerID: PeerID(str: String(format: "%016x", i)),
|
||||
readerNickname: "Reader\(i)"
|
||||
)
|
||||
transport.sendReadReceipt(receipt, to: PeerID(str: String(format: "%016x", i)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here without crashing or deadlocking, test passes
|
||||
}
|
||||
|
||||
@Test("isPeerReachable is thread safe")
|
||||
@MainActor
|
||||
func isPeerReachableThreadSafety() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
|
||||
let iterations = 100
|
||||
|
||||
// Concurrent reads on isPeerReachable
|
||||
await withTaskGroup(of: Bool.self) { group in
|
||||
for i in 0..<iterations {
|
||||
group.addTask {
|
||||
let peerID = PeerID(str: String(format: "%016x", i))
|
||||
return transport.isPeerReachable(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect results (all should be false since no favorites configured)
|
||||
for await result in group {
|
||||
#expect(result == false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//
|
||||
// PrivateChatManagerTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for PrivateChatManager read receipt and selection behavior.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct PrivateChatManagerTests {
|
||||
|
||||
@Test @MainActor
|
||||
func startChat_setsSelectedAndClearsUnread() async {
|
||||
let transport = MockTransport()
|
||||
let manager = PrivateChatManager(meshService: transport)
|
||||
let peerID = PeerID(str: "00000000000000AA")
|
||||
|
||||
manager.privateChats[peerID] = [
|
||||
BitchatMessage(
|
||||
id: "pm-1",
|
||||
sender: "Peer",
|
||||
content: "Hi",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
)
|
||||
]
|
||||
manager.unreadMessages.insert(peerID)
|
||||
|
||||
manager.startChat(with: peerID)
|
||||
|
||||
#expect(manager.selectedPeer == peerID)
|
||||
#expect(!manager.unreadMessages.contains(peerID))
|
||||
#expect(manager.privateChats[peerID] != nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func markAsRead_sendsReadReceiptViaRouter() async {
|
||||
let transport = MockTransport()
|
||||
let router = MessageRouter(transports: [transport])
|
||||
let manager = PrivateChatManager(meshService: transport)
|
||||
manager.messageRouter = router
|
||||
|
||||
let peerID = PeerID(str: "00000000000000BB")
|
||||
transport.reachablePeers.insert(peerID)
|
||||
|
||||
manager.privateChats[peerID] = [
|
||||
BitchatMessage(
|
||||
id: "pm-2",
|
||||
sender: "Peer",
|
||||
content: "Hi",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
)
|
||||
]
|
||||
manager.unreadMessages.insert(peerID)
|
||||
|
||||
manager.markAsRead(from: peerID)
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
#expect(transport.sentReadReceipts.count == 1)
|
||||
#expect(manager.sentReadReceipts.contains("pm-2"))
|
||||
#expect(!manager.unreadMessages.contains(peerID))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// RelayControllerTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for relay decision logic.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct RelayControllerTests {
|
||||
|
||||
@Test
|
||||
func ttlOne_doesNotRelay() async {
|
||||
let decision = RelayController.decide(
|
||||
ttl: 1,
|
||||
senderIsSelf: false,
|
||||
isEncrypted: false,
|
||||
isDirectedEncrypted: false,
|
||||
isFragment: false,
|
||||
isDirectedFragment: false,
|
||||
isHandshake: false,
|
||||
isAnnounce: false,
|
||||
degree: 0,
|
||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
||||
)
|
||||
|
||||
#expect(!decision.shouldRelay)
|
||||
#expect(decision.newTTL == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func handshake_alwaysRelaysWithTTLDecrement() async {
|
||||
let decision = RelayController.decide(
|
||||
ttl: 3,
|
||||
senderIsSelf: false,
|
||||
isEncrypted: false,
|
||||
isDirectedEncrypted: false,
|
||||
isFragment: false,
|
||||
isDirectedFragment: false,
|
||||
isHandshake: true,
|
||||
isAnnounce: false,
|
||||
degree: 3,
|
||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
||||
)
|
||||
|
||||
#expect(decision.shouldRelay)
|
||||
#expect(decision.newTTL == 2)
|
||||
#expect(decision.delayMs >= 10 && decision.delayMs <= 35)
|
||||
}
|
||||
|
||||
@Test
|
||||
func fragment_relaysWithFragmentCap() async {
|
||||
let decision = RelayController.decide(
|
||||
ttl: 10,
|
||||
senderIsSelf: false,
|
||||
isEncrypted: false,
|
||||
isDirectedEncrypted: false,
|
||||
isFragment: true,
|
||||
isDirectedFragment: false,
|
||||
isHandshake: false,
|
||||
isAnnounce: false,
|
||||
degree: 3,
|
||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
||||
)
|
||||
|
||||
let ttlCap = min(UInt8(10), TransportConfig.bleFragmentRelayTtlCap)
|
||||
let expected = ttlCap &- 1
|
||||
|
||||
#expect(decision.shouldRelay)
|
||||
#expect(decision.newTTL == expected)
|
||||
#expect(decision.delayMs >= TransportConfig.bleFragmentRelayMinDelayMs)
|
||||
#expect(decision.delayMs <= TransportConfig.bleFragmentRelayMaxDelayMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
func denseGraph_capsTTL() async {
|
||||
let decision = RelayController.decide(
|
||||
ttl: 10,
|
||||
senderIsSelf: false,
|
||||
isEncrypted: false,
|
||||
isDirectedEncrypted: false,
|
||||
isFragment: false,
|
||||
isDirectedFragment: false,
|
||||
isHandshake: false,
|
||||
isAnnounce: false,
|
||||
degree: TransportConfig.bleHighDegreeThreshold,
|
||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
||||
)
|
||||
|
||||
#expect(decision.shouldRelay)
|
||||
#expect(decision.newTTL == 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//
|
||||
// UnifiedPeerServiceTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for UnifiedPeerService fingerprint and block resolution.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct UnifiedPeerServiceTests {
|
||||
|
||||
@Test @MainActor
|
||||
func getFingerprint_prefersMeshService() async {
|
||||
let transport = MockTransport()
|
||||
let identity = TestIdentityManager()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
|
||||
|
||||
let peerID = PeerID(str: "00000000000000CC")
|
||||
transport.peerFingerprints[peerID] = "fp-1"
|
||||
|
||||
let fingerprint = service.getFingerprint(for: peerID)
|
||||
|
||||
#expect(fingerprint == "fp-1")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func isBlocked_usesSocialIdentity() async {
|
||||
let transport = MockTransport()
|
||||
let identity = TestIdentityManager()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
|
||||
|
||||
let peerID = PeerID(str: "00000000000000DD")
|
||||
let fingerprint = "fp-blocked"
|
||||
transport.peerFingerprints[peerID] = fingerprint
|
||||
identity.setBlocked(fingerprint, isBlocked: true)
|
||||
|
||||
#expect(service.isBlocked(peerID))
|
||||
}
|
||||
}
|
||||
|
||||
private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
private var socialIdentities: [String: SocialIdentity] = [:]
|
||||
private var favorites: Set<String> = []
|
||||
private var blockedNostr: Set<String> = []
|
||||
private var verified: Set<String> = []
|
||||
|
||||
func forceSave() {}
|
||||
|
||||
func getSocialIdentity(for fingerprint: String) -> SocialIdentity? {
|
||||
socialIdentities[fingerprint]
|
||||
}
|
||||
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {}
|
||||
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {
|
||||
[]
|
||||
}
|
||||
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||
socialIdentities[identity.fingerprint] = identity
|
||||
}
|
||||
|
||||
func getFavorites() -> Set<String> {
|
||||
favorites
|
||||
}
|
||||
|
||||
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
|
||||
if isFavorite {
|
||||
favorites.insert(fingerprint)
|
||||
} else {
|
||||
favorites.remove(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func isFavorite(fingerprint: String) -> Bool {
|
||||
favorites.contains(fingerprint)
|
||||
}
|
||||
|
||||
func isBlocked(fingerprint: String) -> Bool {
|
||||
socialIdentities[fingerprint]?.isBlocked ?? false
|
||||
}
|
||||
|
||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
||||
var identity = socialIdentities[fingerprint] ?? SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: "",
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
identity.isBlocked = isBlocked
|
||||
socialIdentities[fingerprint] = identity
|
||||
}
|
||||
|
||||
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
|
||||
blockedNostr.contains(pubkeyHexLowercased)
|
||||
}
|
||||
|
||||
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
||||
if isBlocked {
|
||||
blockedNostr.insert(pubkeyHexLowercased)
|
||||
} else {
|
||||
blockedNostr.remove(pubkeyHexLowercased)
|
||||
}
|
||||
}
|
||||
|
||||
func getBlockedNostrPubkeys() -> Set<String> {
|
||||
blockedNostr
|
||||
}
|
||||
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}
|
||||
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {}
|
||||
|
||||
func clearAllIdentityData() {
|
||||
socialIdentities.removeAll()
|
||||
favorites.removeAll()
|
||||
blockedNostr.removeAll()
|
||||
verified.removeAll()
|
||||
}
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {}
|
||||
|
||||
func setVerified(fingerprint: String, verified: Bool) {
|
||||
if verified {
|
||||
self.verified.insert(fingerprint)
|
||||
} else {
|
||||
self.verified.remove(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func isVerified(fingerprint: String) -> Bool {
|
||||
verified.contains(fingerprint)
|
||||
}
|
||||
|
||||
func getVerifiedFingerprints() -> Set<String> {
|
||||
verified
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// SubscriptionRateLimitTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
/// Tests for BCH-01-004 fix: Rate-limiting subscription-triggered announces
|
||||
/// to prevent device enumeration attacks
|
||||
struct SubscriptionRateLimitTests {
|
||||
|
||||
@Test("Rate limit configuration values are sensible")
|
||||
func rateLimitConfigurationValues() {
|
||||
// Minimum interval should be at least 1 second to slow enumeration
|
||||
#expect(TransportConfig.bleSubscriptionRateLimitMinSeconds >= 1.0)
|
||||
|
||||
// Backoff factor should be > 1 for exponential backoff
|
||||
#expect(TransportConfig.bleSubscriptionRateLimitBackoffFactor > 1.0)
|
||||
|
||||
// Max backoff should be reasonable (not hours)
|
||||
#expect(TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds <= 60.0)
|
||||
#expect(TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds >= TransportConfig.bleSubscriptionRateLimitMinSeconds)
|
||||
|
||||
// Window should be long enough to track repeated attempts
|
||||
#expect(TransportConfig.bleSubscriptionRateLimitWindowSeconds >= 30.0)
|
||||
|
||||
// Max attempts before suppression should be > 1 to allow legitimate reconnects
|
||||
#expect(TransportConfig.bleSubscriptionRateLimitMaxAttempts >= 2)
|
||||
}
|
||||
|
||||
@Test("Exponential backoff calculation is correct")
|
||||
func exponentialBackoffCalculation() {
|
||||
let minInterval = TransportConfig.bleSubscriptionRateLimitMinSeconds
|
||||
let factor = TransportConfig.bleSubscriptionRateLimitBackoffFactor
|
||||
let maxBackoff = TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds
|
||||
|
||||
// Simulate backoff progression
|
||||
var currentBackoff = minInterval
|
||||
var iterations = 0
|
||||
let maxIterations = 10
|
||||
|
||||
while currentBackoff < maxBackoff && iterations < maxIterations {
|
||||
let nextBackoff = min(currentBackoff * factor, maxBackoff)
|
||||
#expect(nextBackoff >= currentBackoff, "Backoff should increase or stay at max")
|
||||
currentBackoff = nextBackoff
|
||||
iterations += 1
|
||||
}
|
||||
|
||||
// Should reach max within reasonable iterations
|
||||
#expect(iterations <= maxIterations, "Backoff should reach max within \(maxIterations) iterations")
|
||||
#expect(currentBackoff == maxBackoff, "Final backoff should equal max")
|
||||
}
|
||||
|
||||
@Test("Rate limiting would significantly slow enumeration attacks")
|
||||
func rateLimitingSlowsEnumeration() {
|
||||
// Without rate limiting: ~120 devices/minute (0.5 seconds per device)
|
||||
// With rate limiting: minimum interval enforced
|
||||
|
||||
let minInterval = TransportConfig.bleSubscriptionRateLimitMinSeconds
|
||||
let devicesPerMinuteWithRateLimit = 60.0 / minInterval
|
||||
|
||||
// Should be significantly slower than 120 devices/minute
|
||||
#expect(devicesPerMinuteWithRateLimit < 60, "Rate limiting should significantly slow enumeration")
|
||||
|
||||
// With 2-second minimum interval, max ~30 devices/minute per connection
|
||||
// And with backoff, repeated attempts are even slower
|
||||
#expect(devicesPerMinuteWithRateLimit <= 30, "With 2s minimum, should be <=30/min")
|
||||
}
|
||||
|
||||
@Test("Max attempts threshold prevents complete enumeration")
|
||||
func maxAttemptsThresholdPreventsEnumeration() {
|
||||
let maxAttempts = TransportConfig.bleSubscriptionRateLimitMaxAttempts
|
||||
|
||||
// After max attempts within window, announces are suppressed entirely
|
||||
// This means an attacker gets at most maxAttempts announces per window
|
||||
#expect(maxAttempts >= 2, "Should allow at least 2 attempts for legitimate reconnects")
|
||||
#expect(maxAttempts <= 10, "Should cap attempts to prevent enumeration")
|
||||
|
||||
// With 5 attempts max and 2s minimum interval, attacker gets limited info
|
||||
let maxAnnounces = maxAttempts
|
||||
#expect(maxAnnounces <= 10, "Max announces per window should be limited")
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,22 @@ final class TestHelpers {
|
||||
try await sleep(0.01)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func waitUntil(
|
||||
_ condition: @escaping () -> Bool,
|
||||
timeout: TimeInterval = TestConstants.defaultTimeout,
|
||||
pollInterval: TimeInterval = 0.01
|
||||
) async -> Bool {
|
||||
let start = Date()
|
||||
while !condition() {
|
||||
if Date().timeIntervalSince(start) > timeout {
|
||||
return condition()
|
||||
}
|
||||
try? await sleep(pollInterval)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
static func expectAsync<T>(
|
||||
timeout: TimeInterval = TestConstants.defaultTimeout,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// HexStringTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for Data(hexString:) hex parsing
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct HexStringTests {
|
||||
|
||||
// MARK: - Valid Hex Strings
|
||||
|
||||
@Test func validHexString() {
|
||||
let data = Data(hexString: "0102030405")
|
||||
#expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05]))
|
||||
}
|
||||
|
||||
@Test func validHexStringUppercase() {
|
||||
let data = Data(hexString: "AABBCCDD")
|
||||
#expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD]))
|
||||
}
|
||||
|
||||
@Test func validHexStringMixedCase() {
|
||||
let data = Data(hexString: "aAbBcCdD")
|
||||
#expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD]))
|
||||
}
|
||||
|
||||
@Test func validHexStringWith0xPrefix() {
|
||||
let data = Data(hexString: "0x0102030405")
|
||||
#expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05]))
|
||||
}
|
||||
|
||||
@Test func validHexStringWith0XPrefix() {
|
||||
let data = Data(hexString: "0XAABBCCDD")
|
||||
#expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD]))
|
||||
}
|
||||
|
||||
@Test func validHexStringWithWhitespace() {
|
||||
let data = Data(hexString: " 0102030405 ")
|
||||
#expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05]))
|
||||
}
|
||||
|
||||
@Test func validHexStringWith0xPrefixAndWhitespace() {
|
||||
let data = Data(hexString: " 0x0102030405 ")
|
||||
#expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05]))
|
||||
}
|
||||
|
||||
@Test func emptyHexString() {
|
||||
let data = Data(hexString: "")
|
||||
#expect(data == Data())
|
||||
}
|
||||
|
||||
@Test func emptyHexStringWithWhitespace() {
|
||||
let data = Data(hexString: " ")
|
||||
#expect(data == Data())
|
||||
}
|
||||
|
||||
@Test func emptyHexStringWith0xPrefix() {
|
||||
let data = Data(hexString: "0x")
|
||||
#expect(data == Data())
|
||||
}
|
||||
|
||||
// MARK: - Invalid Hex Strings
|
||||
|
||||
@Test func oddLengthHexStringReturnsNil() {
|
||||
let data = Data(hexString: "012")
|
||||
#expect(data == nil)
|
||||
}
|
||||
|
||||
@Test func oddLengthHexStringWith0xPrefixReturnsNil() {
|
||||
let data = Data(hexString: "0x012")
|
||||
#expect(data == nil)
|
||||
}
|
||||
|
||||
@Test func invalidCharactersReturnNil() {
|
||||
let data = Data(hexString: "GHIJ")
|
||||
#expect(data == nil)
|
||||
}
|
||||
|
||||
@Test func mixedValidAndInvalidCharactersReturnNil() {
|
||||
let data = Data(hexString: "01GH")
|
||||
#expect(data == nil)
|
||||
}
|
||||
|
||||
@Test func specialCharactersReturnNil() {
|
||||
let data = Data(hexString: "01-02")
|
||||
#expect(data == nil)
|
||||
}
|
||||
|
||||
// MARK: - Round Trip Tests
|
||||
|
||||
@Test func roundTripConversion() {
|
||||
let original = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF])
|
||||
let hexString = original.hexEncodedString()
|
||||
let roundTripped = Data(hexString: hexString)
|
||||
#expect(roundTripped == original)
|
||||
}
|
||||
|
||||
@Test func roundTripConversionWith0xPrefix() {
|
||||
let original = Data([0xDE, 0xAD, 0xBE, 0xEF])
|
||||
let hexString = "0x" + original.hexEncodedString()
|
||||
let roundTripped = Data(hexString: hexString)
|
||||
#expect(roundTripped == original)
|
||||
}
|
||||
}
|
||||
@@ -426,4 +426,70 @@ struct PeerIDTests {
|
||||
#expect(!PeerID(str: "nostr:\(hex65)").isValid)
|
||||
#expect(!PeerID(str: "nostr_\(hex65)").isValid)
|
||||
}
|
||||
|
||||
// MARK: - File Transfer PeerID Normalization
|
||||
// These tests verify the fix for asymmetric voice/media delivery (BCH-01-XXX)
|
||||
// The bug occurred when selectedPrivateChatPeer was migrated to 64-hex stable key
|
||||
// but the receiver expected SHA256-derived 16-hex format
|
||||
|
||||
@Test func fileTransfer_toShortNormalizesNoiseKeyToFingerprint() {
|
||||
// Given: A 64-hex Noise public key (what selectedPrivateChatPeer becomes after session)
|
||||
let noiseKey = Data(repeating: 0xAB, count: 32)
|
||||
let stableKeyPeerID = PeerID(hexData: noiseKey) // 64-hex
|
||||
|
||||
// When: Convert to short form (what sendFilePrivate should do)
|
||||
let shortID = stableKeyPeerID.toShort()
|
||||
|
||||
// Then: Should be 16-hex SHA256 fingerprint (matching myPeerID format)
|
||||
let expected = noiseKey.sha256Fingerprint().prefix(16)
|
||||
#expect(shortID.id == String(expected))
|
||||
#expect(shortID.id.count == 16)
|
||||
}
|
||||
|
||||
@Test func fileTransfer_shortIDMatchesMyPeerIDFormat() {
|
||||
// Given: A receiver's myPeerID is SHA256-derived (from refreshPeerIdentity)
|
||||
let noiseKey = Data(repeating: 0xCD, count: 32)
|
||||
let myPeerID = PeerID(publicKey: noiseKey) // SHA256-derived 16-hex
|
||||
|
||||
// When: Sender uses toShort() on 64-hex stable key
|
||||
let senderStableKey = PeerID(hexData: noiseKey) // 64-hex
|
||||
let recipientData = Data(hexString: senderStableKey.toShort().id)!
|
||||
let receivedRecipientID = PeerID(hexData: recipientData)
|
||||
|
||||
// Then: Should match receiver's myPeerID (file transfer accepted)
|
||||
#expect(receivedRecipientID == myPeerID)
|
||||
}
|
||||
|
||||
@Test func fileTransfer_truncatedRawKeyDoesNotMatchMyPeerID() {
|
||||
// This test demonstrates the bug we fixed
|
||||
// When 64-hex was truncated to first 8 bytes instead of using SHA256 fingerprint
|
||||
|
||||
// Given: Receiver's myPeerID is SHA256-derived
|
||||
let noiseKey = Data(repeating: 0xEF, count: 32)
|
||||
let myPeerID = PeerID(publicKey: noiseKey) // SHA256-derived 16-hex
|
||||
|
||||
// When: Truncate raw key (the OLD buggy behavior)
|
||||
let truncatedRaw = noiseKey.prefix(8) // First 8 bytes of raw key
|
||||
let wrongRecipientID = PeerID(hexData: truncatedRaw)
|
||||
|
||||
// Then: Should NOT match (demonstrates why fix was needed)
|
||||
#expect(wrongRecipientID != myPeerID)
|
||||
}
|
||||
|
||||
@Test func fileTransfer_shortIDProducesCorrect8ByteRoutingData() {
|
||||
// Verify the wire format is correct (8 bytes for BinaryProtocol)
|
||||
let noiseKey = Data(repeating: 0x12, count: 32)
|
||||
let stableKeyPeerID = PeerID(hexData: noiseKey)
|
||||
let shortID = stableKeyPeerID.toShort()
|
||||
|
||||
// routingData should be 8 bytes (16 hex chars -> 8 bytes)
|
||||
let routingData = shortID.routingData
|
||||
#expect(routingData != nil)
|
||||
#expect(routingData?.count == 8)
|
||||
|
||||
// And it should match SHA256 fingerprint first 8 bytes
|
||||
let expectedFingerprint = noiseKey.sha256Fingerprint()
|
||||
let expectedFirst8 = Data(hexString: String(expectedFingerprint.prefix(16)))
|
||||
#expect(routingData == expectedFirst8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//
|
||||
// XChaCha20Poly1305CompatTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for XChaCha20-Poly1305 encryption with proper error handling.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import struct Foundation.Data
|
||||
@testable import bitchat
|
||||
|
||||
struct XChaCha20Poly1305CompatTests {
|
||||
|
||||
@Test func sealAndOpenRoundtrip() throws {
|
||||
let plaintext = "Hello, XChaCha20-Poly1305!".data(using: .utf8)!
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce)
|
||||
let decrypted = try XChaCha20Poly1305Compat.open(
|
||||
ciphertext: sealed.ciphertext,
|
||||
tag: sealed.tag,
|
||||
key: key,
|
||||
nonce24: nonce
|
||||
)
|
||||
|
||||
#expect(decrypted == plaintext)
|
||||
}
|
||||
|
||||
@Test func sealAndOpenWithAAD() throws {
|
||||
let plaintext = "Secret message".data(using: .utf8)!
|
||||
let key = Data(repeating: 0xAB, count: 32)
|
||||
let nonce = Data(repeating: 0xCD, count: 24)
|
||||
let aad = "additional authenticated data".data(using: .utf8)!
|
||||
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce, aad: aad)
|
||||
let decrypted = try XChaCha20Poly1305Compat.open(
|
||||
ciphertext: sealed.ciphertext,
|
||||
tag: sealed.tag,
|
||||
key: key,
|
||||
nonce24: nonce,
|
||||
aad: aad
|
||||
)
|
||||
|
||||
#expect(decrypted == plaintext)
|
||||
}
|
||||
|
||||
@Test func sealProducesDifferentCiphertextWithDifferentNonces() throws {
|
||||
let plaintext = "Same plaintext".data(using: .utf8)!
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let nonce1 = Data(repeating: 0x01, count: 24)
|
||||
let nonce2 = Data(repeating: 0x02, count: 24)
|
||||
|
||||
let sealed1 = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce1)
|
||||
let sealed2 = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce2)
|
||||
|
||||
#expect(sealed1.ciphertext != sealed2.ciphertext)
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnShortKey() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let shortKey = Data(repeating: 0x42, count: 16)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: shortKey, nonce24: nonce)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnLongKey() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let longKey = Data(repeating: 0x42, count: 64)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: longKey, nonce24: nonce)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnEmptyKey() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let emptyKey = Data()
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: emptyKey, nonce24: nonce)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
|
||||
@Test func openThrowsOnInvalidKeyLength() {
|
||||
let ciphertext = Data(repeating: 0x00, count: 16)
|
||||
let tag = Data(repeating: 0x00, count: 16)
|
||||
let shortKey = Data(repeating: 0x42, count: 31)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.open(ciphertext: ciphertext, tag: tag, key: shortKey, nonce24: nonce)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnShortNonce() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let shortNonce = Data(repeating: 0x24, count: 12)
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: shortNonce)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnLongNonce() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let longNonce = Data(repeating: 0x24, count: 32)
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: longNonce)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnEmptyNonce() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let emptyNonce = Data()
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: emptyNonce)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
|
||||
@Test func openThrowsOnInvalidNonceLength() {
|
||||
let ciphertext = Data(repeating: 0x00, count: 16)
|
||||
let tag = Data(repeating: 0x00, count: 16)
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let shortNonce = Data(repeating: 0x24, count: 23)
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.open(ciphertext: ciphertext, tag: tag, key: key, nonce24: shortNonce)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
|
||||
@Test func openFailsWithWrongKey() throws {
|
||||
let plaintext = "Secret".data(using: .utf8)!
|
||||
let correctKey = Data(repeating: 0x42, count: 32)
|
||||
let wrongKey = Data(repeating: 0x43, count: 32)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: correctKey, nonce24: nonce)
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.open(
|
||||
ciphertext: sealed.ciphertext,
|
||||
tag: sealed.tag,
|
||||
key: wrongKey,
|
||||
nonce24: nonce
|
||||
)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
|
||||
@Test func openFailsWithTamperedCiphertext() throws {
|
||||
let plaintext = "Secret".data(using: .utf8)!
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce)
|
||||
|
||||
// Create tampered ciphertext by changing first byte
|
||||
var tamperedBytes = [UInt8](sealed.ciphertext)
|
||||
tamperedBytes[0] = tamperedBytes[0] ^ 0xFF
|
||||
let tampered = Data(tamperedBytes)
|
||||
|
||||
var didThrow = false
|
||||
do {
|
||||
_ = try XChaCha20Poly1305Compat.open(
|
||||
ciphertext: tampered,
|
||||
tag: sealed.tag,
|
||||
key: key,
|
||||
nonce24: nonce
|
||||
)
|
||||
} catch {
|
||||
didThrow = true
|
||||
}
|
||||
#expect(didThrow)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# Geohash Presence Specification
|
||||
|
||||
## Overview
|
||||
|
||||
The Geohash Presence feature provides a mechanism to track online participants in geohash-based location channels. It uses a dedicated ephemeral Nostr event kind to broadcast "heartbeats," ensuring accurate and privacy-preserving online counts.
|
||||
|
||||
## Nostr Protocol
|
||||
|
||||
### Event Kind
|
||||
A new ephemeral event kind is defined for presence heartbeats:
|
||||
- **Kind:** `20001` (`GEOHASH_PRESENCE`)
|
||||
- **Type:** Ephemeral (not stored by relays long-term)
|
||||
|
||||
### Event Structure
|
||||
The presence event mimics the structure of a geohash chat message (Kind 20000) but without content or nickname metadata, to minimize overhead and focus purely on "liveness".
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 20001,
|
||||
"created_at": <timestamp>,
|
||||
"tags": [
|
||||
["g", "<geohash>"]
|
||||
],
|
||||
"content": "",
|
||||
"pubkey": "<geohash_derived_pubkey>",
|
||||
"id": "<event_id>",
|
||||
"sig": "<signature>"
|
||||
}
|
||||
```
|
||||
|
||||
* **`content`**: Must be empty string.
|
||||
* **`tags`**: Must include `["g", "<geohash>"]`. Should NOT include `["n", "<nickname>"]`.
|
||||
* **`pubkey`**: The ephemeral identity derived specifically for this geohash (same as used for chat messages).
|
||||
|
||||
## Client Behavior
|
||||
|
||||
### 1. Broadcasting Presence
|
||||
|
||||
Clients MUST broadcast a Kind 20001 presence event globally when the app is open, regardless of which screen the user is viewing.
|
||||
|
||||
* **Global Heartbeat:**
|
||||
* **Trigger:** Application start / initialization, or whenever location (available geohashes) changes.
|
||||
* **Frequency:** Randomized loop interval between **40s and 80s** (average 60s).
|
||||
* **Scope:** Sent to *all* geohash channels corresponding to the device's *current physical location*.
|
||||
* **Privacy Restriction:** Presence MUST ONLY be broadcast to low-precision geohash levels to protect user privacy. Specifically:
|
||||
* **Allowed:** `REGION` (precision 2), `PROVINCE` (precision 4), `CITY` (precision 5).
|
||||
* **Denied:** `NEIGHBORHOOD` (precision 6), `BLOCK` (precision 7), `BUILDING` (precision 8+).
|
||||
* **Decorrelation:** Individual broadcasts within a heartbeat loop must be separated by random delays (e.g., 2-5 seconds) to prevent temporal correlation of public keys across different geohash levels. The main loop delay is adjusted to maintain the target average cadence.
|
||||
|
||||
### 2. Subscribing to Presence
|
||||
|
||||
Clients must update their Nostr filters to listen for both chat and presence events on geohash channels.
|
||||
|
||||
* **Filter:**
|
||||
* `kinds`: `[20000, 20001]`
|
||||
* `#g`: `["<geohash>"]`
|
||||
|
||||
### 3. Participant Counting
|
||||
|
||||
The "online participants" count shown in the UI aggregates unique public keys from both presence heartbeats and active chat messages.
|
||||
|
||||
* **Logic:**
|
||||
* Maintain a map of `pubkey -> last_seen_timestamp` for each geohash.
|
||||
* Update `last_seen_timestamp` upon receiving a valid **Kind 20001 (Presence)** OR **Kind 20000 (Chat)** event.
|
||||
* A participant is considered "online" if their `last_seen_timestamp` is within the last **5 minutes**.
|
||||
|
||||
### 4. UI Presentation
|
||||
|
||||
The presentation of the participant count depends on the geohash precision level and data availability.
|
||||
|
||||
* **Standard Display:** For channels where presence is broadcast (Region, Province, City) OR any channel where at least one participant has been detected, show the exact count: `[N people]`.
|
||||
* **High-Precision Uncertainty:** For high-precision channels (Neighborhood, Block, Building) where:
|
||||
* Presence broadcasting is disabled (privacy restriction).
|
||||
* **AND** the detected participant count is `0`.
|
||||
* **Display:** `[? people]`
|
||||
* **Reasoning:** Since clients don't announce themselves in these channels, a count of "0" is misleading (people could be lurking).
|
||||
|
||||
### 5. Implementation Details (Android Reference)
|
||||
|
||||
* **`NostrKind.GEOHASH_PRESENCE`**: Added constant `20001`.
|
||||
* **`NostrProtocol.createGeohashPresenceEvent`**: Helper to generate the event.
|
||||
* **`GeohashViewModel`**:
|
||||
* `startGlobalPresenceHeartbeat()`: Coroutine that `collectLatest` on `LocationChannelManager.availableChannels`.
|
||||
* Implements randomized loop logic (40-80s) and per-broadcast random delays (2-5s).
|
||||
* Filters channels by `precision <= 5` before broadcasting.
|
||||
* **`GeohashMessageHandler`**:
|
||||
* Refactored `onEvent` to update participant counts for both Kind 20000 and 20001.
|
||||
* **`LocationChannelsSheet`**:
|
||||
* Implements the `[? people]` display logic for high-precision, zero-count channels.
|
||||
|
||||
## Benefits
|
||||
|
||||
* **Accuracy:** Counts reflect both active listeners (via heartbeats) and active speakers (via messages).
|
||||
* **Privacy:** High-precision location presence is NOT broadcast. Temporal correlation between different levels is obfuscated via random delays.
|
||||
* **Consistency:** "Online" status is maintained globally while the app is open.
|
||||
* **Transparency:** The UI correctly reflects uncertainty (`?`) when privacy rules prevent accurate passive counting.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Request Sync Manager & V2 Packet Updates
|
||||
|
||||
This document details the implementation of the Request Sync Manager and updates to the V2 packet structure to improve synchronization security and attribution on iOS, mirroring the Android implementation.
|
||||
|
||||
## Overview
|
||||
|
||||
The goal of these changes is to make the request sync functionality "less blind". Previously, sync requests were broadcast, and responses were accepted without strict attribution or timestamp validation (to allow syncing old messages). This opened up potential spoofing vectors and prevented us from enforcing timestamp checks on normal traffic.
|
||||
|
||||
The new implementation introduces a **RequestSyncManager** to track outgoing sync requests and attributes incoming responses (RSR - Request-Sync Response) to specific peers. This allows us to:
|
||||
1. **Enforce Timestamp Validation**: Normal packets now require timestamps to be within 2 minutes of the local clock.
|
||||
2. **Exempt Solicited Sync Responses**: Packets marked as RSR are exempt from timestamp validation *only if* they correspond to a valid, pending sync request sent to that specific peer.
|
||||
3. **Prevent Unsolicited Sync Floods**: Unsolicited RSR packets are rejected.
|
||||
|
||||
## Protocol Changes
|
||||
|
||||
### Binary Protocol Updates
|
||||
* **New Flag**: `IS_RSR` (0x10) added to the packet header flags.
|
||||
* **BitchatPacket**: Updated to include `isRSR: Bool` field.
|
||||
* **Encoding/Decoding**: Updated `BinaryProtocol` to handle the new flag.
|
||||
|
||||
### Request Sync Payload
|
||||
The `REQUEST_SYNC` packet payload (TLV encoded) has been updated to include:
|
||||
* **Future Filters**:
|
||||
* `sinceTimestamp` (Type 0x05): To request packets since a certain time (UInt64 big-endian).
|
||||
* `fragmentIdFilter` (Type 0x06): To request specific fragments (UTF-8 string).
|
||||
|
||||
## Architecture
|
||||
|
||||
### RequestSyncManager
|
||||
A new component (`Sync/RequestSyncManager.swift`) responsible for:
|
||||
* **Tracking**: Stores `peerID -> timestamp` mappings for pending sync requests.
|
||||
* **Validation**: `isValidResponse(from: PeerID, isRSR: Bool)` checks if an incoming RSR packet matches a pending request within the 30-second window.
|
||||
* **Cleanup**: Periodically removes expired requests.
|
||||
|
||||
### GossipSyncManager Updates
|
||||
* **Unicast Sync**: Instead of blind broadcasting, the periodic sync task now iterates over connected peers and sends unicast `REQUEST_SYNC` packets.
|
||||
* **Registration**: Before sending, requests are registered with `RequestSyncManager`.
|
||||
* **Response Marking**: When responding to a `REQUEST_SYNC`, generated packets (Announce/Message) are explicitly marked with `isRSR = true` (and `ttl = 0`).
|
||||
|
||||
### BLEService (Security Manager) Updates
|
||||
* **Timestamp Enforcement**: Checks `abs(now - packetTimestamp) < 2 minutes` for standard packets.
|
||||
* **Conditional Exemption**: If `packet.isRSR` is true (or packet is a legacy TTL=0 response), it queries `RequestSyncManager`.
|
||||
* **Valid**: If solicited, timestamp check is skipped (allowing historical data sync).
|
||||
* **Invalid**: If unsolicited or timed out, the packet is rejected.
|
||||
|
||||
## Usage
|
||||
|
||||
These changes are integrated into `BLEService` and `GossipSyncManager`. No external API changes are required for clients, but all peers must be updated to support the new `IS_RSR` flag and protocol logic to participate in the secure sync process.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Source-Based Routing for BitChat Packets (v2)
|
||||
|
||||
This document specifies the Source-Based Routing extension (v2) for the BitChat protocol. This upgrade enables efficient unicast routing across the mesh by allowing senders to specify an explicit path of intermediate relays.
|
||||
|
||||
**Status:** Implemented in Android and iOS. Backward compatible (v1 clients ignore routing data).
|
||||
|
||||
---
|
||||
|
||||
## 1. Protocol Versioning & Layering
|
||||
|
||||
To support source routing and larger payloads, the packet format has been upgraded to **Version 2**.
|
||||
|
||||
* **Version 1 (Legacy):** 2-byte payload length limit. Ignores routing flags.
|
||||
* **Version 2 (Current):** 4-byte payload length limit. Supports Source Routing.
|
||||
|
||||
**Key Rule:** The `HAS_ROUTE (0x08)` flag is **only valid** if the packet `version >= 2`. Relays receiving a v1 packet must ignore this flag even if set.
|
||||
|
||||
---
|
||||
|
||||
## 2. Packet Structure Comparison
|
||||
|
||||
The following diagram illustrates the structural differences between a standard v1 packet and a source-routed v2 packet.
|
||||
|
||||
### V1 Packet (Legacy)
|
||||
```text
|
||||
+-------------------+---------------------------------------------------------+
|
||||
| Fixed Header (14) | Variable Sections |
|
||||
+-------------------+----------+-------------+------------------+-------------+
|
||||
| Ver: 1 (1B) | SenderID | RecipientID | Payload | Signature |
|
||||
| Type, TTL, etc. | (8B) | (8B) | (Length in Head) | (64B) |
|
||||
| Len: 2 Bytes | | (Optional) | | (Optional) |
|
||||
+-------------------+----------+-------------+------------------+-------------+
|
||||
```
|
||||
|
||||
### V2 Packet (Source Routed)
|
||||
```text
|
||||
+-------------------+-----------------------------------------------------------------------------+
|
||||
| Fixed Header (16) | Variable Sections |
|
||||
+-------------------+----------+-------------+-----------------------+------------------+-------------+
|
||||
| Ver: 2 (1B) | SenderID | RecipientID | SOURCE ROUTE | Payload | Signature |
|
||||
| Type, TTL, etc. | (8B) | (8B) | (Variable) | (Length in Head) | (64B) |
|
||||
| Len: 4 Bytes | | (Required*) | Only if HAS_ROUTE=1 | | (Optional) |
|
||||
+-------------------+----------+-------------+-----------------------+------------------+-------------+
|
||||
```
|
||||
|
||||
**(*) Note:** A `Route` can be attached to **any** packet type that has a `RecipientID` (flag `HAS_RECIPIENT` set).
|
||||
|
||||
### Fixed Header Differences
|
||||
|
||||
| Field | Size (v1) | Size (v2) | Description |
|
||||
|---|---|---|---|
|
||||
| **Version** | 1 byte | 1 byte | `0x01` vs `0x02` |
|
||||
| **Payload Length** | **2 bytes** | **4 bytes** | `UInt32` in v2 to support large files. **Excludes** route/IDs/sig. |
|
||||
| **Total Size** | **14 bytes** | **16 bytes** | V2 header is 2 bytes larger. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Source Route Specification
|
||||
|
||||
The `Source Route` field is a variable-length list of **intermediate hops** that the packet must traverse.
|
||||
|
||||
* **Location:** Immediately follows `RecipientID`.
|
||||
* **Structure:**
|
||||
* `Count` (1 byte): Number of intermediate hops (`N`).
|
||||
* `Hops` (`N * 8` bytes): Sequence of Peer IDs.
|
||||
|
||||
### Intermediate Hops Only
|
||||
The route list MUST contain **only** the intermediate relays between the sender and the recipient.
|
||||
* **DO NOT** include the `SenderID` (it is already in the packet).
|
||||
* **DO NOT** include the `RecipientID` (it is already in the packet).
|
||||
|
||||
**Example:**
|
||||
Topology: `Alice (Sender) -> Bob -> Charlie -> Dave (Recipient)`
|
||||
* Packet `SenderID`: Alice
|
||||
* Packet `RecipientID`: Dave
|
||||
* Packet `Route`: `[Bob, Charlie]` (Count = 2)
|
||||
|
||||
---
|
||||
|
||||
## 4. Topology Discovery (Gossip)
|
||||
|
||||
To calculate routes, nodes need a view of the network topology. This is achieved via a **Neighbor List** extension to the `IdentityAnnouncement` packet.
|
||||
|
||||
The `ANNOUNCE` packet payload now consists of a sequence of TLVs. The standard identity information is followed by an optional Gossip TLV.
|
||||
|
||||
* **Mechanism:** Appended to the `IdentityAnnouncement` payload.
|
||||
* **New TLV Type:** `0x04` (Direct Neighbors).
|
||||
* **Content:** A list of Peer IDs that the announcing node is directly connected to.
|
||||
|
||||
**TLV Structure (Type 0x04):**
|
||||
```text
|
||||
[Type: 0x04] [Length: 1B] [NeighborID1 (8B)] [NeighborID2 (8B)] ...
|
||||
```
|
||||
The `Length` field indicates the total size of the neighbor IDs in bytes (N * 8). There is no explicit count field.
|
||||
|
||||
Nodes receiving this TLV update their local mesh graph, linking the sender to the listed neighbors.
|
||||
|
||||
### Edge Verification (Two-Way Handshake)
|
||||
|
||||
To prevent spoofing and routing through stale connections, the Mesh Graph service implements a strict two-way handshake verification:
|
||||
|
||||
* **Unconfirmed Edge:** If Peer A announces Peer B, but Peer B does *not* announce Peer A, the connection is treated as **unconfirmed**. Unconfirmed edges are visualized as dotted lines in debug tools but are **excluded** from route calculations.
|
||||
* **Confirmed Edge:** An edge is only valid for routing when **both** peers explicitly announce each other in their neighbor lists. This ensures that the connection is bidirectional and currently active from both perspectives.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fragmentation & Source Routing
|
||||
|
||||
When a large source-routed packet (e.g., File Transfer) exceeds the MTU and requires fragmentation:
|
||||
|
||||
1. **Version Inheritance:** All fragments MUST be marked as **Version 2**.
|
||||
2. **Route Inheritance:** All fragments MUST contain the **exact same Route field** as the parent packet.
|
||||
|
||||
**Why?** If fragments were sent as v1 packets or without routes, they would fall back to flooding, negating the bandwidth benefits of source routing for large data transfers.
|
||||
|
||||
---
|
||||
|
||||
## 6. Security & Signing
|
||||
|
||||
Source routing is fully secured by the existing Ed25519 signature scheme.
|
||||
|
||||
* **Scope:** The signature covers the **entire packet structure** (Header + Sender + Recipient + Route + Payload).
|
||||
* **Verification:** The receiver verifies the signature against the `SenderID`'s public key.
|
||||
* **Integrity:** Any tampering with the route list by malicious relays will invalidate the signature, causing the packet to be dropped by the destination.
|
||||
|
||||
**Signature Input Construction:**
|
||||
Serialize the packet exactly as transmitted, but temporarily set `TTL = 0` and remove the `Signature` bytes.
|
||||
|
||||
---
|
||||
|
||||
## 7. Relay Logic
|
||||
|
||||
When a node receives a packet **not** addressed to itself:
|
||||
|
||||
1. **Check Route:**
|
||||
* Is `Version >= 2`?
|
||||
* Is `HAS_ROUTE` flag set?
|
||||
* Is the route list non-empty?
|
||||
2. **If YES (Source Routed):**
|
||||
* Find local Peer ID in the route list at index `i`.
|
||||
* **Next Hop:** The peer at `i + 1`.
|
||||
* **Last Hop:** If `i` is the last index, the Next Hop is the `RecipientID`.
|
||||
* **Action:** Attempt to unicast (`sendToPeer`) to the Next Hop.
|
||||
* **Fallback:** If the Next Hop is unreachable, **fall back to broadcast/flood** to ensure delivery.
|
||||
3. **If NO (Standard):**
|
||||
* Flood the packet to all connected neighbors (subject to TTL and probability rules).
|
||||
@@ -0,0 +1,3 @@
|
||||
/target/
|
||||
/.build/
|
||||
/.swiftpm/
|
||||
Generated
+5046
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["arti-bitchat"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
+24
-18
@@ -6,11 +6,29 @@
|
||||
<array>
|
||||
<dict>
|
||||
<key>BinaryPath</key>
|
||||
<string>tor-nolzma.framework/tor-nolzma</string>
|
||||
<string>libarti_bitchat.a</string>
|
||||
<key>HeadersPath</key>
|
||||
<string>Headers</string>
|
||||
<key>LibraryIdentifier</key>
|
||||
<string>macos-arm64</string>
|
||||
<key>LibraryPath</key>
|
||||
<string>libarti_bitchat.a</string>
|
||||
<key>SupportedArchitectures</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>SupportedPlatform</key>
|
||||
<string>macos</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>BinaryPath</key>
|
||||
<string>libarti_bitchat.a</string>
|
||||
<key>HeadersPath</key>
|
||||
<string>Headers</string>
|
||||
<key>LibraryIdentifier</key>
|
||||
<string>ios-arm64</string>
|
||||
<key>LibraryPath</key>
|
||||
<string>tor-nolzma.framework</string>
|
||||
<string>libarti_bitchat.a</string>
|
||||
<key>SupportedArchitectures</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
@@ -20,11 +38,13 @@
|
||||
</dict>
|
||||
<dict>
|
||||
<key>BinaryPath</key>
|
||||
<string>tor-nolzma.framework/tor-nolzma</string>
|
||||
<string>libarti_bitchat.a</string>
|
||||
<key>HeadersPath</key>
|
||||
<string>Headers</string>
|
||||
<key>LibraryIdentifier</key>
|
||||
<string>ios-arm64-simulator</string>
|
||||
<key>LibraryPath</key>
|
||||
<string>tor-nolzma.framework</string>
|
||||
<string>libarti_bitchat.a</string>
|
||||
<key>SupportedArchitectures</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
@@ -34,20 +54,6 @@
|
||||
<key>SupportedPlatformVariant</key>
|
||||
<string>simulator</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>BinaryPath</key>
|
||||
<string>tor-nolzma.framework/Versions/A/tor-nolzma</string>
|
||||
<key>LibraryIdentifier</key>
|
||||
<string>macos-arm64</string>
|
||||
<key>LibraryPath</key>
|
||||
<string>tor-nolzma.framework</string>
|
||||
<key>SupportedArchitectures</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>SupportedPlatform</key>
|
||||
<string>macos</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XFWK</string>
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
#ifndef ARTI_H
|
||||
#define ARTI_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
/**
|
||||
* Start Arti with a SOCKS5 proxy.
|
||||
*
|
||||
* # Arguments
|
||||
* * `data_dir` - Path to data directory for Tor state (C string)
|
||||
* * `socks_port` - Port for SOCKS5 proxy (e.g., 39050)
|
||||
*
|
||||
* # Returns
|
||||
* * 0 on success
|
||||
* * -1 if already running
|
||||
* * -2 if data_dir is invalid
|
||||
* * -3 if runtime initialization failed
|
||||
* * -4 if bootstrap failed
|
||||
*/
|
||||
int arti_start(const char *data_dir, uint16_t socks_port);
|
||||
|
||||
/**
|
||||
* Stop Arti gracefully.
|
||||
*
|
||||
* # Returns
|
||||
* * 0 on success
|
||||
* * -1 if not running
|
||||
*/
|
||||
int arti_stop(void);
|
||||
|
||||
/**
|
||||
* Check if Arti is currently running.
|
||||
*
|
||||
* # Returns
|
||||
* * 1 if running
|
||||
* * 0 if not running
|
||||
*/
|
||||
int arti_is_running(void);
|
||||
|
||||
/**
|
||||
* Get the current bootstrap progress (0-100).
|
||||
*/
|
||||
int arti_bootstrap_progress(void);
|
||||
|
||||
/**
|
||||
* Get the current bootstrap summary string.
|
||||
*
|
||||
* # Arguments
|
||||
* * `buf` - Buffer to write the summary into
|
||||
* * `len` - Length of the buffer
|
||||
*
|
||||
* # Returns
|
||||
* * Number of bytes written (not including null terminator)
|
||||
* * -1 if buffer is null or too small
|
||||
*/
|
||||
int arti_bootstrap_summary(char *buf, int len);
|
||||
|
||||
/**
|
||||
* Signal Arti to go dormant (reduce resource usage).
|
||||
* This is a hint; Arti may not fully support dormant mode yet.
|
||||
*
|
||||
* # Returns
|
||||
* * 0 on success
|
||||
* * -1 if not running
|
||||
*/
|
||||
int arti_go_dormant(void);
|
||||
|
||||
/**
|
||||
* Signal Arti to wake from dormant mode.
|
||||
*
|
||||
* # Returns
|
||||
* * 0 on success
|
||||
* * -1 if not running
|
||||
*/
|
||||
int arti_wake(void);
|
||||
|
||||
#endif /* ARTI_H */
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,78 @@
|
||||
#ifndef ARTI_H
|
||||
#define ARTI_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
/**
|
||||
* Start Arti with a SOCKS5 proxy.
|
||||
*
|
||||
* # Arguments
|
||||
* * `data_dir` - Path to data directory for Tor state (C string)
|
||||
* * `socks_port` - Port for SOCKS5 proxy (e.g., 39050)
|
||||
*
|
||||
* # Returns
|
||||
* * 0 on success
|
||||
* * -1 if already running
|
||||
* * -2 if data_dir is invalid
|
||||
* * -3 if runtime initialization failed
|
||||
* * -4 if bootstrap failed
|
||||
*/
|
||||
int arti_start(const char *data_dir, uint16_t socks_port);
|
||||
|
||||
/**
|
||||
* Stop Arti gracefully.
|
||||
*
|
||||
* # Returns
|
||||
* * 0 on success
|
||||
* * -1 if not running
|
||||
*/
|
||||
int arti_stop(void);
|
||||
|
||||
/**
|
||||
* Check if Arti is currently running.
|
||||
*
|
||||
* # Returns
|
||||
* * 1 if running
|
||||
* * 0 if not running
|
||||
*/
|
||||
int arti_is_running(void);
|
||||
|
||||
/**
|
||||
* Get the current bootstrap progress (0-100).
|
||||
*/
|
||||
int arti_bootstrap_progress(void);
|
||||
|
||||
/**
|
||||
* Get the current bootstrap summary string.
|
||||
*
|
||||
* # Arguments
|
||||
* * `buf` - Buffer to write the summary into
|
||||
* * `len` - Length of the buffer
|
||||
*
|
||||
* # Returns
|
||||
* * Number of bytes written (not including null terminator)
|
||||
* * -1 if buffer is null or too small
|
||||
*/
|
||||
int arti_bootstrap_summary(char *buf, int len);
|
||||
|
||||
/**
|
||||
* Signal Arti to go dormant (reduce resource usage).
|
||||
* This is a hint; Arti may not fully support dormant mode yet.
|
||||
*
|
||||
* # Returns
|
||||
* * 0 on success
|
||||
* * -1 if not running
|
||||
*/
|
||||
int arti_go_dormant(void);
|
||||
|
||||
/**
|
||||
* Signal Arti to wake from dormant mode.
|
||||
*
|
||||
* # Returns
|
||||
* * 0 on success
|
||||
* * -1 if not running
|
||||
*/
|
||||
int arti_wake(void);
|
||||
|
||||
#endif /* ARTI_H */
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user