Merge branch 'master' into ep/parallel-tests

This commit is contained in:
Evgeny @ SimpleX Chat
2026-10-05 12:18:58 +00:00
169 changed files with 5496 additions and 664 deletions
+1
View File
@@ -123,6 +123,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
private func prepareForLaunch() {
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
excludeAppDataFromBackup()
}
static func keepScreenOn(_ on: Bool) {
+49 -12
View File
@@ -2361,6 +2361,10 @@ func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws
ChatReceiver.shared.start()
m.chatRunning = true
chatLastStartGroupDefault.set(Date.now)
if shouldDeleteDatabaseBackupsDefault.get() {
deleteDatabaseBackups()
shouldDeleteDatabaseBackupsDefault.set(false)
}
}
func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? {
@@ -2910,23 +2914,19 @@ func processReceivedMsg(_ res: ChatEvent) async {
m.callInvitations[invitation.contact.id] = invitation
}
activateCall(invitation)
case let .callOffer(_, contact, callType, offer, sharedKey, _):
case let .callOffer(_, contact, callType, offer, sharedKey, askConfirmation):
await withCall(contact) { call in
await MainActor.run {
call.callState = .offerReceived
call.sharedKey = sharedKey
}
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
let iceServers = getIceServers()
logger.debug(".callOffer useRelay \(useRelay)")
logger.debug(".callOffer iceServers \(String(describing: iceServers))")
await m.callCommand.processCommand(.offer(
offer: offer.rtcSession,
iceCandidates: offer.rtcIceCandidates,
media: callType.media, aesKey: sharedKey,
iceServers: iceServers,
relay: useRelay
))
if askConfirmation {
showUnencryptedCallAlert(call) {
Task { await processCallOffer(callType, offer, sharedKey) }
}
} else {
await processCallOffer(callType, offer, sharedKey)
}
}
case let .callAnswer(_, contact, answer):
await withCall(contact) { call in
@@ -3063,6 +3063,43 @@ func processReceivedMsg(_ res: ChatEvent) async {
logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)")
}
}
func processCallOffer(_ callType: CallType, _ offer: WebRTCSession, _ sharedKey: String?) async {
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
let iceServers = getIceServers()
logger.debug(".callOffer useRelay \(useRelay)")
logger.debug(".callOffer iceServers \(String(describing: iceServers))")
await m.callCommand.processCommand(.offer(
offer: offer.rtcSession,
iceCandidates: offer.rtcIceCandidates,
media: callType.media, aesKey: sharedKey,
iceServers: iceServers,
relay: useRelay
))
}
func showUnencryptedCallAlert(_ call: Call, onContinue: @escaping () -> Void) {
DispatchQueue.main.async {
showAlert(
NSLocalizedString("Call is not encrypted", comment: "alert title"),
message: String.localizedStringWithFormat(NSLocalizedString("%@ accepted the call without end-to-end encryption.", comment: "alert message"), call.contact.displayName),
actions: {[
UIAlertAction(title: NSLocalizedString("End call", comment: "alert action"), style: .destructive) { _ in
// the call may have ended, or a new one started with the same contact, while the alert was shown
guard m.activeCall === call else { return }
if let uuid = call.callUUID {
CallController.shared.endCall(callUUID: uuid)
} else {
CallController.shared.endCall(call: call) {}
}
},
UIAlertAction(title: NSLocalizedString("Continue", comment: "alert action"), style: .default) { _ in
if m.activeCall === call { onContinue() }
}
]}
)
}
}
}
func switchToLocalSession() {
@@ -117,7 +117,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
encryptor.delegate = self
frameEncryptor = encryptor
let decryptor = RTCFrameDecryptor.init(sizeChange: -Int32(WebRTCClient.ivTagBytes))
let decryptor = RTCFrameDecryptor.init(sizeChange: 0)
decryptor.delegate = self
frameDecryptor = decryptor
}
@@ -508,11 +508,12 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func frameDecryptor(_ decryptor: RTCFrameDecryptor, mediaType: RTCRtpMediaType, withFrame encrypted: Data) -> Data? {
guard encrypted.count > 0 else { return nil }
let isKeyFrame = encrypted[0] & 1 == 0
let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3
guard encrypted.count >= clearTextBytesSize + WebRTCClient.ivTagBytes else { return nil }
if var key: [CChar] = activeCall?.aesKey?.cString(using: .utf8),
let pointer: UnsafeMutableRawPointer = malloc(encrypted.count) {
memcpy(pointer, (encrypted as NSData).bytes, encrypted.count)
let isKeyFrame = encrypted[0] & 1 == 0
let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3
logCrypto("decrypt", chat_decrypt_media(&key, pointer.advanced(by: clearTextBytesSize), Int32(encrypted.count - clearTextBytesSize)))
return Data(bytes: pointer, count: encrypted.count - WebRTCClient.ivTagBytes)
} else {
@@ -522,11 +523,12 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
func frameEncryptor(_ encryptor: RTCFrameEncryptor, mediaType: RTCRtpMediaType, withFrame unencrypted: Data) -> Data? {
guard unencrypted.count > 0 else { return nil }
let isKeyFrame = unencrypted[0] & 1 == 0
let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3
guard unencrypted.count >= clearTextBytesSize else { return nil }
if var key: [CChar] = activeCall?.aesKey?.cString(using: .utf8),
let pointer: UnsafeMutableRawPointer = malloc(unencrypted.count + WebRTCClient.ivTagBytes) {
memcpy(pointer, (unencrypted as NSData).bytes, unencrypted.count)
let isKeyFrame = unencrypted[0] & 1 == 0
let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3
logCrypto("encrypt", chat_encrypt_media(chat_ctrl, &key, pointer.advanced(by: clearTextBytesSize), Int32(unencrypted.count + WebRTCClient.ivTagBytes - clearTextBytesSize)))
return Data(bytes: pointer, count: unencrypted.count + WebRTCClient.ivTagBytes)
} else {
@@ -153,6 +153,9 @@ struct DatabaseEncryptionView: View {
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
}
try await apiStorageEncryption(currentKey: currentKey, newKey: newKey)
if currentKey != newKey {
shouldDeleteDatabaseBackupsDefault.set(true)
}
encryptionStartedDefault.set(false)
initialRandomDBPassphraseGroupDefault.set(false)
if migration {
@@ -118,6 +118,9 @@ struct DatabaseErrorView: View {
case .errorKeychain:
titleText("Keychain error")
errorView(Text("Cannot access keychain to save database password"))
case .errorKeyGeneration:
titleText("Database error")
errorView(Text("Cannot generate random database passphrase"))
case .invalidConfirmation:
// this can only happen if incorrect parameter is passed
titleText("Invalid migration confirmation")
@@ -217,6 +220,8 @@ struct DatabaseErrorView: View {
)
case .errorKeychain:
am.showAlertMsg(title: "Keychain error")
case .errorKeyGeneration:
am.showAlertMsg(title: "Database error", message: "Cannot generate random database passphrase")
case let .errorSQL(_, error):
am.showAlert(Alert(
title: Text("Database error"),
@@ -453,6 +453,10 @@ struct DatabaseView: View {
ChatReceiver.shared.start()
chatLastStartGroupDefault.set(Date.now)
AppChatState.shared.set(.active)
if shouldDeleteDatabaseBackupsDefault.get() {
deleteDatabaseBackups()
shouldDeleteDatabaseBackupsDefault.set(false)
}
} catch let error {
runChat.wrappedValue = false
showAlert(NSLocalizedString("Error starting chat", comment: ""), message: responseError(error))
@@ -709,6 +709,8 @@ private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert:
alert.wrappedValue = .wrongPassphrase()
case .errorKeychain:
alert.wrappedValue = .keychainError()
case .errorKeyGeneration:
alert.wrappedValue = .databaseError(message: NSLocalizedString("Cannot generate random database passphrase", comment: "alert message"))
case let .errorSQL(_, error):
alert.wrappedValue = .databaseError(message: error)
case let .unknown(error):
@@ -539,7 +539,7 @@ struct MigrateToDevice: View {
Task {
do {
if !hasChatCtrl() {
chatInitControllerRemovingDatabases()
try chatInitControllerRemovingDatabases()
} else if ChatModel.shared.chatRunning == true {
// cannot delete storage if chat is running
try await stopChatAsync()
@@ -735,6 +735,8 @@ private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert:
alert.wrappedValue = .wrongPassphrase()
case .errorKeychain:
alert.wrappedValue = .keychainError()
case .errorKeyGeneration:
alert.wrappedValue = .databaseError(message: NSLocalizedString("Cannot generate random database passphrase", comment: "alert message"))
case let .errorSQL(_, error):
alert.wrappedValue = .databaseError(message: error)
case let .unknown(error):
@@ -49,6 +49,7 @@ let DEFAULT_SHOULD_IMPORT_APP_SETTINGS = "shouldImportAppSettings"
let DEFAULT_DEVELOPER_TOOLS = "developerTools"
let DEFAULT_ENCRYPTION_STARTED = "encryptionStarted"
let DEFAULT_ENCRYPTION_STARTED_AT = "encryptionStartedAt"
let DEFAULT_SHOULD_DELETE_DATABASE_BACKUPS = "shouldDeleteDatabaseBackups"
let DEFAULT_ACCENT_COLOR_RED = "accentColorRed" // deprecated, only used for migration
let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen" // deprecated, only used for migration
let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" // deprecated, only used for migration
@@ -207,6 +208,8 @@ let encryptionStartedDefault = BoolDefault(defaults: UserDefaults.standard, forK
let encryptionStartedAtDefault = DateDefault(defaults: UserDefaults.standard, forKey: DEFAULT_ENCRYPTION_STARTED_AT)
let shouldDeleteDatabaseBackupsDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOULD_DELETE_DATABASE_BACKUPS)
let connectViaLinkTabDefault = EnumDefault<ConnectViaLinkTab>(defaults: UserDefaults.standard, forKey: DEFAULT_CONNECT_VIA_LINK_TAB, withDefault: .scan)
let privacySimplexLinkModeDefault = EnumDefault<SimpleXLinkMode>(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_SIMPLEX_LINK_MODE, withDefault: .description)
+5
View File
@@ -247,6 +247,11 @@ class ShareModel: ObservableObject {
title: "Keychain error",
message: "Cannot access keychain to save database password"
)
case .errorKeyGeneration:
ErrorAlert(
title: "Database error",
message: "Cannot generate random database passphrase"
)
case .invalidConfirmation:
ErrorAlert("Invalid migration confirmation")
case let .unknown(json):
+18 -18
View File
@@ -193,8 +193,8 @@
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; };
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.8-HxXQtneh1H7HWR541ntOgE-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.8-HxXQtneh1H7HWR541ntOgE-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.8-HxXQtneh1H7HWR541ntOgE.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.8-HxXQtneh1H7HWR541ntOgE.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */; };
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; };
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
@@ -586,8 +586,8 @@
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.8-HxXQtneh1H7HWR541ntOgE-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.8-HxXQtneh1H7HWR541ntOgE-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.8-HxXQtneh1H7HWR541ntOgE.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.8-HxXQtneh1H7HWR541ntOgE.a"; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a"; sourceTree = "<group>"; };
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
@@ -760,8 +760,8 @@
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */,
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */,
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.8-HxXQtneh1H7HWR541ntOgE-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.8-HxXQtneh1H7HWR541ntOgE.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -848,8 +848,8 @@
64C829992D54AEEE006B9E89 /* libffi.a */,
64C829982D54AEED006B9E89 /* libgmp.a */,
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.8-HxXQtneh1H7HWR541ntOgE-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.8-HxXQtneh1H7HWR541ntOgE.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -2139,7 +2139,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 357;
CURRENT_PROJECT_VERSION = 362;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2189,7 +2189,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 357;
CURRENT_PROJECT_VERSION = 362;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2231,7 +2231,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 357;
CURRENT_PROJECT_VERSION = 362;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2251,7 +2251,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 357;
CURRENT_PROJECT_VERSION = 362;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2276,7 +2276,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 357;
CURRENT_PROJECT_VERSION = 362;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@@ -2313,7 +2313,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 357;
CURRENT_PROJECT_VERSION = 362;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@@ -2350,7 +2350,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 357;
CURRENT_PROJECT_VERSION = 362;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2401,7 +2401,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 357;
CURRENT_PROJECT_VERSION = 362;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2455,7 +2455,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 357;
CURRENT_PROJECT_VERSION = 362;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2489,7 +2489,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 357;
CURRENT_PROJECT_VERSION = 362;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
+10 -4
View File
@@ -32,7 +32,12 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio
} else if useKeychain {
if !hasDatabase() {
logger.debug("chatMigrateInit generating a random DB key")
dbKey = randomDatabasePassword()
guard let key = randomDatabasePassword() else {
let result = (false, DBMigrationResult.errorKeyGeneration)
migrationResult = result
return result
}
dbKey = key
initialRandomDBPassphraseGroupDefault.set(true)
} else if let key = kcDatabasePassword.get() {
dbKey = key
@@ -56,7 +61,7 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio
public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation: MigrationConfirmation = .error) -> (DBMigrationResult, chat_ctrl?) {
let dbPath = url.path
let dbKey = key ?? randomDatabasePassword()
guard let dbKey = key ?? randomDatabasePassword() else { return (.errorKeyGeneration, nil) }
logger.debug("chatInitTemporaryDatabase path: \(dbPath)")
var temporaryController: chat_ctrl? = nil
var cPath = dbPath.cString(using: .utf8)!
@@ -66,14 +71,14 @@ public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation
return (dbMigrationResult(dataFromCString(cjson)), temporaryController)
}
public func chatInitControllerRemovingDatabases() {
public func chatInitControllerRemovingDatabases() throws {
let dbPath = getAppDatabasePath().path
let fm = FileManager.default
// Remove previous databases, otherwise, can be .errorNotADatabase with nil controller
try? fm.removeItem(atPath: dbPath + CHAT_DB)
try? fm.removeItem(atPath: dbPath + AGENT_DB)
let dbKey = randomDatabasePassword()
guard let dbKey = randomDatabasePassword() else { throw RuntimeError("Cannot generate random database passphrase") }
logger.debug("chatInitControllerRemovingDatabases path: \(dbPath)")
var cPath = dbPath.cString(using: .utf8)!
var cKey = dbKey.cString(using: .utf8)!
@@ -353,6 +358,7 @@ public enum DBMigrationResult: Decodable, Equatable {
case errorMigration(dbFile: String, migrationError: MigrationError)
case errorSQL(dbFile: String, migrationSQLError: String)
case errorKeychain
case errorKeyGeneration
case unknown(json: String)
}
+3
View File
@@ -3894,6 +3894,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
case .memberCreatedContact: return false
case .memberProfileUpdated: return false
case .newMemberPendingReview: return true
case .msgBadSignature: return false
}
case .sndGroupEvent: return false
case .rcvConnEvent: return false
@@ -6065,6 +6066,7 @@ public enum RcvGroupEvent: Decodable, Hashable {
case memberCreatedContact
case memberProfileUpdated(fromProfile: Profile, toProfile: Profile)
case newMemberPendingReview
case msgBadSignature
var text: String { text(isChannel: false) }
@@ -6100,6 +6102,7 @@ public enum RcvGroupEvent: Decodable, Hashable {
case .memberCreatedContact: return NSLocalizedString("requested connection", comment: "rcv group event chat item")
case let .memberProfileUpdated(fromProfile, toProfile): return profileUpdatedText(fromProfile, toProfile)
case .newMemberPendingReview: return NSLocalizedString("New member wants to join the group.", comment: "rcv group event chat item")
case .msgBadSignature: return NSLocalizedString("message rejected: bad signature", comment: "rcv group event chat item")
}
}
+27
View File
@@ -50,6 +50,10 @@ private let CHAT_DB_BAK: String = "_chat.db.bak"
private let AGENT_DB_BAK: String = "_agent.db.bak"
private let CHAT_DB_EXPORTED: String = "_chat.db.exported"
private let AGENT_DB_EXPORTED: String = "_agent.db.exported"
// Spec: spec/database.md#getDocumentsDirectory
public func getDocumentsDirectory() -> URL {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
@@ -66,6 +70,18 @@ func getAppDirectory() -> URL {
: getDocumentsDirectory()
}
public func excludeAppDataFromBackup() {
var values = URLResourceValues()
values.isExcludedFromBackup = true
for var dir in [getGroupContainerDirectory(), getDocumentsDirectory()] {
do {
try dir.setResourceValues(values)
} catch {
logger.error("FileUtils.excludeAppDataFromBackup error: \(error.localizedDescription)")
}
}
}
// Spec: spec/database.md#DB_FILE_PREFIX
let DB_FILE_PREFIX = "simplex_v1"
@@ -101,6 +117,8 @@ public func deleteAppDatabaseAndFiles() {
}
try? fm.removeItem(atPath: dbPath + CHAT_DB_BAK)
try? fm.removeItem(atPath: dbPath + AGENT_DB_BAK)
try? fm.removeItem(atPath: dbPath + CHAT_DB_EXPORTED)
try? fm.removeItem(atPath: dbPath + AGENT_DB_EXPORTED)
try? fm.removeItem(at: getTempFilesDirectory())
try? fm.removeItem(at: getMigrationTempFilesDirectory())
try? fm.createDirectory(at: getTempFilesDirectory(), withIntermediateDirectories: true)
@@ -176,6 +194,13 @@ private func restoreBackupFile(fromPath: String, toPath: String) throws {
try fm.copyItem(atPath: fromPath, toPath: toPath)
}
public func deleteDatabaseBackups() {
let fm = FileManager.default
let dbPath = getAppDatabasePath().path
try? fm.removeItem(atPath: dbPath + CHAT_DB_BAK)
try? fm.removeItem(atPath: dbPath + AGENT_DB_BAK)
}
public func hasLegacyDatabase() -> Bool {
hasDatabaseAtPath(getLegacyDatabasePath())
}
@@ -198,6 +223,8 @@ public func removeLegacyDatabaseAndFiles() -> Bool {
let r2 = nil != (try? fm.removeItem(atPath: dbPath.path + CHAT_DB))
try? fm.removeItem(atPath: dbPath.path + AGENT_DB_BAK)
try? fm.removeItem(atPath: dbPath.path + CHAT_DB_BAK)
try? fm.removeItem(atPath: dbPath.path + AGENT_DB_EXPORTED)
try? fm.removeItem(atPath: dbPath.path + CHAT_DB_EXPORTED)
try? fm.removeItem(at: appFiles)
return r1 && r2
}
+2 -2
View File
@@ -37,7 +37,7 @@ public struct KeyChainItem {
}
}
func randomDatabasePassword() -> String {
func randomDatabasePassword() -> String? {
var keyData = Data(count: 32)
let status = keyData.withUnsafeMutableBytes {
SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!)
@@ -46,7 +46,7 @@ func randomDatabasePassword() -> String {
return keyData.base64EncodedString()
} else {
logger.error("randomDatabasePassword: error \(status)")
return ""
return nil
}
}
+1 -1
View File
@@ -138,7 +138,7 @@ public func createErrorNtf(_ dbStatus: DBMigrationResult, _ badgeCount: Int) ->
title = NSLocalizedString("Encrypted message: no passphrase", comment: "notification")
case .errorMigration:
title = NSLocalizedString("Encrypted message: database migration error", comment: "notification")
case .errorSQL:
case .errorSQL, .errorKeyGeneration:
title = NSLocalizedString("Encrypted message: database error", comment: "notification")
case .errorKeychain:
title = NSLocalizedString("Encrypted message: keychain error", comment: "notification")
+5
View File
@@ -74,6 +74,10 @@ See [`getDocumentsDirectory()`](../SimpleXChat/FileUtils.swift#L47) and [`getGro
The container choice is stored in `dbContainerGroupDefault` (`GroupDefaults`).
### Backup Exclusion
`isExcludedFromBackup` is set on both containers by [`excludeAppDataFromBackup()`](../SimpleXChat/FileUtils.swift#L69-L79), which is called from [`prepareForLaunch()`](../Shared/AppDelegate.swift#L124-L127) on each app launch. Files that the app, NSE and SE create or replace in these directories later are excluded as well.
---
## 3. Haskell Store Modules
@@ -130,6 +134,7 @@ Migration results are decoded in Swift as `DBMigrationResult`:
- `.errorMigration(dbFile:, migrationError:)` -- migration failed
- `.errorSQL(dbFile:, migrationSQLError:)` -- SQL error during migration
- `.errorKeychain` -- keychain access failed
- `.errorKeyGeneration` -- random database key generation failed
- `.unknown(json:)` -- unrecognized response
---
@@ -178,7 +178,6 @@ object NtfManager {
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
NotificationCompat.Builder(context, CallChannel)
.setFullScreenIntent(fullScreenPendingIntent, true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
} else {
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/raw/ring_once")
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
@@ -36,6 +36,7 @@ import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.NtfManager
import chat.simplex.app.model.NtfManager.AcceptCallAction
import chat.simplex.common.AppLock
import chat.simplex.common.helpers.applyAppLocale
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
@@ -342,7 +343,10 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
chatModel.activeCallInvitation.value = null
ntfManager.cancelCallNotification()
},
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
acceptCall = {
AppLock.recheckAuthState()
cm.acceptIncomingCall(invitation = invitation)
},
openApp = {
val intent = Intent(context, MainActivity::class.java)
.setAction(NtfManager.OpenChatAction)
@@ -376,8 +380,12 @@ fun IncomingCallLockScreenAlertLayout(
IncomingCallInfo(invitation, chatModel)
Spacer(Modifier.fillMaxHeight().weight(1f))
if (callOnLockScreen == CallOnLockScreen.ACCEPT) {
ProfileImage(size = 192.dp, image = invitation.contact.profile.image)
Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2)
if (chatModel.controller.appPrefs.notificationPreviewMode.get() == NotificationPreviewMode.HIDDEN.name) {
ProfileImage(size = 192.dp)
} else {
ProfileImage(size = 192.dp, image = invitation.contact.profile.image)
Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2)
}
Spacer(Modifier.fillMaxHeight().weight(1f))
Row {
LockScreenCallButton(stringResource(MR.strings.reject), painterResource(R.drawable.ic_call_end_filled), Color.Red, rejectCall)
@@ -1,8 +1,11 @@
package chat.simplex.common.platform
import android.annotation.SuppressLint
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyInfo
import android.security.keystore.KeyProperties
import android.security.keystore.StrongBoxUnavailableException
import chat.simplex.common.views.helpers.AlertManager
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
@@ -68,15 +71,44 @@ internal class Cryptor: CryptorInterface {
keyStore.deleteEntry(alias)
}
override fun keyStorage(alias: String): String? {
val secretKey = getSecretKey(alias) ?: return null
val keyInfo = SecretKeyFactory.getInstance(secretKey.algorithm, "AndroidKeyStore").getKeySpec(secretKey, KeyInfo::class.java) as KeyInfo
val storage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
when (keyInfo.securityLevel) {
KeyProperties.SECURITY_LEVEL_STRONGBOX -> MR.strings.keystore_key_storage_strongbox
KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT -> MR.strings.keystore_key_storage_tee
KeyProperties.SECURITY_LEVEL_SOFTWARE -> MR.strings.keystore_key_storage_software
else -> return null
}
} else {
// isInsideSecureHardware does not distinguish StrongBox, which createSecretKey only requests on API 31+
@Suppress("DEPRECATION")
if (keyInfo.isInsideSecureHardware) MR.strings.keystore_key_storage_tee else MR.strings.keystore_key_storage_software
}
return generalGetString(storage)
}
private fun createSecretKey(alias: String): SecretKey? {
if (keyStore.containsAlias(alias)) return getSecretKey(alias)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
return generateKey(keySpec(alias).setIsStrongBoxBacked(true))
} catch (e: StrongBoxUnavailableException) {
Log.i(TAG, "StrongBox is unavailable, using default keystore: ${e.message}")
}
}
return generateKey(keySpec(alias))
}
private fun keySpec(alias: String): KeyGenParameterSpec.Builder =
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(BLOCK_MODE)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
private fun generateKey(spec: KeyGenParameterSpec.Builder): SecretKey {
val keyGenerator: KeyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM, "AndroidKeyStore")
keyGenerator.init(
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(BLOCK_MODE)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
)
keyGenerator.init(spec.build())
return keyGenerator.generateKey()
}
@@ -132,7 +132,7 @@ actual fun ActiveCallView() {
}
Box(Modifier.fillMaxSize()) {
WebRTCView(chatModel.callCommand) { apiMsg ->
Log.d(TAG, "received from WebRTCView: $apiMsg")
Log.d(TAG, "received from WebRTCView: ${apiMsg.resp.javaClass.simpleName}")
val call = chatModel.activeCall.value
if (call != null) {
val callState = call.androidCallState as ActiveCallState
@@ -711,7 +711,7 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
.collect {
while (callCommand.isNotEmpty()) {
val cmd = callCommand.removeFirstOrNull()
Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd")
Log.d(TAG, "WebRTCView LaunchedEffect executing ${cmd?.javaClass?.simpleName}")
if (cmd != null) {
processCommand(wv, cmd)
}
@@ -785,7 +785,7 @@ class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) {
// onResponse(message)
onResponse(json.decodeFromString(message))
} catch (e: Exception) {
Log.e(TAG, "failed parsing WebView message: $message")
Log.e(TAG, "failed parsing WebView message")
}
}
}
@@ -59,6 +59,7 @@ actual fun DatabaseEncryptionFooter(
useKeychain: MutableState<Boolean>,
chatDbEncrypted: Boolean?,
storedKey: MutableState<Boolean>,
keyStorage: String?,
initialRandomDBPassphrase: MutableState<Boolean>,
migration: Boolean,
) {
@@ -66,7 +67,7 @@ actual fun DatabaseEncryptionFooter(
SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted))
} else if (useKeychain.value) {
if (storedKey.value) {
SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely))
SectionTextFooter(String.format(generalGetString(MR.strings.keychain_is_storing_securely), keyStorage?.let { " ($it)" } ?: ""))
if (initialRandomDBPassphrase.value && !migration) {
SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase))
} else {
@@ -207,7 +207,7 @@ fun MainScreen() {
SwitchingUsersView()
}
if (unauthorized.value && !(chatModel.activeCallViewIsVisible.value && chatModel.showCallView.value)) {
if (unauthorized.value) {
LaunchedEffect(Unit) {
// With these constrains when user presses back button while on ChatList, activity destroys and shows auth request
// while the screen moves to a launcher. Detect it and prevent showing the auth
@@ -221,7 +221,8 @@ fun MainScreen() {
SplashView(true)
ModalManager.fullscreen.showPasscodeInView()
}
} else {
}
if (!unauthorized.value || chatModel.activeCallViewIsVisible.value) {
if (chatModel.showCallView.value) {
if (appPlatform.isAndroid) {
LaunchedEffect(Unit) {
@@ -235,6 +236,8 @@ fun MainScreen() {
ActiveCallView()
}
}
}
if (!unauthorized.value) {
ModalManager.fullscreen.showOneTimePasscodeInView()
AlertManager.privacySensitive.showInView()
if (onboarding == OnboardingStage.OnboardingComplete) {
@@ -269,6 +269,11 @@ object AppLock {
fun elapsedRealtime(): Long = System.nanoTime() / 1_000_000
fun recheckAuthState() {
if (ChatModel.showCallView.value) {
// BiometricPrompt drops a prompt requested while MainActivity is stopped (call on lock screen), so request it again
if (userAuthorized.value == false) runAuthenticate()
return
}
val enteredBackgroundVal = enteredBackground.value
val delay = ChatController.appPrefs.laLockDelay.get()
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= delay * 1000) {
@@ -3555,6 +3555,7 @@ data class ChatItem (
is RcvGroupEvent.MemberCreatedContact -> false
is RcvGroupEvent.MemberProfileUpdated -> false
is RcvGroupEvent.NewMemberPendingReview -> true
is RcvGroupEvent.MsgBadSignature -> false
}
is CIContent.SndGroupEventContent -> false
is CIContent.RcvConnEventContent -> false
@@ -5461,6 +5462,7 @@ sealed class RcvGroupEvent() {
@Serializable @SerialName("memberCreatedContact") class MemberCreatedContact(): RcvGroupEvent()
@Serializable @SerialName("memberProfileUpdated") class MemberProfileUpdated(val fromProfile: Profile, val toProfile: Profile): RcvGroupEvent()
@Serializable @SerialName("newMemberPendingReview") class NewMemberPendingReview(): RcvGroupEvent()
@Serializable @SerialName("msgBadSignature") class MsgBadSignature(): RcvGroupEvent()
val text: String get() = text(isChannel = false)
@@ -5485,6 +5487,7 @@ sealed class RcvGroupEvent() {
is MemberCreatedContact -> generalGetString(MR.strings.rcv_group_event_member_created_contact)
is MemberProfileUpdated -> profileUpdatedText(fromProfile, toProfile)
is NewMemberPendingReview -> generalGetString(MR.strings.rcv_group_event_new_member_pending_review)
is MsgBadSignature -> generalGetString(MR.strings.rcv_group_event_msg_bad_signature)
}
private fun profileUpdatedText(from: Profile, to: Profile): String =
@@ -32,6 +32,7 @@ import chat.simplex.common.views.chat.item.contentModerationPostLink
import chat.simplex.common.views.chat.item.showContentBlockedAlert
import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert
import chat.simplex.common.views.chatlist.openGroupChat
import chat.simplex.common.views.database.deleteDatabaseBackups
import chat.simplex.common.views.migration.MigrationFileLinkData
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.views.usersettings.*
@@ -215,6 +216,7 @@ class AppPreferences {
val encryptedSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE, null)
val initializationVectorSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE, null)
val encryptionStartedAt = mkDatePreference(SHARED_PREFS_ENCRYPTION_STARTED_AT, null)
val shouldDeleteDatabaseBackups = mkBoolPreference(SHARED_PREFS_SHOULD_DELETE_DATABASE_BACKUPS, false)
val confirmDBUpgrades = mkBoolPreference(SHARED_PREFS_CONFIRM_DB_UPGRADES, false)
val selfDestruct = mkBoolPreference(SHARED_PREFS_SELF_DESTRUCT, false)
val selfDestructDisplayName = mkStrPreference(SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME, null)
@@ -487,6 +489,7 @@ class AppPreferences {
private const val SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE = "EncryptedSelfDestructPassphrase"
private const val SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE = "InitializationVectorSelfDestructPassphrase"
private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt"
private const val SHARED_PREFS_SHOULD_DELETE_DATABASE_BACKUPS = "ShouldDeleteDatabaseBackups"
private const val SHARED_PREFS_NEW_DATABASE_INITIALIZED = "NewDatabaseInitialized"
private const val SHARED_PREFS_SHOULD_IMPORT_APP_SETTINGS = "ShouldImportAppSettings"
private const val SHARED_PREFS_CONFIRM_DB_UPGRADES = "ConfirmDBUpgrades"
@@ -686,6 +689,10 @@ object ChatController {
}
apiStartChat()
appPrefs.chatStopped.set(false)
if (appPrefs.shouldDeleteDatabaseBackups.get()) {
deleteDatabaseBackups()
appPrefs.shouldDeleteDatabaseBackups.set(false)
}
} catch (e: Throwable) {
Log.e(TAG, "failed starting chat $e")
throw e
@@ -941,7 +948,7 @@ object ChatController {
val r = json.decodeFromString<API>(rStr)
if (log) {
Log.d(TAG, "sendCmd response type ${r.responseType}")
if (r is API.Result && (r.res is CR.Response || r.res is CR.Invalid)) {
if (r is API.Result && ((r.res is CR.Response && !r.res.type.startsWith("call")) || r.res is CR.Invalid)) {
Log.d(TAG, "sendCmd response json $rStr")
}
chatModel.addTerminalItem(TerminalItem.resp(rhId, r))
@@ -958,7 +965,7 @@ object ChatController {
} else {
val r = json.decodeFromString<API>(rStr)
Log.d(TAG, "chatRecvMsg: ${r.responseType}")
if (r is API.Result && (r.res is CR.Response || r.res is CR.Invalid)) Log.d(TAG, "chatRecvMsg json: $rStr")
if (r is API.Result && ((r.res is CR.Response && !r.res.type.startsWith("call")) || r.res is CR.Invalid)) Log.d(TAG, "chatRecvMsg json: $rStr")
r
}
}
@@ -3364,21 +3371,13 @@ object ChatController {
chatModel.callManager.reportNewIncomingCall(r.callInvitation.copy(remoteHostId = rhId))
}
is CR.CallOffer -> {
// TODO askConfirmation?
// TODO check encryption is compatible
withCall(r, r.contact) { call ->
chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, sharedKey = r.sharedKey)
val useRelay = appPrefs.webrtcPolicyRelay.get()
val iceServers = getIceServers()
Log.d(TAG, ".callOffer iceServers $iceServers")
chatModel.callCommand.add(WCallCommand.Offer(
offer = r.offer.rtcSession,
iceCandidates = r.offer.rtcIceCandidates,
media = r.callType.media,
aesKey = r.sharedKey,
iceServers = iceServers,
relay = useRelay
))
chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, hasSharedKey = r.sharedKey != null)
if (r.askConfirmation) {
showUnencryptedCallAlert(call) { processCallOffer(r) }
} else {
processCallOffer(r)
}
}
}
is CR.CallAnswer -> {
@@ -3634,6 +3633,39 @@ object ChatController {
}
}
private fun processCallOffer(r: CR.CallOffer) {
val useRelay = appPrefs.webrtcPolicyRelay.get()
val iceServers = getIceServers()
chatModel.callCommand.add(WCallCommand.Offer(
offer = r.offer.rtcSession,
iceCandidates = r.offer.rtcIceCandidates,
media = r.callType.media,
aesKey = r.sharedKey,
iceServers = iceServers,
relay = useRelay
))
}
private fun showUnencryptedCallAlert(call: Call, onContinue: () -> Unit) {
// the call may have ended, or a new one started with the same contact, while the alert was shown
fun offerPending(): Boolean {
val c = chatModel.activeCall.value
return c != null && c.remoteHostId == call.remoteHostId && c.contact.id == call.contact.id && c.callState == CallState.OfferReceived
}
val endCall = { if (offerPending()) withBGApi { chatModel.callManager.endCall(call) } }
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.call_not_encrypted_title),
text = generalGetString(MR.strings.call_not_encrypted_desc).format(call.contact.displayName),
confirmText = generalGetString(MR.strings.call_service_notification_end_call),
onConfirm = { endCall() },
dismissText = generalGetString(MR.strings.continue_to_next_step),
onDismiss = { if (offerPending()) onContinue() },
onDismissRequest = { endCall() },
destructive = true,
parseHtml = false
)
}
suspend fun leaveGroup(rh: Long?, groupId: Long) {
val groupInfo = apiLeaveGroup(rh, groupId)
if (groupInfo != null) {
@@ -7215,9 +7247,9 @@ sealed class CR {
is SndStandaloneFileComplete -> withUser(user, rcvURIs.size.toString())
is SndFileError -> withUser(user, "errorMessage: ${json.encodeToString(errorMessage)}\nchatItem: ${json.encodeToString(chatItem_)}")
is SndFileWarning -> withUser(user, "errorMessage: ${json.encodeToString(errorMessage)}\nchatItem: ${json.encodeToString(chatItem_)}")
is CallInvitations -> "callInvitations: ${json.encodeToString(callInvitations)}"
is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: $callInvitation.callType\nsharedKey: ${callInvitation.sharedKey ?: ""}"
is CallOffer -> withUser(user, "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}")
is CallInvitations -> "callInvitations: ${json.encodeToString(callInvitations.map { it.copy(sharedKey = null) })}"
is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: ${callInvitation.callType}"
is CallOffer -> withUser(user, "contact: ${contact.id}\ncallType: $callType\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}")
is CallAnswer -> withUser(user, "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}")
is CallExtraInfo -> withUser(user, "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}")
is CallEnded -> withUser(user, "contact: ${contact.id}")
@@ -4,6 +4,7 @@ interface CryptorInterface {
fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String?
fun encryptText(text: String, alias: String): Pair<ByteArray, ByteArray>
fun deleteKey(alias: String)
fun keyStorage(alias: String): String?
}
expect val cryptor: CryptorInterface
@@ -3,9 +3,12 @@ package chat.simplex.common.platform
import androidx.compose.foundation.combinedClickable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.views.helpers.KeyChangeEffect
@@ -29,14 +32,14 @@ expect fun Modifier.desktopPointerHoverIconHand(): Modifier
expect fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier
@Composable
fun Modifier.desktopModifyBlurredState(enabled: Boolean, blurred: MutableState<Boolean>, showMenu: State<Boolean>,): Modifier {
fun Modifier.desktopModifyBlurredState(blurred: MutableState<Boolean>, showMenu: State<Boolean>,): Modifier {
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
if (appPlatform.isDesktop) {
KeyChangeEffect(blurRadius.value) {
blurred.value = enabled && blurRadius.value > 0
blurred.value = blurRadius.value > 0
}
}
return if (appPlatform.isDesktop && enabled && blurRadius.value > 0 && !showMenu.value) {
return if (appPlatform.isDesktop && blurRadius.value > 0 && !showMenu.value) {
var job: Job = remember { Job() }
LaunchedEffect(Unit) {
// The approach here is to allow menu to show up and to not blur the view. When menu is shown and mouse is hovering,
@@ -64,24 +67,31 @@ fun blurHidesMedia(enabled: Boolean, blurred: State<Boolean>): Boolean =
@Composable
fun Modifier.privacyBlur(
enabled: Boolean,
fullSize: Boolean,
preview: ImageBitmap,
blurred: MutableState<Boolean> = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) },
scrollState: State<Boolean>,
onLongClick: () -> Unit = {}
): Modifier {
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
return if (blurHidesMedia(enabled, blurred)) {
val blurredPreview = remember(preview, blurRadius.value) { preview.blurredBy(blurRadius.value) }
this then Modifier
.drawWithContent { drawImage(blurredPreview, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) }
return if (blurHidesMedia(true, blurred)) {
this then (if (fullSize) {
val blurredPreview = remember(preview, blurRadius.value) { preview.blurredBy(blurRadius.value) }
Modifier.drawWithContent { drawImage(blurredPreview, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) }
} else Modifier.drawWithCache {
val cropScale = maxOf(size.width / preview.width, size.height / preview.height)
val croppedSize = IntSize((preview.width * cropScale).roundToInt(), (preview.height * cropScale).roundToInt())
val blurredCropped = preview.blurredBy(blurRadius.value, croppedSize.width.toDp().value)
val offset = IntOffset(((size.width - croppedSize.width) / 2).roundToInt(), ((size.height - croppedSize.height) / 2).roundToInt())
onDrawWithContent { clipRect { drawImage(blurredCropped, dstOffset = offset, dstSize = croppedSize) } }
})
.combinedClickable(
onLongClick = onLongClick,
onClick = {
blurred.value = false
}
)
} else if (enabled && blurRadius.value > 0 && appPlatform.isAndroid) {
} else if (blurRadius.value > 0 && appPlatform.isAndroid) {
LaunchedEffect(Unit) {
snapshotFlow { scrollState.value }
.filter { it }
@@ -96,12 +106,14 @@ fun Modifier.privacyBlur(
// Calibrated so the resample blurs as much as Modifier.blur did at each radius; 360 read 5-75% stronger.
private const val BLURRED_MEDIA_WIDTH_DP = 400
// The width in-chat media is assumed to be drawn at; small views are blurred as much on screen.
private const val CHAT_MEDIA_WIDTH_DP = 360
// Bounds the first step: nothing bounds a decoded video frame, and reading every pixel of a 4K one would stall.
private const val RESAMPLE_MEDIA_FROM_SIDE = 512
private fun ImageBitmap.blurredBy(radius: Int): ImageBitmap {
private fun ImageBitmap.blurredBy(radius: Int, drawnWidthDp: Float = CHAT_MEDIA_WIDTH_DP.toFloat()): ImageBitmap {
if (width <= 0 || height <= 0) return this
val w = (BLURRED_MEDIA_WIDTH_DP / radius).coerceIn(1, width)
val w = (BLURRED_MEDIA_WIDTH_DP * drawnWidthDp / CHAT_MEDIA_WIDTH_DP / radius).toInt().coerceIn(1, width)
val h = (w * height / width).coerceIn(1, BLURRED_MEDIA_WIDTH_DP)
val longest = maxOf(width, height)
var image = if (longest > RESAMPLE_MEDIA_FROM_SIDE) {
@@ -1,5 +1,6 @@
package chat.simplex.common.platform
import chat.simplex.common.AppLock
import chat.simplex.common.model.*
import chat.simplex.common.views.call.RcvCallInvitation
import chat.simplex.common.views.chatlist.acceptContactRequest
@@ -96,6 +97,7 @@ abstract class NtfManager {
}
fun acceptCallAction(chatId: ChatId) {
AppLock.recheckAuthState()
chatModel.clearOverlays.value = true
val invitation = chatModel.callInvitations[chatId]
if (invitation == null) {
@@ -1,5 +1,6 @@
package chat.simplex.common.views.call
import chat.simplex.common.AppLock
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.withBGApi
@@ -51,13 +52,12 @@ class CallManager(val chatModel: ChatModel) {
callUUID = invitation.callUUID,
callState = CallState.InvitationAccepted,
initialCallType = invitation.callType.media,
sharedKey = invitation.sharedKey,
hasSharedKey = invitation.sharedKey != null,
androidCallState = platform.androidCreateActiveCallState()
)
showCallView.value = true
val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
val iceServers = getIceServers()
Log.d(TAG, "answerIncomingCall iceServers: $iceServers")
callCommand.add(WCallCommand.Start(
media = invitation.callType.media,
aesKey = invitation.sharedKey,
@@ -79,6 +79,7 @@ class CallManager(val chatModel: ChatModel) {
// Don't destroy WebView if you plan to accept next call right after this one
if (!switchingCall.value) {
AppLock.appWasHidden()
showCallView.value = false
activeCall.value?.androidCallState?.close()
activeCall.value = null
@@ -21,7 +21,7 @@ data class Call(
val localMediaSources: CallMediaSources = CallMediaSources(mic = true, camera = initialCallType == CallMediaType.Video),
val localCapabilities: CallCapabilities? = null,
val peerMediaSources: CallMediaSources = CallMediaSources(),
val sharedKey: String? = null,
val hasSharedKey: Boolean = false,
var localCamera: VideoCamera = VideoCamera.User,
val connectionInfo: ConnectionInfo? = null,
var connectedAt: Instant? = null,
@@ -32,14 +32,14 @@ data class Call(
val androidCallState: Closeable
) {
val encrypted: Boolean get() = localEncrypted && sharedKey != null
val encrypted: Boolean get() = localEncrypted && hasSharedKey
private val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
val encryptionStatus: String get() = when(callState) {
CallState.WaitCapabilities -> ""
CallState.InvitationSent -> generalGetString(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption)
CallState.InvitationAccepted -> generalGetString(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption)
else -> generalGetString(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted)
CallState.InvitationAccepted -> generalGetString(if (!hasSharedKey) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption)
else -> generalGetString(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (!hasSharedKey) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted)
}
val hasVideo: Boolean
@@ -2470,6 +2470,7 @@ fun BoxScope.ChatItemsList(
LaunchedEffect(Unit) {
snapshotFlow { listState.value.isScrollInProgress }
.onCompletion { chatViewScrollState.value = false }
.collect {
chatViewScrollState.value = it
}
@@ -120,7 +120,7 @@ fun CIImageView(
}
@Composable
fun ImageView(painter: Painter, image: String, fileSource: CryptoFile?, onClick: () -> Unit) {
fun ImageView(painter: Painter, image: String, onClick: () -> Unit) {
// On my Android device Compose fails to display 6000x6000 px WebP image with exception:
// IllegalStateException: Recording currently in progress - missing #endRecording() call?
// but can display 5000px image. Using even lower value here just to feel safer.
@@ -152,11 +152,7 @@ fun CIImageView(
.privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
contentAlignment = Alignment.Center
) {
imageView(previewBitmap, onClick = {
if (fileSource != null) {
openFile(fileSource)
}
})
imageView(previewBitmap, onClick = onClick)
Icon(
painterResource(MR.images.ic_open_in_new),
contentDescription = stringResource(MR.strings.image_descr),
@@ -191,7 +187,7 @@ fun CIImageView(
}
} else Modifier
)
.desktopModifyBlurredState(!smallView, blurred, showMenu),
.desktopModifyBlurredState(blurred, showMenu),
contentAlignment = Alignment.TopEnd
) {
val res: MutableState<Triple<ImageBitmap, ByteArray, String>?> = remember { mutableStateOf(null) }
@@ -217,7 +213,7 @@ fun CIImageView(
val loaded = if (revealed) res.value else null
if (loaded != null && file != null) {
val (imageBitmap, data, _) = loaded
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, blurred, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) })
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, blurred, @Composable { painter, onClick -> ImageView(painter, image, onClick) })
} else {
imageView(previewBitmap, onClick = {
if (file != null) {
@@ -54,7 +54,7 @@ fun CIVideoView(
}
} else Modifier
)
.desktopModifyBlurredState(!smallView, blurred, showMenu),
.desktopModifyBlurredState(blurred, showMenu),
contentAlignment = Alignment.TopEnd
) {
val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) }
@@ -84,11 +84,11 @@ fun CIVideoView(
val uriDecrypted = remember(filePath) { mutableStateOf(if (file.fileSource?.cryptoArgs == null) uri else file.fileSource.decryptedGet()) }
val decrypted = uriDecrypted.value
if (decrypted != null && smallView) {
SmallVideoView(decrypted, file, preview, duration * 1000L, autoPlay, sizeMultiplier, openFullscreen = openFullscreen)
SmallVideoView(decrypted, file, preview, duration * 1000L, autoPlay, blurred, sizeMultiplier, openFullscreen = openFullscreen)
} else if (decrypted != null) {
VideoView(decrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen)
} else if (smallView) {
SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, sizeMultiplier, openFullscreen = openFullscreen)
SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, blurred, sizeMultiplier, openFullscreen = openFullscreen)
} else {
VideoViewEncrypted(uriDecrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen)
}
@@ -184,16 +184,17 @@ private fun SmallVideoViewEncrypted(
defaultPreview: ImageBitmap,
autoPlay: MutableState<Boolean>,
showMenu: MutableState<Boolean>,
blurred: MutableState<Boolean>,
sizeMultiplier: Float,
openFullscreen: () -> Unit,
) {
var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) }
val onLongClick = { showMenu.value = true }
Box {
VideoPreviewImageView(defaultPreview, smallView = true, blurred = remember { mutableStateOf(false) }, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick)
VideoPreviewImageView(defaultPreview, smallView = true, blurred = blurred, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick)
if (decryptionInProgress) {
VideoDecryptionProgress(sizeMultiplier, onLongClick = onLongClick)
} else if (!file.showStatusIconInSmallView) {
} else if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) {
PlayButton(false, sizeMultiplier, onLongClick = onLongClick) {
decryptionInProgress = true
withBGApi {
@@ -216,6 +217,7 @@ private fun SmallVideoView(
defaultPreview: ImageBitmap,
defaultDuration: Long,
autoPlay: MutableState<Boolean>,
blurred: MutableState<Boolean>,
sizeMultiplier: Float,
openFullscreen: () -> Unit
) {
@@ -233,8 +235,8 @@ private fun SmallVideoView(
onLongClick = {},
{}
)
VideoPreviewImageView(preview, smallView = true, blurred = remember { mutableStateOf(false) }, onClick = openFullscreen, onLongClick = {})
if (!file.showStatusIconInSmallView) {
VideoPreviewImageView(preview, smallView = true, blurred = blurred, onClick = openFullscreen, onLongClick = {})
if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) {
PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen)
}
}
@@ -153,6 +153,7 @@ fun FramedItemView(
@Composable
fun ciQuoteView(qi: CIQuote) {
val blurred = remember { mutableStateOf(appPreferences.privacyMediaBlurRadius.get() > 0) }
val sentColor = MaterialTheme.appColors.sentQuote
val receivedColor = MaterialTheme.appColors.receivedQuote
Row(
@@ -170,7 +171,7 @@ fun FramedItemView(
imageBitmap,
contentDescription = stringResource(MR.strings.image_descr),
contentScale = ContentScale.Crop,
modifier = Modifier.size(68.dp).clipToBounds()
modifier = Modifier.size(68.dp).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true })
)
}
is MsgContent.MCVideo -> {
@@ -182,7 +183,7 @@ fun FramedItemView(
imageBitmap,
contentDescription = stringResource(MR.strings.video_descr),
contentScale = ContentScale.Crop,
modifier = Modifier.size(68.dp).clipToBounds()
modifier = Modifier.size(68.dp).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true })
)
}
is MsgContent.MCFile, is MsgContent.MCVoice -> {
@@ -311,26 +311,31 @@ fun ChatPreviewView(
mutableStateOf({ providerForGallery(chat.chatItems, ci?.id ?: 0) {} })
}
val uriHandler = LocalUriHandler.current
// Media in the chat list has no menu, so a menu opened from it must close at once, or the media stays revealed.
val noMenu = remember { mutableStateOf(false) }
LaunchedEffect(noMenu.value) { noMenu.value = false }
when (mc) {
is MsgContent.MCLink -> SmallContentPreview {
val image = remember(mc.preview.image) { base64ToBitmap(mc.preview.image) }
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
IconButton(
{ openBrowserAlert(mc.preview.uri, uriHandler) },
Modifier.desktopPointerHoverIconHand(),
) {
Image(base64ToBitmap(mc.preview.image), null, contentScale = ContentScale.Crop)
Image(image, null, Modifier.desktopModifyBlurredState(blurred, noMenu).privacyBlur(fullSize = false, image, blurred, chatViewScrollState.collectAsState()), contentScale = ContentScale.Crop)
}
Box(Modifier.align(Alignment.TopEnd).size(15.sp.toDp()).background(Color.Black.copy(0.25f), CircleShape), contentAlignment = Alignment.Center) {
Icon(painterResource(MR.images.ic_arrow_outward), null, Modifier.size(13.sp.toDp()), tint = Color.White)
}
}
is MsgContent.MCImage -> SmallContentPreview {
CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
CIImageView(image = mc.image, file = ci.file, provider, noMenu, smallView = true) {
val user = chatModel.currentUser.value ?: return@CIImageView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
}
}
is MsgContent.MCVideo -> SmallContentPreview {
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, noMenu, smallView = true) {
val user = chatModel.currentUser.value ?: return@CIVideoView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
}
@@ -421,7 +426,7 @@ fun ChatPreviewView(
val deleted = ci?.isDeletedContent == true || ci?.meta?.itemDeleted != null
val showContentPreview = (showChatPreviews && chatModelDraftChatId != chat.id && !deleted) || activeVoicePreview.value != null
if (ci != null && showContentPreview) {
chatItemContentPreview(chat, ci)
key(ci.id) { chatItemContentPreview(chat, ci) }
}
if (mc !is MsgContent.MCVoice || !showContentPreview || mc.text.isNotEmpty() || chatModelDraftChatId == chat.id) {
Box(Modifier.offset(x = if (mc is MsgContent.MCFile && ci.meta.itemDeleted == null) -15.sp.toDp() else 0.dp)) {
@@ -44,6 +44,7 @@ fun DatabaseEncryptionView(m: ChatModel, migration: Boolean) {
val useKeychain = remember { mutableStateOf(appPrefs.storeDBPassphrase.get()) }
val initialRandomDBPassphrase = remember { mutableStateOf(appPrefs.initialRandomDBPassphrase.get()) }
val storedKey = remember { val key = DatabaseUtils.ksDatabasePassword.get(); mutableStateOf(key != null && key != "") }
val keyStorage = remember(storedKey.value) { if (storedKey.value) DatabaseUtils.ksDatabasePassword.storage() else null }
// Do not do rememberSaveable on current key to prevent saving it on disk in clear text
val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.ksDatabasePassword.get() ?: "" else "") }
val newKey = rememberSaveable { mutableStateOf("") }
@@ -60,6 +61,7 @@ fun DatabaseEncryptionView(m: ChatModel, migration: Boolean) {
newKey,
confirmNewKey,
storedKey,
keyStorage,
initialRandomDBPassphrase,
progressIndicator,
migration,
@@ -105,6 +107,7 @@ fun DatabaseEncryptionLayout(
newKey: MutableState<String>,
confirmNewKey: MutableState<String>,
storedKey: MutableState<Boolean>,
keyStorage: String?,
initialRandomDBPassphrase: MutableState<Boolean>,
progressIndicator: MutableState<Boolean>,
migration: Boolean,
@@ -196,7 +199,7 @@ fun DatabaseEncryptionLayout(
}
Column {
DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase, migration)
DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, keyStorage, initialRandomDBPassphrase, migration)
}
SectionBottomSpacer()
}
@@ -254,6 +257,7 @@ expect fun DatabaseEncryptionFooter(
useKeychain: MutableState<Boolean>,
chatDbEncrypted: Boolean?,
storedKey: MutableState<Boolean>,
keyStorage: String?,
initialRandomDBPassphrase: MutableState<Boolean>,
migration: Boolean,
)
@@ -434,6 +438,7 @@ suspend fun encryptDatabase(
m.controller.apiSaveAppSettings(AppSettings.current.prepareForExport())
}
val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value)
if (error == null && currentKey.value != newKey.value) appPrefs.shouldDeleteDatabaseBackups.set(true)
appPrefs.encryptionStartedAt.set(null)
val sqliteError = ((error as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError
when {
@@ -459,12 +464,13 @@ suspend fun encryptDatabase(
if (migration) {
appPreferences.storeDBPassphrase.set(useKeychain.value)
}
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
if (useKeychain.value) {
DatabaseUtils.ksDatabasePassword.set(new)
} else {
removePassphraseFromKeyChain(useKeychain, storedKey, migration)
}
// DatabaseEncryptionView reads key storage when storedKey changes, so storedKey is updated after the key is saved
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted))
}
@@ -539,6 +545,7 @@ fun PreviewDatabaseEncryptionLayout() {
newKey = remember { mutableStateOf("") },
confirmNewKey = remember { mutableStateOf("") },
storedKey = remember { mutableStateOf(true) },
keyStorage = stringResource(MR.strings.keystore_key_storage_strongbox),
initialRandomDBPassphrase = remember { mutableStateOf(true) },
progressIndicator = remember { mutableStateOf(false) },
migration = false,
@@ -556,12 +556,16 @@ suspend fun deleteChatAsync(m: ChatModel) {
fun deleteChatDatabaseFilesAndState() {
val chat = File(dataDir, chatDatabaseFileName)
val chatBak = File(dataDir, "$chatDatabaseFileName.bak")
val chatExported = File(dataDir, "$chatDatabaseFileName.exported")
val agent = File(dataDir, agentDatabaseFileName)
val agentBak = File(dataDir, "$agentDatabaseFileName.bak")
val agentExported = File(dataDir, "$agentDatabaseFileName.exported")
chat.delete()
chatBak.delete()
chatExported.delete()
agent.delete()
agentBak.delete()
agentExported.delete()
filesDir.deleteRecursively()
filesDir.mkdir()
remoteHostsDir.deleteRecursively()
@@ -594,6 +598,11 @@ fun deleteChatDatabaseFilesAndState() {
ntfManager.cancelAllNotifications()
}
fun deleteDatabaseBackups() {
File(dataDir, "$chatDatabaseFileName.bak").delete()
File(dataDir, "$agentDatabaseFileName.bak").delete()
}
private suspend fun exportArchive(
m: ChatModel,
progressIndicator: MutableState<Boolean>,
@@ -38,6 +38,8 @@ object DatabaseUtils {
passphrase.set(null)
initVector.set(null)
}
fun storage(): String? = cryptor.keyStorage(alias)
}
fun hasAtLeastOneDatabase(rootDir: String): Boolean =
@@ -206,7 +206,7 @@ fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongC
stringResource(MR.strings.image_descr_link_preview),
modifier = Modifier
.fillMaxWidth()
.desktopModifyBlurredState(true, blurred, showMenu)
.desktopModifyBlurredState(blurred, showMenu)
.privacyBlur(true, image, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick),
contentScale = ContentScale.FillWidth,
)
@@ -453,7 +453,7 @@ private fun MutableState<MigrationToState?>.PassphraseEnteringView(currentKey: S
}
}
) {}
DatabaseEncryptionFooter(useKeychain, chatDbEncrypted = true, remember { mutableStateOf(false) }, remember { mutableStateOf(false) }, true)
DatabaseEncryptionFooter(useKeychain, chatDbEncrypted = true, remember { mutableStateOf(false) }, null, remember { mutableStateOf(false) }, true)
}
if (verifyingPassphrase.value) {
ProgressView()
@@ -43,7 +43,7 @@
<string name="callstatus_accepted">قُبلت المكالمة</string>
<string name="allow_calls_only_if">اسمح بالمكالمات فقط إذا سمحت جهة اتصالك بذلك.</string>
<string name="allow_message_reactions_only_if">اسمح بردود الفعل على الرسائل فقط إذا سمحت جهة اتصالك بذلك.</string>
<string name="keychain_is_storing_securely">يتم استخدام Android Keystore لتخزين عبارة المرور بشكل آمن - فهو يسمح لخدمة الإشعارات بالعمل.</string>
<string name="keychain_is_storing_securely">يتم استخدام Android Keystore%s لتخزين عبارة المرور بشكل آمن - فهو يسمح لخدمة الإشعارات بالعمل.</string>
<string name="empty_chat_profile_is_created">يتم إنشاء ملف تعريف دردشة فارغ بالاسم المقدم، ويفتح التطبيق كالمعتاد.</string>
<string name="answer_call">أجب الاتصال</string>
<string name="chat_preferences_always">دائمًا</string>
@@ -1415,6 +1415,8 @@
<string name="status_no_e2e_encryption">no e2e encryption</string>
<string name="status_contact_has_e2e_encryption">contact has e2e encryption</string>
<string name="status_contact_has_no_e2e_encryption">contact has no e2e encryption</string>
<string name="call_not_encrypted_title">Call is not encrypted</string>
<string name="call_not_encrypted_desc">%s accepted the call without end-to-end encryption.</string>
<string name="call_connection_peer_to_peer">peer-to-peer</string>
<string name="call_connection_via_relay">via relay</string>
<string name="icon_descr_hang_up">Hang up</string>
@@ -1642,6 +1644,9 @@
<!-- DatabaseEncryptionView.kt -->
<string name="save_passphrase_in_keychain">Save passphrase in Keystore</string>
<string name="save_passphrase_in_settings">Save passphrase in settings</string>
<string name="keystore_key_storage_strongbox">Secure element</string>
<string name="keystore_key_storage_tee">TEE</string>
<string name="keystore_key_storage_software">Software</string>
<string name="database_encrypted">Database encrypted!</string>
<string name="error_encrypting_database">Error encrypting database</string>
<string name="remove_passphrase_from_keychain">Remove passphrase from Keystore?</string>
@@ -1658,7 +1663,7 @@
<string name="set_passphrase">Set passphrase</string>
<string name="enter_correct_current_passphrase">Please enter correct current passphrase.</string>
<string name="database_is_not_encrypted">Your chat database is not encrypted - set passphrase to protect it.</string>
<string name="keychain_is_storing_securely">Android Keystore is used to securely store passphrase - it allows notification service to work.</string>
<string name="keychain_is_storing_securely">Android Keystore%s is used to securely store passphrase - it allows notification service to work.</string>
<string name="settings_is_storing_in_clear_text">The passphrase is stored in settings as plaintext.</string>
<string name="encrypted_with_random_passphrase">Database is encrypted using a random passphrase, you can change it.</string>
<string name="impossible_to_recover_passphrase"><![CDATA[<b>Please note</b>: you will NOT be able to recover or change passphrase if you lose it.]]></string>
@@ -1784,6 +1789,7 @@
<string name="rcv_group_event_invited_via_your_group_link">invited via your group link</string>
<string name="rcv_group_event_member_created_contact">requested connection</string>
<string name="rcv_group_event_new_member_pending_review">New member wants to join the group.</string>
<string name="rcv_group_event_msg_bad_signature">message rejected: bad signature</string>
<string name="snd_group_event_changed_member_role">you changed role of %s to %s</string>
<string name="snd_group_event_changed_role_for_yourself">you changed role for yourself to %s</string>
<string name="snd_group_event_member_blocked">you blocked %s</string>
@@ -78,7 +78,7 @@
<string name="allow_your_contacts_to_send_voice_messages">Позволи на вашите контакти да изпращат гласови съобщения.</string>
<string name="all_your_contacts_will_remain_connected_update_sent">Всички ваши контакти ще останат свързани. Актуализацията на профила ще бъде изпратена до вашите контакти.</string>
<string name="notifications_mode_service">Винаги включен</string>
<string name="keychain_is_storing_securely">Android Keystore се използва за сигурно съхраняване на паролата - тоа позволява на услугата за известия да работи.</string>
<string name="keychain_is_storing_securely">Android Keystore%s се използва за сигурно съхраняване на паролата - тоа позволява на услугата за известия да работи.</string>
<string name="empty_chat_profile_is_created">Създаен беше празен профил за чат с предоставеното име и приложението се отвари както обикновено.</string>
<string name="notifications_mode_off_desc">Приложението може да получава известия само когато работи, няма да се стартира услуга във фонов режим</string>
<string name="settings_section_title_icon">Икона на приложението</string>
@@ -125,7 +125,7 @@
<string name="settings_section_title_icon">Icona aplicació</string>
<string name="privacy_media_blur_radius">Desenfocar els mitjans</string>
<string name="settings_section_title_calls">Trucades</string>
<string name="keychain_is_storing_securely">Android Keystore s\'utilitza per emmagatzemar de manera segura la frase de contrasenya: permet que el servei de notificacions funcioni.</string>
<string name="keychain_is_storing_securely">Android Keystore%s s\'utilitza per emmagatzemar de manera segura la frase de contrasenya: permet que el servei de notificacions funcioni.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore s\'utilitzarà per emmagatzemar de manera segura la frase de contrasenya després de reiniciar l\'aplicació o canviar la frase de contrasenya; permetrà rebre notificacions.</string>
<string name="cannot_access_keychain">No es pot accedir a Keystore per desar la contrasenya de la base de dades</string>
<string name="impossible_to_recover_passphrase"><![CDATA[<b>Tingueu en compte</b>: NO podreu recuperar ni canviar la contrasenya si la perdeu.]]></string>
@@ -280,7 +280,7 @@
<string name="update_database_passphrase">Aktualizovat přístupovou frázi</string>
<string name="enter_correct_current_passphrase">Zadejte prosím správnou aktuální přístupovou frázi.</string>
<string name="database_is_not_encrypted">Databáze chatu není šifrována - nastavte přístupovou frázi pro její ochranu.</string>
<string name="keychain_is_storing_securely">K bezpečnému uložení přístupové fráze slouží úložiště klíčů Android - umožňuje fungování služby oznámení.</string>
<string name="keychain_is_storing_securely">K bezpečnému uložení přístupové fráze slouží úložiště klíčů Android%s - umožňuje fungování služby oznámení.</string>
<string name="impossible_to_recover_passphrase"><![CDATA[<b>Upozornění</b>: pokud přístupovou frázi ztratíte, NEBUDE možné ji obnovit ani změnit.]]></string>
<string name="database_will_be_encrypted_and_passphrase_stored">Databáze bude zašifrována a přístupová fráze bude uložena v úložišti klíčů.</string>
<string name="store_passphrase_securely">Přístupovou frázi uložte bezpečně, v případě její ztráty ji NEBUDE možné změnit.</string>
@@ -147,7 +147,7 @@
<string name="network_smp_proxy_mode_always_description">Brug altid privat routing.</string>
<string name="always_use_relay">Brug altid relæ</string>
<string name="rcv_group_and_other_events">og %d andre begivenheder</string>
<string name="keychain_is_storing_securely">Android Keystore bruges til sikkert at opbevare adgangssæt - det giver Notification Service mulighed for at arbejde.</string>
<string name="keychain_is_storing_securely">Android Keystore%s bruges til sikkert at opbevare adgangssæt - det giver Notification Service mulighed for at arbejde.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore vil blive brugt til sikkert at gemme adgangssæt, når du genstarter appen eller skifter adgangssætning - det giver mulighed for at modtage meddelelser.</string>
<string name="empty_chat_profile_is_created">En tom chatprofil med det angivne navn oprettes, og appen åbnes som sædvanligt.</string>
<string name="connect__a_new_random_profile_will_be_shared">En ny tilfældig profil deles.</string>
@@ -583,7 +583,7 @@
<string name="update_database_passphrase">Datenbank-Passwort aktualisieren</string>
<string name="enter_correct_current_passphrase">Bitte geben Sie das korrekte, aktuelle Passwort ein.</string>
<string name="database_is_not_encrypted">Ihre Chat-Datenbank ist nicht verschlüsselt. Bitte legen Sie ein Passwort fest, um sie zu schützen.</string>
<string name="keychain_is_storing_securely">Der Android-Keystore wird verwendet, um das Passwort sicher zu speichern. Dies ermöglicht die ordentliche Funktion des Benachrichtigungsdienstes.</string>
<string name="keychain_is_storing_securely">Der Android-Keystore%s wird verwendet, um das Passwort sicher zu speichern. Dies ermöglicht die ordentliche Funktion des Benachrichtigungsdienstes.</string>
<string name="encrypted_with_random_passphrase">Die Datenbank wird mit einem zufälligen Passwort verschlüsselt, Sie können es ändern.</string>
<string name="impossible_to_recover_passphrase"><![CDATA[<b>Bitte beachten Sie</b>: Sie können das Passwort NICHT wiederherstellen oder ändern, wenn Sie es vergessen haben oder verlieren.]]></string>
<string name="keychain_allows_to_receive_ntfs">Der Android-Keystore wird verwendet, um das Passwort sicher zu speichern, nachdem Sie die App neu gestartet oder das Passwort geändert haben – dies ermöglicht den Empfang von Benachrichtigungen.</string>
@@ -242,7 +242,7 @@
<string name="alert_title_msg_bad_hash">Κακό μήνυμα hash</string>
<string name="privacy_media_blur_radius">Θάμπωση των μέσων</string>
<string name="settings_section_title_chat_database">Βάση δεδομένων συνομιλίας</string>
<string name="keychain_is_storing_securely">Το Android Keystore χρησιμοποιείται για την ασφαλή αποθήκευση της φράσης πρόσβασης - επιτρέπει την υπηρεσία ειδοποιήσεων να λειτουργεί.</string>
<string name="keychain_is_storing_securely">Το Android Keystore%s χρησιμοποιείται για την ασφαλή αποθήκευση της φράσης πρόσβασης - επιτρέπει την υπηρεσία ειδοποιήσεων να λειτουργεί.</string>
<string name="member_info_member_blocked">αποκλεισμένος</string>
<string name="member_blocked_by_admin">Αποκλεισμένος από τον διαχειριστή</string>
<string name="cant_call_contact_alert_title">Δεν είναι δυνατή η κλήση επαφής</string>
@@ -51,7 +51,7 @@
<string name="users_delete_all_chats_deleted">Se eliminarán todos los chats y mensajes. ¡No puede deshacerse!</string>
<string name="accept_feature">Aceptar</string>
<string name="allow_to_send_disappearing">Se permiten mensajes temporales.</string>
<string name="keychain_is_storing_securely">Android Keystore se usará para almacenar la frase de contraseña de forma segura - permite que el servicio de notificaciones funcione.</string>
<string name="keychain_is_storing_securely">Android Keystore%s se usará para almacenar la frase de contraseña de forma segura - permite que el servicio de notificaciones funcione.</string>
<string name="users_add">Añadir perfil</string>
<string name="color_primary">Color</string>
<string name="allow_your_contacts_irreversibly_delete">Permites que tus contactos eliminan irreversiblemente los mensajes enviados. (24 horas)</string>
@@ -994,7 +994,7 @@
<string name="encrypt_database">رمزنگاری</string>
<string name="update_database_passphrase">به‌روزرسانی عبارت عبور پایگاه داده</string>
<string name="set_passphrase">تعیین عبارت عبور</string>
<string name="keychain_is_storing_securely">از مخزن کلید اندروید برای ذخیره امن عبارت عبور استفاده می‌شود - به سرویس اعلان اجازه عمل می‌دهد.</string>
<string name="keychain_is_storing_securely">از مخزن کلید اندروید%s برای ذخیره امن عبارت عبور استفاده می‌شود - به سرویس اعلان اجازه عمل می‌دهد.</string>
<string name="you_have_to_enter_passphrase_every_time">باید هر بار که برنامه شروع می‌شود عبارت عبور را وارد کنید - در دستگاه ذخیره نمی‌شود.</string>
<string name="encrypted_database">پایگاه داده رمزنگاری شده</string>
<string name="icon_descr_contact_checked">مخاطب بررسی شد</string>
@@ -115,7 +115,7 @@
<string name="delete_files_and_media_question">Poistetaanko tiedostot ja media\?</string>
<string name="total_files_count_and_size">%d tiedosto(a), joiden kokonaiskoko on %s</string>
<string name="current_passphrase">Nykyinen tunnuslause…</string>
<string name="keychain_is_storing_securely">Android Keystorea käytetään salalauseen turvalliseen tallentamiseen - se mahdollistaa ilmoituspalvelun toiminnan.</string>
<string name="keychain_is_storing_securely">Android Keystorea%s käytetään salalauseen turvalliseen tallentamiseen - se mahdollistaa ilmoituspalvelun toiminnan.</string>
<string name="encrypted_with_random_passphrase">Tietokanta on salattu satunnaisella tunnuslauseella, voit muuttaa sitä.</string>
<string name="database_error">Tietokantavirhe</string>
<string name="database_passphrase_is_required">Keskustelun avaamiseen tarvitaan tietokannan tunnuslause.</string>
@@ -584,7 +584,7 @@
<string name="new_passphrase">Nouvelle phrase secrète…</string>
<string name="confirm_new_passphrase">Confirmer la nouvelle phrase secrète…</string>
<string name="update_database_passphrase">Mise à jour de la phrase secrète de la base de données</string>
<string name="keychain_is_storing_securely">Le Keystore d\'Android est utilisé pour stocker en toute sécurité la phrase secrète - elle permet au service de notification de fonctionner.</string>
<string name="keychain_is_storing_securely">Le Keystore d\'Android%s est utilisé pour stocker en toute sécurité la phrase secrète - elle permet au service de notification de fonctionner.</string>
<string name="you_have_to_enter_passphrase_every_time">Vous devez saisir la phrase secrète à chaque fois que l\'application démarre - elle n\'est pas stockée sur l\'appareil.</string>
<string name="encrypt_database_question">Chiffrer la base de données \?</string>
<string name="change_database_passphrase_question">Changer la phrase secrète de la base de données \?</string>
@@ -41,7 +41,7 @@
<string name="icon_descr_cancel_link_preview">hivatkozáselőnézet visszavonása</string>
<string name="network_session_mode_user_description"><![CDATA[<b>Az összes csevegési profiljához az alkalmazásban</b> külön TCP-kapcsolat (és SOCKS-hitelesítési adat) lesz használva.]]></string>
<string name="both_you_and_your_contact_can_send_disappearing">Mindkét fél küldhet eltűnő üzeneteket.</string>
<string name="keychain_is_storing_securely">Az Android Keystore-t a jelmondat biztonságos tárolására használják – lehetővé teszi az értesítési szolgáltatás működését.</string>
<string name="keychain_is_storing_securely">Az Android Keystore-t%s a jelmondat biztonságos tárolására használják – lehetővé teszi az értesítési szolgáltatás működését.</string>
<string name="alert_title_msg_bad_hash">Hibás az üzenet kivonata</string>
<string name="color_background">Háttér</string>
<string name="socks_proxy_setting_limitations"><![CDATA[<b>Megjegyzés</b>: az üzenet- és a fájlátjátszók SOCKS proxyn keresztül kapcsolódnak. A hívások pedig közvetlen kapcsolatot használnak.]]></string>
@@ -987,7 +987,7 @@
<string name="settings_section_title_themes">Tema</string>
<string name="settings_section_title_delivery_receipts">Kirim tanda terima kiriman ke</string>
<string name="alert_text_fragment_encryption_out_of_sync_old_database">Hal ini dapat terjadi ketika Anda atau koneksi Anda menggunakan cadangan basis data lama.</string>
<string name="keychain_is_storing_securely">Android Keystore digunakan untuk menyimpan frasa sandi dengan aman - memungkinkan layanan notifikasi berfungsi.</string>
<string name="keychain_is_storing_securely">Android Keystore%s digunakan untuk menyimpan frasa sandi dengan aman - memungkinkan layanan notifikasi berfungsi.</string>
<string name="remove_passphrase">Hapus</string>
<string name="encrypt_database">Enkripsi</string>
<string name="messages_section_title">Pesan</string>
@@ -256,7 +256,7 @@
<string name="error_importing_database">Errore nell\'importazione del database della chat</string>
<string name="group_full_name_field">Nome completo del gruppo:</string>
<string name="full_backup">Backup dei dati dell\'app</string>
<string name="keychain_is_storing_securely">L\'archivio chiavi di Android è usato per memorizzare in modo sicuro la password; permette il funzionamento del servizio di notifica.</string>
<string name="keychain_is_storing_securely">L\'archivio chiavi di Android%s è usato per memorizzare in modo sicuro la password; permette il funzionamento del servizio di notifica.</string>
<string name="allow_your_contacts_to_send_voice_messages">Permetti ai tuoi contatti di inviare messaggi vocali.</string>
<string name="chat_database_deleted">Database della chat eliminato</string>
<string name="settings_section_title_icon">Icona app</string>
@@ -51,7 +51,7 @@
<string name="allow_voice_messages_question">לאפשר הודעות קוליות\?</string>
<string name="allow_your_contacts_to_send_voice_messages">אפשר לאנשי קשר לשלוח הודעות קוליות.</string>
<string name="notifications_mode_service">תמיד פעיל</string>
<string name="keychain_is_storing_securely">ישנו שימוש ב־Android Keystore כדי לאחסן בבטחה את הסיסמה – דבר המאפשר לשירות ההתראות לעבוד.</string>
<string name="keychain_is_storing_securely">ישנו שימוש ב־Android Keystore%s כדי לאחסן בבטחה את הסיסמה – דבר המאפשר לשירות ההתראות לעבוד.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore יאחסן בבטחה את הסיסמה לאחר הפעלה מחדש של האפליקציה או שינוי הסיסמה – דבר המאפשר קבלת התראות.</string>
<string name="full_backup">גיבוי נתוני האפליקציה</string>
<string name="settings_section_title_icon">סמל האפליקציה</string>
@@ -61,7 +61,7 @@
<string name="allow_direct_messages">メンバーへのダイレクトメッセージを許可</string>
<string name="allow_to_send_voice">音声メッセージの送信を許可</string>
<string name="notifications_mode_off_desc">アクティブの時のみに通知が出ます。バックグラウンド通知サービスは起動されません。</string>
<string name="keychain_is_storing_securely">Androidキーストアはパスフレーズの保管に使われます。通知機能に必要です。</string>
<string name="keychain_is_storing_securely">Androidキーストア%sはパスフレーズの保管に使われます。通知機能に必要です。</string>
<string name="keychain_allows_to_receive_ntfs">再起動時とパスフレーズ変更時にAndroidキーストアがパスフレーズの保管に使われます。通知機能に必要です。</string>
<string name="answer_call">通話に応答</string>
<string name="settings_section_title_icon">アプリのアイコン</string>
@@ -148,7 +148,7 @@
<string name="allow_irreversible_message_deletion_only_if">대화 상대가 허용하는 경우에만 영구적인 메시지 삭제를 허용합니다. (24 시간)</string>
<string name="all_your_contacts_will_remain_connected">모든 대화 상대가 연결된 상태로 유지됩니다.</string>
<string name="notifications_mode_service">항상 켜기</string>
<string name="keychain_is_storing_securely">Android 암호 저장소는 암호를 안전하게 저장하는 데 사용됩니다 - 알림 서비스가 작동할 수 있습니다.</string>
<string name="keychain_is_storing_securely">Android 암호 저장소%s는 암호를 안전하게 저장하는 데 사용됩니다 - 알림 서비스가 작동할 수 있습니다.</string>
<string name="keychain_allows_to_receive_ntfs">앱을 다시 시작하거나 암호를 변경한 후 Android 암호 저장소를 사용하여 암호를 안전하게 저장합니다. - 알림을 받을 수 있습니다.</string>
<string name="notifications_mode_off_desc">앱이 실행 중일 때만 알림을 받을 수 있으며, 백그라운드 서비스는 시작되지 않습니다.</string>
<string name="full_backup">앱 데이터 백업</string>
@@ -523,7 +523,7 @@
<string name="all_app_data_will_be_cleared">Visi programėlės duomenys bus ištrinti.</string>
<string name="empty_chat_profile_is_created">Sukuriamas tuščias pokalbių profilis nurodytu pavadinimu ir programėlė atveriama kaip įprasta.</string>
<string name="settings_section_title_app">Programėlė</string>
<string name="keychain_is_storing_securely">Saugiam slaptafrazės saugojimui yra naudojama „Android Keystore“ – tai įgalina pranešimų tarnybą veikti.</string>
<string name="keychain_is_storing_securely">Saugiam slaptafrazės saugojimui yra naudojama „Android Keystore“%s – tai įgalina pranešimų tarnybą veikti.</string>
<string name="color_secondary_variant">Papildoma antrinė spalva</string>
<string name="color_primary_variant">Papildomas akcentavimas</string>
<string name="allow_to_send_files">Leisti siųsti failus ir mediją.</string>
@@ -1672,7 +1672,7 @@
<string name="set_passphrase">Iestatīt frāzi</string>
<string name="enter_correct_current_passphrase">Ievadiet pareizo pašreizējo frāzi</string>
<string name="database_is_not_encrypted">Datubāze nav šifrēta</string>
<string name="keychain_is_storing_securely">Atslēgu glabātuve tiek droši glabāta</string>
<string name="keychain_is_storing_securely">Atslēgu glabātuve%s tiek droši glabāta</string>
<string name="settings_is_storing_in_clear_text">Iestatījumi tiek glabāti parastā tekstā</string>
<string name="encrypted_with_random_passphrase">Šifrēts ar nejaušu frāzi</string>
<string name="impossible_to_recover_passphrase"><![CDATA[Nav iespējams atgūt frāzi]]></string>
@@ -117,7 +117,7 @@
<string name="network_smp_proxy_mode_always_description">Bruk alltid privat ruting.</string>
<string name="always_use_relay">Bruk alltid relé</string>
<string name="rcv_group_and_other_events">og %d andre hendelser</string>
<string name="keychain_is_storing_securely">Android Keystore brukes til å lagre passord på en sikker måte – det gjør at varslingstjenesten fungerer.</string>
<string name="keychain_is_storing_securely">Android Keystore%s brukes til å lagre passord på en sikker måte – det gjør at varslingstjenesten fungerer.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore brukes til å trygt lagre passordet ditt etter at du restarter appen eller bytter passord - det gjør at du kan motta varsler.</string>
<string name="empty_chat_profile_is_created">En tom chat-profil med navnet du har valgt vil bli laget, og appen åpnes som vanlig.</string>
<string name="connect__a_new_random_profile_will_be_shared">En ny tilfeldig profil vil bli delt.</string>
@@ -79,7 +79,7 @@
<string name="icon_descr_audio_off">Geluid uit</string>
<string name="full_backup">Back-up van app gegevens</string>
<string name="answer_call">Beantwoord oproep</string>
<string name="keychain_is_storing_securely">Android Keychain wordt gebruikt om het wachtwoord veilig op te slaan, hierdoor kan de meldings service werken.</string>
<string name="keychain_is_storing_securely">Android Keychain%s wordt gebruikt om het wachtwoord veilig op te slaan, hierdoor kan de meldings service werken.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keychain wordt gebruikt om het wachtwoord veilig op te slaan nadat u de app opnieuw hebt opgestart of het wachtwoord heeft gewijzigd, hiermee kunt u meldingen ontvangen.</string>
<string name="app_version_code">App build: %s</string>
<string name="notifications_mode_off_desc">App kan alleen meldingen ontvangen wanneer deze actief is, er wordt geen achtergrondservice gestart</string>
@@ -538,7 +538,7 @@
<string name="update_database">Aktualizuj</string>
<string name="update_database_passphrase">Aktualizuj hasło do bazy danych</string>
<string name="database_is_not_encrypted">Twoja baza danych czatu nie jest szyfrowana - ustaw hasło, aby ją chronić.</string>
<string name="keychain_is_storing_securely">Android Keystore służy do bezpiecznego przechowywania hasła - umożliwia działanie usługi powiadomień.</string>
<string name="keychain_is_storing_securely">Android Keystore%s służy do bezpiecznego przechowywania hasła - umożliwia działanie usługi powiadomień.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore będzie używany do bezpiecznego przechowywania hasła po ponownym uruchomieniu aplikacji lub zmianie hasła - pozwoli to na otrzymywanie powiadomień.</string>
<string name="impossible_to_recover_passphrase"><![CDATA[<b> Uwaga</b>: NIE będziesz w stanie odzyskać ani zmienić hasła, jeśli je zgubisz.]]></string>
<string name="cannot_access_keychain">Nie można uzyskać dostępu do Keystore w celu zapisania hasła bazy danych</string>
@@ -54,7 +54,7 @@
<string name="call_on_lock_screen">Chamadas na tela de bloqueio:</string>
<string name="icon_descr_audio_on">Áudio ligado</string>
<string name="chat_database_imported">Banco de dados do chat importado</string>
<string name="keychain_is_storing_securely">O Android Keystore é usado para armazenar a senha com segurança. Isso permite que o serviço de notificações funcione.</string>
<string name="keychain_is_storing_securely">O Android Keystore%s é usado para armazenar a senha com segurança. Isso permite que o serviço de notificações funcione.</string>
<string name="keychain_allows_to_receive_ntfs">O Android Keystore será usado para armazenar a senha com segurança após você reiniciar o aplicativo ou alterar a senha, permitindo continuar recebendo notificações.</string>
<string name="cannot_access_keychain">Não é possível acessar a Keystore para salvar a senha do banco de dados</string>
<string name="chat_is_stopped_indication">O chat está parado</string>
@@ -166,7 +166,7 @@
<string name="voice_messages_prohibited">Mensagens de voz proibidas!</string>
<string name="voice_message_with_duration">Mensagem de voz (%1$s)</string>
<string name="app_version_name">Versão da aplicação: v%s</string>
<string name="keychain_is_storing_securely">O Android Keystore é usado para armazenar com segurança a senha - permite que o serviço de notificações funcione.</string>
<string name="keychain_is_storing_securely">O Android Keystore%s é usado para armazenar com segurança a senha - permite que o serviço de notificações funcione.</string>
<string name="keychain_allows_to_receive_ntfs">O Android Keystore será usado para armazenar com segurança a senha depois de voçê reiniciar a aplicação ou alterar a senha - irá permitir receber notificações.</string>
<string name="notifications_will_be_hidden">As notificações serão entregues apenas até à aplicação parar!</string>
<string name="app_version_code">Compilação da aplicação: %s</string>
@@ -93,7 +93,7 @@
<string name="connect_plan_already_joining_the_group">Se alătură deja grupului!</string>
<string name="notifications_mode_service">Mereu pornit</string>
<string name="connect__a_new_random_profile_will_be_shared">Un nou profil aleatoriu va fi distribuit.</string>
<string name="keychain_is_storing_securely">Android Keystore este folosit pentru a stoca în siguranță parola. Acest lucru permite funcționarea serviciului de notificări.</string>
<string name="keychain_is_storing_securely">Android Keystore%s este folosit pentru a stoca în siguranță parola. Acest lucru permite funcționarea serviciului de notificări.</string>
<string name="rcv_group_and_other_events">și %d alte evenimente</string>
<string name="answer_call">Răspunde la apel</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore va fi folosit pentru a stoca în siguranță parola după ce repornești aplicația sau schimbi parola — acest lucru va permite primirea de notificări.</string>
@@ -585,7 +585,7 @@
<string name="update_database_passphrase">Поменять пароль</string>
<string name="enter_correct_current_passphrase">Пожалуйста, введите правильный пароль.</string>
<string name="database_is_not_encrypted">База данных НЕ зашифрована. Установите пароль, чтобы защитить Ваши данные.</string>
<string name="keychain_is_storing_securely">Android Keystore используется для безопасного хранения пароля - это позволяет стабильно получать уведомления в фоновом режиме.</string>
<string name="keychain_is_storing_securely">Android Keystore%s используется для безопасного хранения пароля - это позволяет стабильно получать уведомления в фоновом режиме.</string>
<string name="encrypted_with_random_passphrase">База данных зашифрована случайным паролем, Вы можете его поменять.</string>
<string name="impossible_to_recover_passphrase"><![CDATA[<b>Внимание</b>: Вы не сможете восстановить или поменять пароль, если потеряете его.]]></string>
<string name="keychain_allows_to_receive_ntfs">Пароль базы данных будет безопасно сохранён в Android Keystore после запуска чата или изменения пароля - это позволит стабильно получать уведомления.</string>
@@ -651,7 +651,7 @@
<string name="set_password_to_export">Nastavte prístupovú frázu pre export</string>
<string name="to_reveal_profile_enter_password">Aby ste odhalili svoj skrytý profil, zadajte celé heslo do vyhľadávacieho poľa na stránke profilov chatov.</string>
<string name="network_proxy_auth_mode_username_password">Vaše prihlasovacie údaje môžu byť zaslané nešifrované.</string>
<string name="keychain_is_storing_securely">Android Keystore je použitý na bezpečné uloženie prístupovej frázy - umožňuje to fungovanie služby oznámení.</string>
<string name="keychain_is_storing_securely">Android Keystore%s je použitý na bezpečné uloženie prístupovej frázy - umožňuje to fungovanie služby oznámení.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore bude použitý na bezpečné uloženie prístupovej frázy po reštarte aplikácie alebo zmene prístupovej frázy - umožní to fungovanie služby oznámení.</string>
<string name="impossible_to_recover_passphrase"><![CDATA[<b>Upozornenie</b>: ak stratíte vašu prístupovú frázu, NEBUDE možné ju obnoviť ani zmeniť.]]></string>
<string name="change_database_passphrase_question">Zmeniť prístupovú frázu k databáze?</string>
@@ -23,7 +23,7 @@
<string name="color_primary_variant">เน้นสีเพิ่มเติม</string>
<string name="settings_section_title_icon">ไอคอนแอป</string>
<string name="v5_0_app_passcode">รหัสผ่านแอป</string>
<string name="keychain_is_storing_securely">Android Keystore ใช้เพื่อจัดเก็บรหัสผ่านอย่างปลอดภัย - ซึ่งจะช่วยให้บริการแจ้งเตือนทำงานได้</string>
<string name="keychain_is_storing_securely">Android Keystore%s ใช้เพื่อจัดเก็บรหัสผ่านอย่างปลอดภัย - ซึ่งจะช่วยให้บริการแจ้งเตือนทำงานได้</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore จะถูกใช้เพื่อจัดเก็บรหัสผ่านอย่างปลอดภัยหลังจากที่คุณรีสตาร์ทแอปหรือเปลี่ยนรหัสผ่าน - ซึ่งจะอนุญาตให้รับบริการแจ้งเตือนได้</string>
<string name="app_version_code">รุ่นแอป: %s</string>
<string name="full_backup">การสํารองข้อมูลแอป</string>
@@ -665,7 +665,7 @@
<string name="v4_2_auto_accept_contact_requests">Bağlanma isteklerini otomatik kabul et</string>
<string name="database_downgrade_warning">Uyarı: Bazı verileri kaybedebilirsin!</string>
<string name="all_your_contacts_will_remain_connected_update_sent">Tüm kişileriniz bağlı kalacaktır. Profil güncellemesi kişilerinize gönderilecektir.</string>
<string name="keychain_is_storing_securely">Android Keystore parolayı güvenli bir şekilde saklamak için kullanılır - bildirim hizmetinin çalışmasını sağlar.</string>
<string name="keychain_is_storing_securely">Android Keystore%s parolayı güvenli bir şekilde saklamak için kullanılır - bildirim hizmetinin çalışmasını sağlar.</string>
<string name="button_welcome_message">Karşılama mesajı</string>
<string name="group_welcome_title">Karşılama mesajı</string>
<string name="voice_messages_are_prohibited">Sesli mesajlar yasaktır.</string>
@@ -25,7 +25,7 @@
<string name="app_passcode_replaced_with_self_destruct">Пароль застосунку замінено паролем самознищення.</string>
<string name="full_backup">Резервне копіювання даних застосунку</string>
<string name="smp_servers_add_to_another_device">Додати на інший пристрій</string>
<string name="keychain_is_storing_securely">Сховище ключів Android використовується для безпечного збереження ключової фрази - це дозволяє службі сповіщень працювати.</string>
<string name="keychain_is_storing_securely">Сховище ключів Android%s використовується для безпечного збереження ключової фрази - це дозволяє службі сповіщень працювати.</string>
<string name="v4_2_group_links_desc">Адміністратори можуть створювати посилання для приєднання до групи.</string>
<string name="app_version_code">Збірка додатку: %s</string>
<string name="allow_voice_messages_only_if">Дозволити голосові повідомлення тільки за умови, що ваш контакт дозволяє їх.</string>
@@ -82,7 +82,7 @@
<string name="connect_plan_already_joining_the_group">Đã tham gia nhóm rồi!</string>
<string name="always_use_relay">Luôn sử dụng relay</string>
<string name="rcv_group_and_other_events">và %d sự kiện khác</string>
<string name="keychain_is_storing_securely">Android Keystore được sử dụng để lưu trữ passphrase - nó cho phép dịch vụ thông báo hoạt động.</string>
<string name="keychain_is_storing_securely">Android Keystore%s được sử dụng để lưu trữ passphrase - nó cho phép dịch vụ thông báo hoạt động.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore sẽ được sử dụng để lưu trữ passphrase một cách an toàn sau khi bạn khởi động lại ứng dụng hoặc thay đổi passphrase - nó cho phép tiếp nhận thông báo.</string>
<string name="migrate_from_device_all_data_will_be_uploaded">Tất cả các liên hệ, cuộc hội thoại và tệp của bạn sẽ được mã hóa an toàn và tải lên từng phần tới các XFTP relay được chỉ định.</string>
<string name="v5_6_safer_groups_descr">Quản trị viên có thể chặn một thành viên khỏi tất cả.</string>
@@ -89,7 +89,7 @@
<string name="users_delete_profile_for">为此删除聊天资料</string>
<string name="delete_database">删除数据库</string>
<string name="keychain_allows_to_receive_ntfs">在你重启应用程序或者更换密码后安卓密钥库系统用来安全地保存密码——来确保收到通知。</string>
<string name="keychain_is_storing_securely">安卓密钥库系统用来安全地保存密码——来确保通知服务运作。</string>
<string name="keychain_is_storing_securely">安卓密钥库系统%s用来安全地保存密码——来确保通知服务运作。</string>
<string name="appearance_settings">外观</string>
<string name="app_version_title">应用程序版本</string>
<string name="full_backup">应用程序数据备份</string>
@@ -94,7 +94,7 @@
<string name="full_backup">備份應用程式資料</string>
<string name="settings_section_title_icon">應用程式圖示</string>
<string name="chat_database_imported">已匯入對話資料庫</string>
<string name="keychain_is_storing_securely">Android 金鑰庫是用於安全地儲存密碼 - 確保通知推送服務的運作。</string>
<string name="keychain_is_storing_securely">Android 金鑰庫%s是用於安全地儲存密碼 - 確保通知推送服務的運作。</string>
<string name="impossible_to_recover_passphrase"><![CDATA[<b>請注意</b>:如果你忘記了密碼你將不能再次復原或修改密碼。]]></string>
<string name="keychain_allows_to_receive_ntfs">當你重新啟動應用程式或修改密碼後, Android 金鑰庫將用來安全地儲存密碼 - 將允許接收訊息通知。</string>
<string name="chat_is_stopped_indication">聊天已停止</string>
@@ -182,9 +182,10 @@ private fun ApplicationScope.AppWindow(closedByError: MutableState<Boolean>) {
}
}
var windowFocused by remember { simplexWindowState.windowFocused }
LaunchedEffect(windowFocused) {
val showCallView = ChatModel.showCallView.value
LaunchedEffect(windowFocused, showCallView) {
val delay = ChatController.appPrefs.laLockDelay.get()
if (!windowFocused && ChatModel.showAuthScreen.value && delay > 0) {
if (!windowFocused && !showCallView && ChatModel.showAuthScreen.value && delay > 0) {
delay(delay * 1000L)
// Trigger auth state check when delay ends (and if it ends)
AppLock.recheckAuthState()
@@ -12,4 +12,6 @@ actual val cryptor: CryptorInterface = object : CryptorInterface {
override fun deleteKey(alias: String) {
// LALAL
}
override fun keyStorage(alias: String): String? = null
}
@@ -20,6 +20,7 @@ import java.io.IOException
import java.net.BindException
import java.security.SecureRandom
import java.util.Base64
import java.util.concurrent.atomic.AtomicBoolean
private const val SERVER_HOST = "localhost"
private const val SERVER_PORT = 50395
@@ -31,7 +32,7 @@ val connections = ArrayList<WebSocket>()
actual fun ActiveCallView() {
val scope = rememberCoroutineScope()
WebRTCController(chatModel.callCommand) { apiMsg ->
Log.d(TAG, "received from WebRTCController: $apiMsg")
Log.d(TAG, "received from WebRTCController: ${apiMsg.resp.javaClass.simpleName}")
val call = chatModel.activeCall.value
if (call != null) {
Log.d(TAG, "has active call $call")
@@ -202,7 +203,7 @@ fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (
}
while (callCommand.isNotEmpty()) {
val cmd = callCommand.removeFirstOrNull()
Log.d(TAG, "WebRTCController LaunchedEffect executing $cmd")
Log.d(TAG, "WebRTCController LaunchedEffect executing ${cmd?.javaClass?.simpleName}")
if (cmd != null) {
processCommand(cmd)
}
@@ -232,10 +233,16 @@ fun startServer(
val resourceNotFound = newFixedLengthResponse(Status.NOT_FOUND, "text/plain", "This page couldn't be found")
val webSocketAccepted = AtomicBoolean(false)
override fun handle(session: IHTTPSession): Response {
return when {
session.headers["upgrade"] == "websocket" ->
if (hasValidCallServerToken(session.parameters, token)) {
if (
session.headers["origin"] == "http://${SERVER_HOST}:${listeningPort}"
&& hasValidCallServerToken(session.parameters, token)
&& webSocketAccepted.compareAndSet(false, true)
) {
super.handle(session)
} else {
unauthorizedResponse()
@@ -289,7 +296,7 @@ class MyWebSocket(val onResponse: (WVAPIMessage) -> Unit, handshakeRequest: IHTT
// onResponse(message.textPayload)
onResponse(json.decodeFromString(message.textPayload))
} catch (e: Exception) {
Log.e(TAG, "failed parsing browser message: $message")
Log.e(TAG, "failed parsing browser message")
}
}
@@ -59,6 +59,7 @@ actual fun DatabaseEncryptionFooter(
useKeychain: MutableState<Boolean>,
chatDbEncrypted: Boolean?,
storedKey: MutableState<Boolean>,
keyStorage: String?,
initialRandomDBPassphrase: MutableState<Boolean>,
migration: Boolean,
) {
@@ -33,6 +33,23 @@ class CallServerAuthTest {
assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token")))
}
@Test
fun testWebSocketUpgradeRejectedFromOtherOrigin() {
assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=$token", origin = "http://example.com")))
}
@Test
fun testSecondWebSocketUpgradeRejected() {
assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token")))
assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=$token")))
}
@Test
fun testWebSocketUpgradeAcceptedAfterWrongToken() {
assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=wrong")))
assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token")))
}
@Test
fun testCallPageRejectedWithoutToken() {
assertEquals(401, requestStatus(get(path = "/simplex/call/")))
@@ -46,10 +63,11 @@ class CallServerAuthTest {
private fun get(path: String): List<String> = listOf("GET $path HTTP/1.1", "Host: localhost:$port")
private fun webSocketUpgrade(path: String): List<String> =
private fun webSocketUpgrade(path: String, origin: String = "http://localhost:$port"): List<String> =
listOf(
"GET $path HTTP/1.1",
"Host: localhost:$port",
"Origin: $origin",
"Upgrade: websocket",
"Connection: Upgrade",
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
+4 -4
View File
@@ -24,11 +24,11 @@ android.nonTransitiveRClass=true
kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.jvm.target=11
android.version_name=7.1-beta.4
android.version_code=379
android.version_name=7.1-beta.6
android.version_code=386
desktop.version_name=7.1-beta.4
desktop.version_code=162
desktop.version_name=7.1-beta.6
desktop.version_code=167
kotlin.version=2.1.20
gradle.plugin.version=8.7.0
+1 -1
View File
@@ -306,7 +306,7 @@ User-defined tags for organizing conversations. CRUD via `ApiCreateChatTag`, `Ap
The real-time communication framework used for audio and video calls. The app uses WebRTC for peer-to-peer media streams, with SMP used only for call signaling (offer/answer/ICE candidates).
### Call (data class)
Represents an active call session. Fields: `remoteHostId`, `userProfile`, `contact`, `callUUID`, `callState` (CallState enum), `initialCallType` (Audio/Video), `localMediaSources`, `localCapabilities`, `peerMediaSources`, `sharedKey` (for E2E call encryption), `connectionInfo`, `connectedAt`.
Represents an active call session. Fields: `remoteHostId`, `userProfile`, `contact`, `callUUID`, `callState` (CallState enum), `initialCallType` (Audio/Video), `localMediaSources`, `localCapabilities`, `peerMediaSources`, `hasSharedKey` (whether an E2E call encryption key was agreed), `connectionInfo`, `connectedAt`.
*See:* `common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt:14`
+1 -1
View File
@@ -128,7 +128,7 @@ Common Module (commonMain)
| Chat Model | [`ChatModel.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L86) | `object ChatModel` | 86 |
| App Preferences | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L102) | `class AppPreferences` | 102 |
| Platform Interface | [`Platform.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L15) | `interface PlatformInterface` | 15 |
| Notification Manager | [`NtfManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L19) | `abstract class NtfManager` | 19 |
| Notification Manager | [`NtfManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L20) | `abstract class NtfManager` | 20 |
| Theme Manager | [`ThemeManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L18) | `object ThemeManager` | 18 |
| Android Haskell Init | [`AppCommon.android.kt`](../common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt#L33) | `fun initHaskell(packageName: String)` | 33 |
| Common Migrations | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L41) | `fun runMigrations()` | 41 |
+4 -4
View File
@@ -115,8 +115,8 @@ When onboarding is complete:
| `SwitchingUsersView` | User switch in progress | Loading overlay |
| Auth gate | `userAuthorized != true` | `AuthView` or `SplashView` + passcode |
| Active call | `showCallView == true` | `ActiveCallView` (desktop) or call activity (Android) |
| One-time passcode | Always | `ModalManager.fullscreen.showOneTimePasscodeInView` |
| Privacy alerts | Always | `AlertManager.privacySensitive` |
| One-time passcode | `userAuthorized == true` | `ModalManager.fullscreen.showOneTimePasscodeInView` |
| Privacy alerts | `userAuthorized == true` | `AlertManager.privacySensitive` |
| Incoming call | `activeCallInvitation != null` | `IncomingCallAlertView` |
| Shared alerts | Always | `AlertManager.shared` |
@@ -294,7 +294,7 @@ object AppLock {
### Authentication Flow
1. **MainScreen** checks `unauthorized` (derived: `userAuthorized.value != true`) at line ~135.
2. If unauthorized and not in an active call:
2. If unauthorized, including during an active call:
- Launches `AppLock.runAuthenticate()` which triggers platform-specific biometric/passcode prompt.
- On Android with system auth finishing during activity destruction, authentication is skipped.
3. If `performLA` preference is set and `laFailed` is true: shows `AuthView` with "Unlock" button.
@@ -302,7 +302,7 @@ object AppLock {
### Lock Delay
The `laLockDelay` preference controls how long after backgrounding the app requires re-authentication. When `laLockDelay == 0`, screen rotation triggers a 3-second grace period (line ~270) to prevent unnecessary re-auth.
The `laLockDelay` preference controls how long after backgrounding the app requires re-authentication. When `laLockDelay == 0`, screen rotation triggers a 3-second grace period (line ~270) to prevent unnecessary re-auth. A call counts as activity: `recheckAuthState()` is a no-op while `showCallView` is true, and `CallManager.endCall()` resets `enteredBackground`, so the delay is counted from the end of the call. When a call is accepted from a notification or from the Android lock screen, `recheckAuthState()` is called before `acceptIncomingCall()`, so an expired delay still locks the app.
### Lock Modes
+17 -15
View File
@@ -59,18 +59,18 @@ State transitions are driven by `WCallResponse` messages from the WebRTC layer.
## 3. Android Implementation
### 3.1 CallActivity.kt (464 lines)
### 3.1 CallActivity.kt (468 lines)
[`CallActivity.kt`](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt)
A dedicated `ComponentActivity` that hosts the call UI. Key responsibilities:
- **Intent handling** ([line 64](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L64)): On `AcceptCallAction` intent, looks up the matching `RcvCallInvitation` and calls `callManager.acceptIncomingCall()`.
- **Lock screen support** ([line 160](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L160)): `unlockForIncomingCall()` uses `setShowWhenLocked(true)` / `setTurnScreenOn(true)` on API 27+, falls back to window flags on older versions. `lockAfterIncomingCall()` reverses these settings.
- **Picture-in-Picture** ([line 99](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L99)): `setPipParams()` configures PiP aspect ratio and source rect hint. On Android 12+ (`Build.VERSION_CODES.S`), auto-enter PiP is enabled for video calls. `onPictureInPictureModeChanged` toggles `activeCallViewIsCollapsed` and sends a `WCallCommand.Layout` command.
- **Permission checks** ([line 122](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L122)): Checks `RECORD_AUDIO` and conditionally `CAMERA` permissions.
- **Service binding** ([line 181](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L181)): Binds to `CallService` as a workaround for Android 12 background activity launch restrictions.
- **CallActivityView composable** ([line 208](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L208)): Renders `ActiveCallView()` when permissions are granted and a call is active. Shows `CallPermissionsView` when permissions are needed. Shows `IncomingCallLockScreenAlert` for incoming calls on the lock screen.
- **Intent handling** ([line 65](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L65)): On `AcceptCallAction` intent, looks up the matching `RcvCallInvitation` and calls `callManager.acceptIncomingCall()`.
- **Lock screen support** ([line 161](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L161)): `unlockForIncomingCall()` uses `setShowWhenLocked(true)` / `setTurnScreenOn(true)` on API 27+, falls back to window flags on older versions. `lockAfterIncomingCall()` reverses these settings.
- **Picture-in-Picture** ([line 100](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L100)): `setPipParams()` configures PiP aspect ratio and source rect hint. On Android 12+ (`Build.VERSION_CODES.S`), auto-enter PiP is enabled for video calls. `onPictureInPictureModeChanged` toggles `activeCallViewIsCollapsed` and sends a `WCallCommand.Layout` command.
- **Permission checks** ([line 123](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L123)): Checks `RECORD_AUDIO` and conditionally `CAMERA` permissions.
- **Service binding** ([line 182](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L182)): Binds to `CallService` as a workaround for Android 12 background activity launch restrictions.
- **CallActivityView composable** ([line 209](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L209)): Renders `ActiveCallView()` when permissions are granted and a call is active. Shows `CallPermissionsView` when permissions are needed. Shows `IncomingCallLockScreenAlert` for incoming calls on the lock screen.
### 3.2 CallService.kt (207 lines)
@@ -113,17 +113,19 @@ The `actual` platform implementation of `ActiveCallView()` and supporting compos
## 4. Desktop Implementation
### 4.1 CallView.desktop.kt (263 lines)
### 4.1 CallView.desktop.kt (308 lines)
[`CallView.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt)
Desktop calls run WebRTC in the system browser, not an embedded WebView:
- **NanoWSD server** ([line 209](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L209)): `startServer()` creates a `NanoWSD` instance bound to `localhost:50395`. If that port is already in use it falls back to an OS-assigned free port (`port 0`); `WebRTCController` reads `server.listeningPort` for the browser URL. The server serves `call.html` from JAR resources at `/assets/www/desktop/call.html` for the path `/simplex/call/`. All other paths serve resources from `/assets/www/`.
- **WebSocket communication** ([line 238](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L238)): `MyWebSocket` handles WebSocket frames from the browser. `onMessage` deserializes JSON into `WVAPIMessage` and forwards to the response handler. `onClose` triggers `WCallResponse.End`.
- **WebRTCController** ([line 153](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L153)): Starts the server, then opens `http://localhost:<listeningPort>/simplex/call/` (normally `50395`) via `LocalUriHandler`. Processes `WCallCommand` queue by sending JSON over WebSocket to all active connections. On dispose, sends `WCallCommand.End` and stops the server.
- **SendStateUpdates** ([line 137](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L137)): Sends `WCallCommand.Description` with call state and encryption info text to the browser for display.
- **ActiveCallView** ([line 28](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L28)): Handles `WCallResponse` messages identically to Android (same state machine), plus a `WCallCommand.Permission` message on `Capabilities` error for browser permission denial guidance.
- **NanoWSD server** ([line 215](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L215)): `startServer()` creates a `NanoWSD` instance bound to `localhost:50395`. If that port is already in use it falls back to an OS-assigned free port (`port 0`); `WebRTCController` reads `server.listeningPort` for the browser URL. The server serves `call.html` from JAR resources at `/assets/www/desktop/call.html` for the path `/simplex/call/` when the request includes a valid `token` query parameter. All other paths serve resources from `/assets/www/`.
- **Call token** ([line 271](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L271)): `newCallServerToken()` returns 32 random bytes, base64url-encoded. `hasValidCallServerToken()` ([line 277](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L277)) checks the `token` query parameter.
- **WebSocket upgrade** ([line 238](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L238)): Each server instance accepts one WebSocket upgrade. The upgrade request must include `Origin: http://localhost:<listeningPort>` and a valid token. Other upgrade requests receive `401 Unauthorized`.
- **WebSocket communication** ([line 283](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L283)): `MyWebSocket` handles WebSocket frames from the browser. `onMessage` deserializes JSON into `WVAPIMessage` and forwards to the response handler. `onClose` triggers `WCallResponse.End`.
- **WebRTCController** ([line 157](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L157)): Generates the call token, starts the server, then opens `http://localhost:<listeningPort>/simplex/call/?token=<token>` (normally `50395`) via `LocalUriHandler`. Processes `WCallCommand` queue by sending JSON over the WebSocket. On dispose, sends `WCallCommand.End` and stops the server.
- **SendStateUpdates** ([line 141](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L141)): Sends `WCallCommand.Description` with call state and encryption info text to the browser for display.
- **ActiveCallView** ([line 32](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L32)): Handles `WCallResponse` messages identically to Android (same state machine), plus a `WCallCommand.Permission` message on `Capabilities` error for browser permission denial guidance.
---
@@ -166,8 +168,8 @@ An in-app notification banner shown when a call invitation arrives while the app
|---|---|---|---|
| `CallView.kt` | [`common/src/commonMain/.../views/call/CallView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt) | 28 | `expect fun ActiveCallView()`, delivery receipt waiting |
| `CallView.android.kt` | [`common/src/androidMain/.../views/call/CallView.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt) | 891 | Android WebView WebRTC, overlay, permissions |
| `CallView.desktop.kt` | [`common/src/desktopMain/.../views/call/CallView.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt) | 263 | Desktop browser WebRTC via NanoWSD |
| `CallActivity.kt` | [`android/src/main/java/.../views/call/CallActivity.kt`](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt) | 464 | Android call Activity, PiP, lock screen |
| `CallView.desktop.kt` | [`common/src/desktopMain/.../views/call/CallView.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt) | 308 | Desktop browser WebRTC via NanoWSD |
| `CallActivity.kt` | [`android/src/main/java/.../views/call/CallActivity.kt`](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt) | 472 | Android call Activity, PiP, lock screen |
| `CallService.kt` | [`android/src/main/java/.../CallService.kt`](../../android/src/main/java/chat/simplex/app/CallService.kt) | 207 | Android foreground service for calls |
| `CallManager.kt` | [`common/src/commonMain/.../views/call/CallManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt) | 119 | Call lifecycle management |
| `WebRTC.kt` | [`common/src/commonMain/.../views/call/WebRTC.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt) | -- | `CallState` enum, `WCallCommand`, `WCallResponse` types |
@@ -35,16 +35,16 @@ The architecture uses an abstract `NtfManager` in common code with platform-spec
[`NtfManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt) (139 lines, commonMain)
The global `ntfManager` instance is declared at [line 17](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L17) and initialized by each platform at startup.
The global `ntfManager` instance is declared at [line 18](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L18) and initialized by each platform at startup.
### Concrete methods
| Method | Line | Description |
|---|---|---|
| `notifyContactConnected` | [L20](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L20) | Displays "contact connected" notification for a `Contact` |
| `notifyContactRequestReceived` | [L27](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L27) | Shows contact request notification with an "Accept" action button |
| `notifyMessageReceived` | [L38](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L38) | Conditionally shows message notification based on `ntfsEnabled`, `showNotification`, and whether user is viewing that chat |
| `acceptContactRequestAction` | [L51](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L51) | Accepts a contact request from a notification action |
| `notifyContactConnected` | [L21](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L21) | Displays "contact connected" notification for a `Contact` |
| `notifyContactRequestReceived` | [L28](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L28) | Shows contact request notification with an "Accept" action button |
| `notifyMessageReceived` | [L39](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L39) | Conditionally shows message notification based on `ntfsEnabled`, `showNotification`, and whether user is viewing that chat |
| `acceptContactRequestAction` | [L52](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L52) | Accepts a contact request from a notification action |
| `openChatAction` | [L59](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L59) | Opens a specific chat from a notification tap, switching user if needed |
| `showChatsAction` | [L74](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L74) | Opens the chat list, switching user if needed |
| `acceptCallAction` | [L88](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L88) | Accepts a call invitation from a notification action |
@@ -101,7 +101,7 @@ Channel creation happens in `createNtfChannelsMaybeShowAlert()` ([line 298](../.
[Line 160](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L160):
- Returns `false` (no notification) if app is in foreground -- in-app alert is used instead.
- **Lock screen / screen off**: Uses `setFullScreenIntent` with a `PendingIntent` to `CallActivity`, plus `VISIBILITY_PUBLIC`.
- **Lock screen / screen off**: Uses `setFullScreenIntent` with a `PendingIntent` to `CallActivity`.
- **Foreground / unlocked**: Uses regular notification with Accept/Reject action buttons and a custom ringtone (`ring_once` raw resource).
- Notification flags include `FLAG_INSISTENT` for repeating sound and vibration.
- Call notification channel vibration pattern: `[250, 250, 0, 2600]` ms.
+12 -6
View File
@@ -5,13 +5,19 @@ module Main where
import BadgeService.Options (BadgeServiceOpts (..))
import BadgeService.Service
import Control.Logger.Simple (LogConfig (..), LogLevel (..), setLogLevel, withGlobalLogging)
import GHC.IO.Encoding (setLocaleEncoding)
import Simplex.Chat.Terminal (terminalChatConfig)
import System.IO (hSetEncoding, stderr, stdout, utf8)
-- | withGlobalLogging installs the SMP agent's log sinks, which the chat core otherwise installs only under --log-agent.
main :: IO ()
main = withGlobalLogging LogConfig {lc_file = Nothing, lc_stderr = True} $ do
setLogLevel LogWarn
opts@BadgeServiceOpts {runCLI} <- welcomeGetOpts
if runCLI
then badgeServiceCLI opts
else newServiceState >>= badgeService opts terminalChatConfig
main = do
-- Without a UTF-8 locale GHC reads the ini and writes logs as ASCII, and throws on a non-ASCII group name.
setLocaleEncoding utf8
mapM_ (`hSetEncoding` utf8) [stdout, stderr]
withGlobalLogging LogConfig {lc_file = Nothing, lc_stderr = True} $ do
setLogLevel LogWarn
opts@BadgeServiceOpts {runCLI} <- welcomeGetOpts
if runCLI
then badgeServiceCLI opts
else newServiceState >>= badgeService opts terminalChatConfig
+87 -26
View File
@@ -21,8 +21,10 @@ At this stage the service:
- creates a double-ratchet contact address on first start (service RPC requires DR, see [`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md)),
- listens for service requests (`CEvtServiceRequest`) on that address, rejects a request whose `purchaseKey` is not the key the agent verified the signature against, and answers `redeemBadgeCode`,
- issues redemption codes, storing only their `SHA-256` and printing each code once,
- does not accept contact requests unless `[dev] chat_redeem` is on: the address is for RPC only,
- issues redemption codes, storing only their `SHA-256` in its code table,
- does not accept contact requests: the address is for RPC only,
- in service mode with `[group]` in the ini, manages one SimpleX group and serves `/issue`, `/bulk`
and `/revoke` in it (see [Issuing codes](#issuing-codes)),
- in service mode with `--service-config`, also serves the built web app (`npm run build` in `web/`), `POST /api/invoice` and `GET /api/invoice/:id`, the BTCPay and Stripe webhook routes, and a payment poller, seeding its price/offer catalog on every start,
- owns the `sx_badge_service_`-prefixed tables and its own migrations table (`sx_badge_service_migrations`).
@@ -47,8 +49,9 @@ simplex-badge-service --help
- default (no `--run-cli`): background service mode, no interactive terminal.
- `--run-cli`: interactive CLI that also processes service requests (mirrors
`simplex-directory-service --run-cli`). This mode is the chat/RPC side and the `//` commands
below: it starts no web listener and no poller, and `[dev] chat_redeem` does not apply to it,
whatever `--service-config` says.
below: it starts no web listener and no poller, and serves no group commands, whatever
`--service-config` says. It still updates a code's group message when the code is redeemed or
revoked.
- `--no-address`: skip address creation on start-up (for operators who provision the address themselves).
The service cannot sign credentials without an issuer key and refuses to start without one:
@@ -92,7 +95,9 @@ Other options:
`badge_service.ini` holds the listener bind address and `static_dir`, an optional
`[btcpay]` section (omitting it disables Bitcoin and Monero), an optional `[stripe]`
section (omitting it disables card payments) and the poll cadence.
section (omitting it disables card payments), an optional `[group]` section (omitting it
turns off the group's commands, though a group created earlier still has its code messages updated)
and the poll cadence.
`badge_service.ini.example` is the committed template; `badge_service.ini` itself is
gitignored, since a real one holds API keys and webhook secrets.
@@ -203,27 +208,15 @@ the exception: each answers 200, 400 or 413 with an empty body, because its prov
caller and nothing it could read would change what the route does. A wrong verb on any route, those
two included, answers `method_not_allowed`.
### Redeeming over chat, for local testing
```ini
[dev]
chat_redeem = on
```
With this on, the service accepts contact requests and answers `/redeem <code>` from a contact
with the credential as one-line JSON, ready to paste into a client as `/badge add <json>`. Off by
default, and only `on`/`off` parse, so a typo cannot silently arm it. It applies to the service
mode only; `--run-cli` ignores it.
Keep it off anywhere real. The service RPC signs over a master key only the client holds; here
there is no client key, so the service generates one and hands it over with the credential, which
means it can link every badge it issues this way. `simplex-chat badge sign` has the same property
and is the offline equivalent.
## Issuing codes
Issuing a code is an operator command sent to the running service in `--run-cli` mode, not a way
to start it — so codes are issued without a second process touching the service's database:
Operators issue codes two ways: from the service's own command line in `--run-cli` mode, and from the
managed group in service mode. Both are commands to a running process, so no second process
touches the service's database.
### From the command line
The command is sent to the running service in `--run-cli` mode, not a way to start it:
```
//issue <badge_type> [months] [paid|unpaid|free]
@@ -245,8 +238,76 @@ A code that leaked, or that was refunded, is withdrawn the same way:
```
A revoked code answers redemption with `code_invalid`, as if it had never existed, so its holder
learns nothing from trying. Revoking is not repeatable: the second attempt says so. A code that
was already redeemed cannot be revoked: its badge was issued, and the command answers with an error.
learns nothing from trying. A client that redeemed it before the revoke still gets its own badge
back when it asks again. Revoking it again answers "already revoked" and fixes its group
message if the first revoke didn't. A code with no uses left can't be revoked, because its badges
were already given out, and the command answers with an error. A multi-use code with uses left can
be revoked, which stops the uses that remain.
Core parses `//...` into `CustomChatCommand` and leaves it to the service's `preCmdHook`, which is
why issuing codes lives in the service rather than in core.
### From the group
With `[group]` in `badge_service.ini`, the service manages one group and serves three commands in
it: `/issue <type> [months <M>] [uses <N>]` and `/bulk <type> [months <M>] count <B>` for moderators
and above, `/revoke <code>` for admins and owners. `months` is 1 to 255, `uses` 1 to 1000 and
`count` 1 to 100; a value outside these gets the usage reply. A member's role is checked as the
service last saw it, so a command sent by a moderator just demoted or removed can still run if it
reaches the service first; revoke any code the service posts for them after the change. `uses`
above 1 makes a multi-use code, tracked by a group message showing its remaining uses and the time
of the last one; when every use is redeemed, the same message says so. Every reply carrying a code is read by every member,
since the group has no private lane, so a code issued there is only as private as its least trusted
member.
Those replies are also kept as plain text in the service's chat database, so a copy of the database
holds every code issued in the group. Keep the group's visible history off: with it on, each new
member receives recent messages, and the codes in them, when they join. A multi-use code's message
carries the code, and every redemption edits it or, after a day, posts it again; either way every
current member receives it, so a member who joined after the code was issued gets the code while it
still has uses left. A message replaced by a new post stays in the group with its old count. Keep
disappearing messages off in the group and set no message TTL for the service's chats: a code's
message that expires is treated as deleted and never posted again, so its counter stops.
Every member can see when each use of a multi-use code was redeemed: the message shows the time of
the last one, and its edit times show the rest.
`/revoke <code>` names the code in an ordinary group message, so every member holds it before the
service reads the command, and the code stays redeemable until the service acts on it — for the
whole of any downtime. Revoke a code that is not already public in the group, a refunded one above
all, with `//revoke` in `--run-cli` mode. A `/revoke <code>` with nothing after the code, from a
member below admin, is answered that the code was not revoked and is now visible to the group. A
group command the service received but had not run when it stopped, or received while it ran in
`--run-cli` mode, is dropped with no reply, so resend it, or use `//revoke`.
The first member to join through the link is promoted to owner, so the operator joins before sharing
it. Keep the service an owner too: below owner it cannot update the group's command menu, and below
author it cannot post codes or replies. A failed promotion is logged at once, and an owner who left
is logged at the next start or join. Then make the member you choose owner with the `/mr` command
that the log line names, in `--run-cli` mode; the service never promotes anyone once the first
promotion was attempted.
The join link logged when the group is created stays valid: anyone who has it can join later, as a
member, and read every code posted or edited from then on. That includes a removed member, who can
rejoin through it, so removing a member does not stop them seeing new codes. Keep the log that holds
it private. The link is also stored in the `group_link` column of `sx_badge_service_group`, where it
can be read again.
If an owner deletes the group, or removes the service from it, the service logs an error on start
and stops serving the group. To create a new group, stop the service, delete the row, and start it
again: with SQLite, run `DELETE FROM sx_badge_service_group;` on the `<prefix>_chat.db` file
(`~/.simplex/simplex_badge_service_chat.db` by default), opened with `sqlcipher` and the database key
if one is set; with PostgreSQL, run
`DELETE FROM <schema-prefix>_chat_schema.sx_badge_service_group;` (`simplex_v1_chat_schema` by default).
Multi-use codes issued in the old group stay redeemable, but their messages there are no longer
updated, so revoke with `//revoke` any that should not stay live.
The group is identified by the single `sx_badge_service_group` row. Rolling back past the
`20260918_badge_group_ops` migration drops that table, so a later re-upgrade creates a second group
and orphans the first one with its members and roles; multi-use codes come back single-use with
their claims re-derived, and outstanding trackers come back unanchored. Redeemed credentials are
preserved and no code becomes redeemable again, though while the old version runs, only the holder
whose credential ends last gets it back on a retry, and any other holder of a multi-use code gets
`code_used`; every holder of a revoked code gets `code_invalid`. Rolling back means re-creating and
re-sharing the group; delete the orphaned one with `/d #'<old local name>'` in `--run-cli` mode, as its
join link still works and its messages hold every code posted there.
The configured `display_name` and `description` apply only to the group the service creates. Editing
them later is logged as not applied and changes nothing.
@@ -50,7 +50,13 @@ idle_seconds = 60
;index = 1
;private_key = replace-me
; local testing only: signs a credential for anyone who sends /redeem <code> over chat,
; with a master key this service generates and can therefore link
[dev]
chat_redeem = off
; optional: when present the service manages one group, created on its first start without
; --run-cli, and logs its join link once, on the start that creates it. The link is a bearer
; secret that stays valid, so keep that log private. The first member to join is promoted to
; owner, so join right away.
; moderators can /issue and /bulk codes; admins and owners can also /revoke.
; display_name must be a name the chat core accepts unchanged: one it would spell
; differently stops the whole service, payments included, from starting.
;[group]
;display_name = SimpleX Badges
;description = Welcome to the badges group
@@ -0,0 +1,40 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TupleSections #-}
module BadgeService.Codes
( issueOneCode,
issueFailedText,
revokeBadgeCode,
singleUse,
)
where
import BadgeService.Store (RevokeResult, insertBadgeCode, revokeCode)
import BadgeService.Store.Invoices (truncateToSecond)
import Data.Int (Int64)
import Data.Text (Text)
import Data.Time.Clock (getCurrentTime)
import Simplex.Chat.Badges (BadgeType)
import Simplex.Chat.Badges.Code (BadgeCode, badgeCodeHash, randomBadgeCode)
import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus)
import Simplex.Chat.Bot.Store (withDB')
import Simplex.Chat.Controller (ChatController (..))
singleUse :: Int
singleUse = 1
revokeBadgeCode :: ChatController -> BadgeCode -> IO (Either String RevokeResult)
revokeBadgeCode cc code = do
now <- truncateToSecond <$> getCurrentTime
withDB' "revokeBadgeCode" cc $ \db -> revokeCode db (badgeCodeHash code) now
-- | The group is joined through a bearer link, so this reply names no database error.
issueFailedText :: Text
issueFailedText = "The code could not be issued."
-- | The code table keeps only the hash, so the caller must deliver the code.
issueOneCode :: ChatController -> BadgeType -> Int -> BadgeCodePaymentStatus -> Int -> IO (Either String (BadgeCode, Int64))
issueOneCode cc badgeType months paymentStatus redeemLimit = do
code <- randomBadgeCode $ random cc
now <- truncateToSecond <$> getCurrentTime
fmap (code,) <$> withDB' "issueBadgeCode" cc (\db -> insertBadgeCode db (badgeCodeHash code) badgeType months paymentStatus redeemLimit now)
@@ -11,6 +11,7 @@ module BadgeService.Config
speedPolicyName,
PollConfig (..),
BadgeIssuerKey (..),
GroupConfig (..),
ServiceConfig (..),
defaultExpiryMinutes,
defaultSessionMinutes,
@@ -21,12 +22,15 @@ where
import qualified Control.Exception as E
import BadgeService.Log (logWarn)
import Control.Monad (mfilter)
import Data.Attoparsec.Text (Parser, endOfInput, isEndOfLine, parseOnly, satisfy, skipMany, skipSpace, skipWhile)
import qualified Data.ByteString.Char8 as B
import Data.Ini (Ini, iniGlobals, iniParser, keys, lookupValue, sections)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Simplex.Chat.Library.Commands (mkValidName)
import Simplex.Messaging.Crypto.BBS (BBSSecretKey)
import Simplex.Messaging.Encoding.String (strDecode)
import System.IO.Error (ioeGetErrorString)
@@ -97,14 +101,19 @@ data BadgeIssuerKey = BadgeIssuerKey
instance Show BadgeIssuerKey where
show BadgeIssuerKey {keyIdx} = "issuer key " <> show keyIdx
data GroupConfig = GroupConfig
{ gDisplayName :: Text,
gDescription :: Maybe Text
}
deriving (Eq, Show)
data ServiceConfig = ServiceConfig
{ listener :: ListenerConfig,
btcpay :: Maybe BTCPayConfig,
stripe :: Maybe StripeConfig,
poll :: PollConfig,
issuer :: Maybe BadgeIssuerKey,
-- Local testing only; signs credentials with a master key this service can link.
devChatRedeem :: Bool
group :: Maybe GroupConfig
}
deriving (Eq, Show)
@@ -155,7 +164,7 @@ knownSettings =
("btcpay", ["host", "api_key", "store_id", "webhook_secret", "expiry_minutes", "speed_policy", "payment_tolerance"]),
("stripe", ["secret_key", "publishable_key", "webhook_secret", "session_minutes"]),
("poll", ["waiting_seconds", "idle_seconds"]),
("dev", ["chat_redeem"]),
("group", ["display_name", "description"]),
("issuer", ["index", "private_key"])
]
@@ -179,16 +188,14 @@ parseConfig ini = do
p <- num "listener" "port" 8080
if 1 <= p && p <= 65535 then Right p else Left "listener.port must be between 1 and 65535"
lServeWebapp <- bool "listener" "serve_webapp" True
let lWebappExportDir = case fmap T.strip (look "listener" "webapp_export_dir") of
Just v | not (T.null v) -> Just (T.unpack v)
_ -> Nothing
let lWebappExportDir = T.unpack <$> present "listener" "webapp_export_dir"
lTrustForwardedFor <- bool "listener" "trust_forwarded_for" False
btc <- btcpaySection
str <- stripeSection
iss <- issuerSection
grp <- groupSection
pWaitingSeconds <- cadence "waiting_seconds" 3
pIdleSeconds <- cadence "idle_seconds" 60
devRedeem <- bool "dev" "chat_redeem" False
pure
ServiceConfig
{ listener = ListenerConfig {lHost, lPort, lStaticDir, lServeWebapp, lWebappExportDir, lTrustForwardedFor},
@@ -196,17 +203,14 @@ parseConfig ini = do
stripe = str,
poll = PollConfig {pWaitingSeconds, pIdleSeconds},
issuer = iss,
devChatRedeem = devRedeem
group = grp
}
where
hasSection s = s `elem` sections ini
look s k = either (const Nothing) Just (lookupValue s k ini)
required s k = case look s k of
Just v | not (T.null (T.strip v)) -> Right (T.strip v)
_ -> Left (T.unpack s <> "." <> T.unpack k <> " is required")
optional s k d = case fmap T.strip (look s k) of
Just v | not (T.null v) -> Right v
_ -> Right d
present s k = mfilter (not . T.null) (T.strip <$> look s k)
required s k = maybe (Left (T.unpack s <> "." <> T.unpack k <> " is required")) Right (present s k)
optional s k d = Right (fromMaybe d (present s k))
-- Integer, because readMaybe at Int wraps silently, reading 2^64+4 as 4.
num s k d = case look s k of
Nothing -> Right d
@@ -268,6 +272,20 @@ parseConfig ini = do
Just v -> case readMaybe (T.unpack (T.strip v)) of
Just d | d >= 0 && d <= maxTolerance -> Right d
_ -> Left ("btcpay.payment_tolerance must be a percentage between 0 and " <> show maxTolerance)
groupSection
| not (hasSection "group") = Right Nothing
| otherwise = do
gDisplayName <- required "group" "display_name" >>= validGroupName
pure (Just GroupConfig {gDisplayName, gDescription = present "group" "description"})
-- The core refuses a group name that mkValidName would change, so it is rejected here.
validGroupName n =
let valid = T.pack (mkValidName (T.unpack n))
in if n == valid
then Right n
else Left ("group.display_name \"" <> T.unpack n <> "\" is not a valid group name" <> closest valid)
closest valid
| T.null valid = ""
| otherwise = ", the closest valid name is \"" <> T.unpack valid <> "\""
stripeSection
| not (hasSection "stripe") = Right Nothing
| otherwise = do
@@ -0,0 +1,433 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module BadgeService.Group
( ensureManagedGroup,
runGroupLane,
inertGroupConfig,
GroupEvent (..),
GroupAction (..),
groupEvent,
hasTracker,
refreshTracker,
revokeWithTracker,
coalesceTrackerRefreshes,
TrackerAction (..),
trackerDecision,
codeInTracker,
noOwnerHint,
orphanHint,
)
where
import BadgeService.Codes (issueFailedText, issueOneCode, revokeBadgeCode, singleUse)
import BadgeService.Config (GroupConfig (..))
import BadgeService.Group.Command (CmdAction (..), GroupCmd (..), groupCmdAction, groupCommands)
import BadgeService.Log (logError, logInfo, logWarn)
import BadgeService.Store (CodeTracker (..), ManagedGroup (..), RevokeResult (..), clearCodeGroupItems, getCodeTracker, getEditableTrackers, getManagedGroup, insertManagedGroup, markOwnerBootstrapped, setCodeGroupItem)
import BadgeService.Store.Invoices (truncateToSecond)
import Control.Concurrent.STM (TQueue, atomically, flushTQueue, readTQueue, readTVarIO, writeTQueue)
import Control.Monad (forM_, forever, mfilter, replicateM, unless, void)
import Control.Monad.Except (runExceptT)
import Data.Either (partitionEithers)
import Data.Functor (($>), (<&>))
import Data.Int (Int64)
import Data.List (sortOn)
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe, mapMaybe)
import qualified Data.Set as S
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime, nominalDay)
import Data.Time.Format (defaultTimeLocale, formatTime)
import GHC.Stack (HasCallStack, withFrozenCallStack)
import Simplex.Chat.Badges.Code (BadgeCode, formatBadgeCode, parseBadgeCode)
import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..))
import Simplex.Chat.Bot.Store (withDB')
import Simplex.Chat.Controller
import Simplex.Chat.Core (sendChatCmd)
import Simplex.Chat.Markdown (viewName)
import Simplex.Chat.Messages
import Simplex.Chat.Messages.CIContent (CIContent (..), ciContentToText)
import Simplex.Chat.Protocol (MsgContent (..))
import Simplex.Chat.Store.Messages (getGroupChatItem)
import Simplex.Chat.Store.Shared (StoreError (..))
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences (GroupPreferences (..), commands_, emptyGroupPrefs)
import Simplex.Chat.Types.Shared (GroupMemberRole (..))
import Simplex.Chat.View (simplexChatContact)
import Simplex.Messaging.Agent.Protocol (CreatedConnLink (..), UserId)
import Simplex.Messaging.Encoding.String (StrEncoding, strEncode)
import Simplex.Messaging.Util (catchOwn', safeDecodeUtf8, tshow, ($>>=))
import System.Exit (exitFailure)
buildGroupProfile :: GroupConfig -> GroupProfile
buildGroupProfile GroupConfig {gDisplayName, gDescription} =
GroupProfile
{ displayName = gDisplayName,
fullName = "",
shortDescr = Nothing,
description = gDescription,
image = Nothing,
publicGroup = Nothing,
groupPreferences = Just (emptyGroupPrefs {commands = Just groupCommands} :: GroupPreferences),
memberAdmission = Nothing
}
ensureManagedGroup :: ChatController -> GroupConfig -> IO (Maybe GroupId)
ensureManagedGroup cc gc =
withDB' "getManagedGroup" cc getManagedGroup >>= \case
-- A read error must not fall through to create, which would orphan a second group.
-- The service stops instead, so a restart retries rather than leaving the group unserved.
Left _ -> logError "badge group lookup failed, stopping" >> exitFailure
-- The join link is a bearer secret, logged only where it is created.
Right (Just ManagedGroup {mgGroupId}) ->
sendChatCmd cc (APIGroupInfo mgGroupId) >>= \case
Right CRGroupInfo {groupInfo = GroupInfo {membership, groupProfile = p}}
| memberCurrent membership -> do
forM_ (inertGroupConfig gc p) logWarn
unless (commandsCurrent p) $ advertiseCommands cc mgGroupId p
logInfo "badge group ready"
pure (Just mgGroupId)
| otherwise -> groupGone
Left (ChatErrorStore SEGroupNotFound {}) -> groupGone
r -> do
logError ("badge group info failed: " <> tshow r)
pure (Just mgGroupId)
Right Nothing ->
readTVarIO (currentUser cc) >>= \case
Nothing -> logError "badge group not created: no current user" >> pure Nothing
Just User {userId} -> createManagedGroup cc gc userId
where
groupGone = do
logError "badge group was deleted or the service was removed from it; delete the sx_badge_service_group row and restart to create a new group"
pure Nothing
createManagedGroup :: ChatController -> GroupConfig -> UserId -> IO (Maybe GroupId)
createManagedGroup cc gc userId =
sendChatCmd cc (APINewGroup userId False (buildGroupProfile gc)) >>= \case
Right CRGroupCreated {groupInfo = g@GroupInfo {groupId}} ->
sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case
Right CRGroupLinkCreated {groupLink = GroupLink {connLinkContact}} -> do
now <- truncateToSecond <$> getCurrentTime
let linkText = groupLinkText connLinkContact
withDB' "insertManagedGroup" cc (\db -> insertManagedGroup db groupId linkText now >> clearCodeGroupItems db) >>= \case
Right () -> do
logInfo $ "badge group created, join link: " <> linkText
pure (Just groupId)
Left _ -> logError ("badge group " <> tshow groupId <> " not recorded - " <> orphanHint (groupName' g)) >> pure Nothing
r -> logError ("badge group " <> tshow groupId <> " link failed: " <> tshow r <> " - " <> orphanHint (groupName' g)) >> pure Nothing
r -> logError ("badge group creation failed: " <> tshow r) >> pure Nothing
-- The next start creates another group, so a partly created one is left for the operator to delete.
orphanHint :: GroupName -> Text
orphanHint gName = "delete this orphan group with /d #" <> viewName gName <> " in --run-cli mode"
-- An existing group gets the current commands, so an upgrade that changes them reaches its members.
commandsCurrent :: GroupProfile -> Bool
commandsCurrent groupProfile = (groupPreferences groupProfile >>= commands_) == Just groupCommands
advertiseCommands :: ChatController -> GroupId -> GroupProfile -> IO ()
advertiseCommands cc groupId groupProfile =
sendChatCmd cc (APIUpdateGroupProfile groupId p') >>= \case
Right CRGroupUpdated {} -> logInfo "badge group commands advertised"
r -> logError ("badge group profile update failed: " <> tshow r)
where
prefs = fromMaybe emptyGroupPrefs (groupPreferences groupProfile)
p' = groupProfile {groupPreferences = Just (prefs {commands = Just groupCommands} :: GroupPreferences)}
-- Config is applied only at creation, since a group rename is broadcast to every member.
inertGroupConfig :: GroupConfig -> GroupProfile -> Maybe Text
inertGroupConfig GroupConfig {gDisplayName, gDescription} GroupProfile {displayName, description}
| null diverged = Nothing
| otherwise = Just $ "badge group config is not applied to an existing group: " <> T.intercalate "; " diverged
where
diverged =
[ field <> " \"" <> configured <> "\", group has \"" <> live <> "\""
| (field, configured, live) <-
[ ("display_name", gDisplayName, displayName),
("description", fromMaybe "" gDescription, fromMaybe "" description)
],
configured /= live
]
groupLinkText :: CreatedLinkContact -> Text
groupLinkText (CCLink cReq sLnk_) = maybe (strEncodeTxt (simplexChatContact cReq)) strEncodeTxt sLnk_
strEncodeTxt :: StrEncoding a => a -> Text
strEncodeTxt = safeDecodeUtf8 . strEncode
data GroupEvent
= GEInGroup GroupId GroupAction
| GETracker Int64 BadgeCode
deriving (Eq, Show)
data GroupAction
= GAJoined
| GACommand GroupMemberRole Text
deriving (Eq, Show)
-- Support-scope, moderated or blocked, and live items are ignored: the reply would go to the main group,
-- moderation or blocking without Full Delete keeps the content, and a live item holds only partial text.
groupEvent :: ChatEvent -> Maybe GroupEvent
groupEvent = \case
CEvtJoinedGroupMember {groupInfo = GroupInfo {groupId}} -> Just $ GEInGroup groupId GAJoined
CEvtNewChatItems {chatItems = AChatItem _ _ (GroupChat GroupInfo {groupId} scope) ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent (MCText t), meta = CIMeta {itemDeleted, itemLive}} : _}
| isNothing scope && isNothing itemDeleted && itemLive /= Just True -> Just $ GEInGroup groupId (GACommand (memberRole' m) t)
_ -> Nothing
-- A refresh reads the code's count when it runs, so the last one queued shows every claim before it.
coalesceTrackerRefreshes :: [GroupEvent] -> [GroupEvent]
coalesceTrackerRefreshes = snd . foldr keepLast (S.empty, [])
where
keepLast ev (seen, kept) = case ev of
GETracker badgeCodeId _
| badgeCodeId `S.member` seen -> (seen, kept)
| otherwise -> (S.insert badgeCodeId seen, ev : kept)
_ -> (seen, ev : kept)
logUncaught :: HasCallStack => IO () -> IO ()
logUncaught a = a `catchOwn'` withFrozenCallStack (logError . tshow)
handleGroupEvent :: ChatController -> GroupId -> GroupEvent -> IO ()
handleGroupEvent cc groupId ev = logUncaught (handle ev)
where
handle = \case
GEInGroup gid action
| gid /= groupId -> pure ()
| otherwise -> case action of
GAJoined -> promoteOwner cc groupId
GACommand role t -> case groupCmdAction role t of
RunCmd cmd -> runGroupCmd cc groupId cmd
ReplyText txt -> void $ sendGroupText cc groupId "command reply" txt
IgnoreMsg -> pure ()
GETracker badgeCodeId code -> updateTracker cc groupId badgeCodeId code
promoteFirstOwner :: ChatController -> GroupInfo -> GroupMember -> IO ()
promoteFirstOwner cc g@GroupInfo {groupId} member =
-- The flag is set before promoting, so a retry cannot promote a second owner.
withDB' "markOwnerBootstrapped" cc (`markOwnerBootstrapped` groupId) >>= \case
Right True ->
sendChatCmd cc (APIMembersRole groupId (groupMemberId' member :| []) GROwner) >>= \case
-- The core returns no members when every store update failed.
Right CRMembersRoleUser {members = _ : _} -> logInfo $ "badge group owner promoted: member " <> tshow (groupMemberId' member)
r -> logError $ "badge group owner promotion failed: " <> tshow r <> " - " <> noOwnerHint (groupName' g)
_ -> pure ()
-- Nothing is a group that is not served, so its events are drained.
runGroupLane :: ChatController -> TQueue GroupEvent -> Maybe GroupId -> IO ()
runGroupLane cc q groupId_ = case groupId_ of
Just groupId -> do
promoteOwner cc groupId
-- Reconciling precedes the first batch, so queued events write after the correction.
reconcileTrackers cc groupId
forever $ do
evs <- atomically $ (:) <$> readTQueue q <*> flushTQueue q
mapM_ (handleGroupEvent cc groupId) (coalesceTrackerRefreshes evs)
Nothing -> forever $ void $ atomically (readTQueue q)
-- The earliest joined member is promoted, not the one who just joined, so a join the lane never handled
-- (under --run-cli, or lost in a crash) or a failed attempt never hands the group to a later joiner.
promoteOwner :: ChatController -> GroupId -> IO ()
promoteOwner cc groupId =
withDB' "getManagedGroup" cc getManagedGroup >>= \case
Right (Just ManagedGroup {mgOwnerBootstrapped}) ->
sendChatCmd cc (APIListMembers groupId) >>= \case
Right CRGroupMembers {group = Group {groupInfo, members}}
-- An owner made by hand only needs the flag set, or promoting would add a second owner.
| any (\m -> memberRole' m == GROwner && memberCurrent m) members ->
unless mgOwnerBootstrapped $ void $ withDB' "markOwnerBootstrapped" cc (`markOwnerBootstrapped` groupId)
-- A crash between setting the flag and promoting leaves no owner, and only the operator may retry.
| mgOwnerBootstrapped -> logError $ noOwnerHint (groupName' groupInfo)
| otherwise -> mapM_ (promoteFirstOwner cc groupInfo) $ listToMaybe $ sortOn groupMemberId' $ filter joined members
r -> logError $ "badge group members not listed: " <> tshow r
_ -> pure ()
where
-- A member still joining cannot act yet, so it is not a candidate.
joined m = memberStatus m `elem` [GSMemConnected, GSMemComplete]
-- The live local name, quoted as --run-cli parses it, can differ from the configured one after a name clash or a rename.
noOwnerHint :: GroupName -> Text
noOwnerHint gName = "the badge group has no member owner, make one with /mr #" <> viewName gName <> " <member> owner in --run-cli mode"
runGroupCmd :: ChatController -> GroupId -> GroupCmd -> IO ()
runGroupCmd cc groupId = \case
GCIssue bt months uses ->
issueOneCode cc bt months CPSFree uses >>= \case
Left _ -> reply issueFailedText
Right (code, badgeCodeId)
| not (hasTracker uses) -> reply ("Code: " <> formatBadgeCode code)
| otherwise -> do
now <- truncateToSecond <$> getCurrentTime
sendGroupText cc groupId ("tracker, code " <> tshow badgeCodeId <> " is lost") (initialTrackerBody code uses)
>>= mapM_ (\iid -> withDB' "setCodeGroupItem" cc $ \db -> setCodeGroupItem db badgeCodeId iid now)
GCBulk bt months count -> do
codes <- replicateM count (issueOneCode cc bt months CPSFree singleUse)
let (errs, ok) = partitionEithers codes
issued = map (formatBadgeCode . fst) ok
reply . T.intercalate "\n" $ case errs of
[] -> issued
_ : _ -> issued <> ["Issued " <> tshow (length issued) <> " of " <> tshow count <> " codes. The remaining codes could not be issued."]
-- Naming the code tells concurrent revokes apart; the command already made it public.
GCRevoke code -> do
outcome <- either id id <$> revokeWithTracker cc code
reply (formatBadgeCode code <> ": " <> outcome)
where
reply = void . sendGroupText cc groupId "reply, any codes in it are lost"
-- The label says what is lost when the send fails, since the log line is its only trace.
sendGroupText :: HasCallStack => ChatController -> GroupId -> Text -> Text -> IO (Maybe ChatItemId)
sendGroupText cc groupId label txt =
sendChatCmd cc (APISendMessages (SRGroup groupId Nothing False) False Nothing False (ComposedMessage Nothing Nothing (MCText txt) M.empty :| [])) >>= \case
Right CRNewChatItems {chatItems = ci : _} -> pure (Just (aChatItemId ci))
r -> withFrozenCallStack logError ("badge group message not sent (" <> label <> "): " <> tshow r) $> Nothing
initialTrackerBody :: BadgeCode -> Int -> Text
initialTrackerBody code total = trackerBody code total total Nothing
-- The body is dated by the last redemption, not now, because reconcile rewrites it after a restart.
-- The code is green only while it can still be redeemed.
trackerBody :: BadgeCode -> Int -> Int -> Maybe UTCTime -> Text
trackerBody code remaining total redeemedAt
| remaining > 0 = "!2 " <> formatBadgeCode code <> "!\n" <> tshow remaining <> " of " <> tshow total <> " uses remaining" <> lastUsed
| otherwise = formatBadgeCode code <> "\nAll " <> tshow total <> " uses redeemed" <> lastUsed
where
lastUsed = maybe "" ((", last used " <>) . fmtTime) redeemedAt
revokedBody :: BadgeCode -> Text
revokedBody code = formatBadgeCode code <> "\nRevoked, can no longer be redeemed"
fmtTime :: UTCTime -> Text
fmtTime = T.pack . formatTime defaultTimeLocale "%Y-%m-%d %H:%M UTC"
data TrackerAction = Edit | Repost
deriving (Eq, Show)
-- The core refuses to edit a sent message older than this.
editWindow :: NominalDiffTime
editWindow = nominalDay
trackerDecision :: UTCTime -> UTCTime -> TrackerAction
trackerDecision now sentAt
| diffUTCTime now sentAt < editWindow = Edit
| otherwise = Repost
-- A repost publishes the code a second time, so the reconcile pass may only edit.
data RepostPolicy = MayRepost | EditOnly
-- A deleted or moderated tracker is not reposted, because deleting it does not revoke the code.
setTrackerBody :: ChatController -> GroupId -> Int64 -> RepostPolicy -> (CodeTracker -> Maybe Text) -> IO ()
setTrackerBody cc groupId badgeCodeId policy mkBody =
withDB' "getCodeTracker" cc (`getCodeTracker` badgeCodeId) >>= \case
Right (Just tracker@CodeTracker {trackerItemId, trackerSentAt}) ->
forM_ (mkBody tracker) $ \body -> do
now <- truncateToSecond <$> getCurrentTime
let repost =
trackerItemText cc groupId trackerItemId >>= \case
Nothing -> logWarn $ "badge group tracker not reposted, code " <> tshow badgeCodeId <> " is no longer published"
-- Past the window every write reposts, so an unchanged body is not posted again.
Just current ->
unless (current == body) $
sendGroupText cc groupId ("tracker repost, code " <> tshow badgeCodeId <> " keeps its old message") body
>>= mapM_ (\i -> withDB' "setCodeGroupItem" cc (\db -> setCodeGroupItem db badgeCodeId i now))
-- The core can also refuse an edit inside the window, because it uses the message's own timestamp.
uneditable = case policy of
MayRepost -> repost
EditOnly -> logWarn $ "badge group tracker left uncorrected, code " <> tshow badgeCodeId <> " can no longer be edited"
case trackerDecision now trackerSentAt of
Edit ->
sendChatCmd cc (APIUpdateChatItem (ChatRef CTGroup groupId Nothing) trackerItemId False (UpdatedMessage (MCText body) M.empty)) >>= \case
Right CRChatItemUpdated {} -> pure ()
Right CRChatItemNotChanged {} -> pure ()
Left (ChatError CEInvalidChatItemUpdate) -> uneditable
-- Any other failure may still have applied the edit, so a repost could publish the code twice.
r -> logError $ "badge group tracker not updated, code " <> tshow badgeCodeId <> ": " <> tshow r
Repost -> uneditable
_ -> pure ()
-- A revoke and a redemption can arrive in either order, so a revoked tracker is left alone.
counterBody :: BadgeCode -> CodeTracker -> Maybe Text
counterBody code CodeTracker {redeemCount, redeemLimit, revokedAt, redeemedAt}
| isJust revokedAt = Nothing
| otherwise = Just $ trackerBody code (redeemLimit - redeemCount) redeemLimit redeemedAt
hasTracker :: Int -> Bool
hasTracker redeemLimit = redeemLimit > singleUse
-- The tracker edit reaches every member, so the group lane runs it off the request path.
-- Without a lane (--run-cli, or no [group]) it runs here, before the response.
refreshTracker :: ChatController -> Maybe (TQueue GroupEvent) -> Int64 -> BadgeCode -> IO ()
refreshTracker cc trackerQ_ badgeCodeId code = case trackerQ_ of
Just q -> atomically $ writeTQueue q (GETracker badgeCodeId code)
Nothing -> logUncaught $ withManagedGroup cc $ \groupId -> updateTracker cc groupId badgeCodeId code
updateTracker :: ChatController -> GroupId -> Int64 -> BadgeCode -> IO ()
updateTracker cc groupId badgeCodeId code = setTrackerBody cc groupId badgeCodeId MayRepost (counterBody code)
-- | Left is a refusal or a failure; both sides are the text to show whoever sent the revoke.
revokeWithTracker :: ChatController -> BadgeCode -> IO (Either Text Text)
revokeWithTracker cc code =
revokeBadgeCode cc code >>= \case
-- The message is retired before the answer, so the answer never sits beside a live counter.
Right (Revoked badgeCodeId) -> Right "Revoked." <$ retire badgeCodeId
-- A repeated revoke repairs a message that an earlier revoke failed to update.
Right (AlreadyRevoked badgeCodeId) -> Right "Already revoked." <$ retire badgeCodeId
Right AlreadyRedeemed -> pure $ Left "Fully redeemed. It cannot be revoked."
Right NoSuchCode -> pure $ Left "No such code."
Left _ -> pure $ Left "The code could not be revoked."
where
retire badgeCodeId = logUncaught $ withManagedGroup cc $ \groupId ->
setTrackerBody cc groupId badgeCodeId MayRepost (const $ Just $ revokedBody code)
withManagedGroup :: ChatController -> (GroupId -> IO ()) -> IO ()
withManagedGroup cc action =
withDB' "getManagedGroup" cc getManagedGroup >>= \case
Right (Just ManagedGroup {mgGroupId}) -> action mgGroupId
_ -> pure ()
-- A claim's refresh is queued after the claim commits, so a crash can drop it.
reconcileTrackers :: ChatController -> GroupId -> IO ()
reconcileTrackers cc groupId = do
editableAfter <- addUTCTime (-editWindow) <$> getCurrentTime
withDB' "getEditableTrackers" cc (`getEditableTrackers` editableAfter) >>= \case
Left _ -> logError "badge group trackers not reconciled: tracked code lookup failed"
Right codes -> forM_ codes $ \(badgeCodeId, itemId) -> logUncaught $ reconcileTracker cc groupId badgeCodeId itemId
-- An unchanged edit still walks every member, so a tracker already showing the right text is skipped.
reconcileTracker :: ChatController -> GroupId -> Int64 -> ChatItemId -> IO ()
reconcileTracker cc groupId badgeCodeId itemId =
readTrackerCode cc groupId badgeCodeId itemId >>= mapM_ reconcile
where
reconcile (code, current) = setTrackerBody cc groupId badgeCodeId EditOnly (correctedBody code current)
-- A crash after a revoke committed can leave its tracker still counting, so a revoked code is retired here.
correctedBody code current tracker@CodeTracker {revokedAt} =
mfilter (/= current) $ if isJust revokedAt then Just (revokedBody code) else counterBody code tracker
readTrackerCode :: ChatController -> GroupId -> Int64 -> ChatItemId -> IO (Maybe (BadgeCode, Text))
readTrackerCode cc groupId badgeCodeId itemId =
trackerItemText cc groupId itemId >>= \case
Nothing -> do
logWarn $ "badge group tracker not read, code " <> tshow badgeCodeId
pure Nothing
Just current -> case codeInTracker current of
Nothing -> do
logError $ "badge group tracker carries no readable code, code " <> tshow badgeCodeId
pure Nothing
Just code -> pure (Just (code, current))
codeInTracker :: Text -> Maybe BadgeCode
codeInTracker = listToMaybe . mapMaybe parseBadgeCode . T.words
-- The item is read from the store, because the core's item info also loads every edit and every member's delivery status.
-- Moderation can keep a deleted item's content, so itemDeleted is checked.
trackerItemText :: ChatController -> GroupId -> ChatItemId -> IO (Maybe Text)
trackerItemText cc groupId itemId =
readTVarIO (currentUser cc) $>>= \user ->
withDB' "getGroupChatItem" cc (\db -> runExceptT $ getGroupChatItem db user groupId itemId) <&> \case
Right (Right (CChatItem _ ChatItem {content, meta = CIMeta {itemDeleted}})) | isNothing itemDeleted -> Just (ciContentToText content)
_ -> Nothing
@@ -0,0 +1,142 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TupleSections #-}
module BadgeService.Group.Command
( GroupCmd (..),
CmdAction (..),
groupCmdAction,
groupCommands,
codeP,
badgeTypeP,
textTokenP,
maxMonths,
maxUses,
)
where
import Control.Applicative (optional, (<|>))
import Control.Monad (void)
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Char (isSpace)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Simplex.Chat.Badges (BadgeType (..))
import Simplex.Chat.Badges.Code (BadgeCode, parseBadgeCode)
import Simplex.Chat.Types.Preferences (ChatBotCommand (..))
import Simplex.Chat.Types.Shared (GroupMemberRole (..))
import Simplex.Messaging.Encoding.String (TextEncoding (..))
import Simplex.Messaging.Util (safeDecodeUtf8)
maxBulk, maxUses, maxMonths :: Int
maxBulk = 100
maxUses = 1000
maxMonths = 255
data GroupCmd
= GCIssue BadgeType Int Int
| GCBulk BadgeType Int Int
| GCRevoke BadgeCode
deriving (Eq, Show)
data CmdAction
= RunCmd GroupCmd
| ReplyText Text
| IgnoreMsg
deriving (Eq, Show)
groupCmdAction :: GroupMemberRole -> Text -> CmdAction
groupCmdAction role t = case A.parseOnly cmdActionP (encodeUtf8 (T.strip t)) of
Right (tag, r)
| role >= cmdMinRole tag -> either ReplyText RunCmd r
| Right _ <- r, Just refusal <- cmdRefusal tag -> ReplyText refusal
_ -> IgnoreMsg
-- | Left is the usage reply for an advertised command whose arguments do not parse.
cmdActionP :: A.Parser (CmdTag, Either Text GroupCmd)
cmdActionP = A.choice (map cmdP [minBound .. maxBound])
where
cmdP tag = (tag,) <$> (A.string ("/" <> encodeUtf8 (cmdName tag)) *> (Right <$> fullArgsP tag <|> Left (usage tag) <$ usageEndP))
fullArgsP tag = A.char ' ' *> cmdArgsP tag <* A.endOfInput
-- Without the space check "/issued" would get a usage reply.
usageEndP = void A.space <|> A.endOfInput
usage tag = "Usage: /" <> cmdName tag <> " " <> cmdParams tag
cmdArgsP :: CmdTag -> A.Parser GroupCmd
cmdArgsP = \case
CTIssue -> GCIssue <$> badgeTypeP <*> monthsOpt <*> keyOpt "uses" maxUses
CTBulk -> GCBulk <$> badgeTypeP <*> monthsOpt <*> (A.space *> keyValue "count" maxBulk)
CTRevoke -> GCRevoke <$> codeP
where
monthsOpt = keyOpt "months" maxMonths
keyOpt kw hi = fromMaybe 1 <$> optional (A.space *> keyValue kw hi)
keyValue kw hi = A.string kw *> A.space *> boundedInt kw hi
groupCommands :: [ChatBotCommand]
groupCommands = map command [minBound .. maxBound]
where
command tag = CBCCommand (cmdName tag) (cmdLabel tag) (Just (cmdParams tag))
codeP :: A.Parser BadgeCode
codeP = A.takeWhile1 (not . isSpace) >>= maybe (fail "not a badge code") pure . parseBadgeCode . safeDecodeUtf8
-- attoparsec's decimal wraps silently at Int, so the bound is checked on the wider Integer.
boundedInt :: ByteString -> Int -> A.Parser Int
boundedInt kw hi = do
n <- A.decimal :: A.Parser Integer
if n >= 1 && n <= fromIntegral hi
then pure (fromInteger n)
else fail (B.unpack kw <> " out of range")
-- BadgeType decodes anything to BTUnknown, so a typo would issue an unusable code.
badgeTypeP :: A.Parser BadgeType
badgeTypeP =
textTokenP >>= \case
BTUnknown t -> fail $ "unknown badge type " <> T.unpack t
bt -> pure bt
textTokenP :: TextEncoding a => A.Parser a
textTokenP = do
t <- A.takeWhile1 (not . isSpace)
maybe (fail "invalid value") pure $ textDecode $ safeDecodeUtf8 t
-- The advertised menu lists the commands in this order.
data CmdTag = CTIssue | CTBulk | CTRevoke
deriving (Bounded, Enum)
cmdName :: CmdTag -> Text
cmdName = \case
CTIssue -> "issue"
CTBulk -> "bulk"
CTRevoke -> "revoke"
cmdLabel :: CmdTag -> Text
cmdLabel = \case
CTIssue -> "Generate a badge code"
CTBulk -> "Generate many single-use codes"
CTRevoke -> "Revoke a code"
-- | The parameters are quoted in the usage reply and advertised to the group, so the two cannot drift apart.
cmdParams :: CmdTag -> Text
cmdParams = \case
CTIssue -> "<type> [months <M>] [uses <N>]"
CTBulk -> "<type> [months <M>] count <B>"
CTRevoke -> "<code>"
cmdMinRole :: CmdTag -> GroupMemberRole
cmdMinRole = \case
CTIssue -> GRModerator
CTBulk -> GRModerator
CTRevoke -> GRAdmin
-- | This is the reply to a well-formed command from a sender who may not run it; Nothing means silence.
cmdRefusal :: CmdTag -> Maybe Text
cmdRefusal = \case
CTIssue -> Nothing
CTBulk -> Nothing
-- The sender has just published a code that stays redeemable, so they must learn it was not revoked.
CTRevoke -> Just "Only admins can revoke codes. This code is now visible to all members."
@@ -1,6 +1,4 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
@@ -21,7 +19,10 @@ module BadgeService.Service
where
import BadgeService.Catalog (defaultCatalog)
import BadgeService.Codes (issueFailedText, issueOneCode, singleUse)
import BadgeService.Config (BadgeIssuerKey (..), ServiceConfig (..), readServiceConfig)
import BadgeService.Group (GroupEvent, ensureManagedGroup, groupEvent, hasTracker, refreshTracker, revokeWithTracker, runGroupLane)
import BadgeService.Group.Command (badgeTypeP, codeP, maxMonths, textTokenP)
import BadgeService.Options
import BadgeService.Poller (newPollerEnv, newReadHints, runPoller)
import BadgeService.Providers.BTCPay (btcpayProvider)
@@ -33,19 +34,17 @@ import BadgeService.Waiters (Waiters, newWaiters)
import BadgeService.Web.Server (exportWebapp, newWebEnv, runWebListener)
import Control.Applicative (optional)
import Control.Concurrent.STM
import BadgeService.Log (logError, logInfo, logWarn)
import BadgeService.Log (logError, logInfo)
import Control.Monad
import Control.Monad.IO.Class (liftIO)
import qualified Data.Aeson as J
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Char (isSpace)
import Data.Either (fromRight)
import Data.Functor (($>))
import Data.Functor (($>), (<&>))
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, maybeToList)
import Data.Maybe (fromMaybe, isJust, maybeToList)
import qualified Data.Text as T
import Data.Time.Clock (UTCTime, getCurrentTime)
import Data.Word (Word32)
@@ -54,20 +53,18 @@ import Simplex.Chat.Badges.Code
import Simplex.Chat.Badges.Ledger
import Simplex.Chat.Badges.Service
import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..))
import Simplex.Chat.Bot (initializeBotAddress', sendMessage)
import Simplex.Chat.Bot (initializeBotAddress')
import Simplex.Chat.Bot.Store (withDB, withDB')
import Simplex.Chat.Controller
import Simplex.Chat.Core (sendChatCmd, simplexChatCore)
import Simplex.Chat.Messages
import Simplex.Chat.Messages.CIContent (CIContent (..), SMsgDirection (..), ciContentToText)
import Simplex.Chat.Options (printDbOpts)
import Simplex.Chat.Terminal (terminalChatConfig)
import Simplex.Chat.Terminal.Main (simplexChatCLI')
import Simplex.Chat.Types (AgentInvId (..), Contact, User (..))
import Simplex.Chat.Types (AgentInvId (..), User (..))
import Simplex.Messaging.Agent.Store.Common (DBStore)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.BBS (bbsPublicKey)
import Simplex.Messaging.Encoding.String (TextEncoding, strEncode, textDecode, textEncode)
import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Util (raceAny_, safeDecodeUtf8, tshow)
import Simplex.Messaging.Version (isCompatible)
import System.Directory (getAppUserDataDirectory)
@@ -76,15 +73,15 @@ import System.Exit (exitFailure)
data ServiceState = ServiceState
{ serviceCC :: TMVar ChatController,
serviceRequestQ :: TQueue (User, AgentInvId, Maybe C.PublicKeyEd25519, J.Object),
chatRedeemQ :: TQueue (Contact, T.Text)
groupEventQ :: TQueue GroupEvent
}
newServiceState :: IO ServiceState
newServiceState = do
serviceCC <- newEmptyTMVarIO
serviceRequestQ <- newTQueueIO
chatRedeemQ <- newTQueueIO
pure ServiceState {serviceCC, serviceRequestQ, chatRedeemQ}
groupEventQ <- newTQueueIO
pure ServiceState {serviceCC, serviceRequestQ, groupEventQ}
welcomeGetOpts :: IO BadgeServiceOpts
welcomeGetOpts = do
@@ -128,29 +125,29 @@ badgeService opts@BadgeServiceOpts {serviceConfigFile} cfg env = do
serviceCfg <- traverse readConfigOrExit serviceConfigFile
key <- requireIssuerKey opts serviceCfg cfg
waiters <- newWaiters
let devRedeem = maybe False devChatRedeem serviceCfg
chatHooks =
defaultChatHooks
{ preStartHook = Just $ badgePreStartHook opts,
postStartHook = Just $ badgePostStartHook opts devRedeem env,
preCmdHook = Just badgeCmdHook
}
when devRedeem $ logWarn "[dev] chat_redeem is on: /redeem over chat hands out credentials this service can link"
-- The reader must not block, since outputQ carries every chat event.
simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \_ cc -> do
lanes <- maybe (pure []) (serviceLanes waiters cc) serviceCfg
raceAny_ $
[ forever $
let groupCfg = serviceCfg >>= \ServiceConfig {group} -> group
trackerQ_ = groupEventQ env <$ groupCfg
readEvents cc =
forever $
atomically (readTBQueue $ outputQ cc) >>= \case
(_, Right (CEvtServiceRequest u reqId sigKey reqData)) ->
atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData)
(_, Right CEvtNewChatItems {chatItems = AChatItem _ SMDRcv (DirectChat ct) ChatItem {content = mc@CIRcvMsgContent {}} : _})
| devRedeem -> atomically $ writeTQueue (chatRedeemQ env) (ct, ciContentToText mc)
_ -> pure (),
processQueuedRequests key env
]
<> [processChatRedeems key env | devRedeem]
<> lanes
(_, Right ev)
| isJust groupCfg -> forM_ (groupEvent ev) $ atomically . writeTQueue (groupEventQ env)
_ -> pure ()
startLanes cc = do
lanes <- maybe (pure []) (serviceLanes waiters cc) serviceCfg
-- The group is resolved before the other lanes start, so a failed lookup exits at once rather than waiting for them to end.
groupLane_ <- forM groupCfg $ \gc -> runGroupLane cc (groupEventQ env) <$> ensureManagedGroup cc gc
raceAny_ $ processQueuedRequests key trackerQ_ env : maybeToList groupLane_ <> lanes
chatHooks =
defaultChatHooks
{ preStartHook = Just $ badgePreStartHook opts,
postStartHook = Just $ badgePostStartHook opts env,
preCmdHook = Just badgeCmdHook
}
-- The reader runs from the start and must not block, since outputQ carries every chat event and a full queue stalls the core.
simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \_ cc -> raceAny_ [readEvents cc, startLanes cc]
where
serviceLanes :: Waiters -> ChatController -> ServiceConfig -> IO [IO ()]
serviceLanes ws ChatController {chatStore} sc = do
@@ -188,13 +185,13 @@ badgeServiceCLI opts@BadgeServiceOpts {serviceConfigFile} = do
chatHooks =
defaultChatHooks
{ preStartHook = Just $ badgePreStartHook opts,
postStartHook = Just $ badgePostStartHook opts False env,
postStartHook = Just $ badgePostStartHook opts env,
preCmdHook = Just badgeCmdHook,
eventHook = Just eventHook
}
raceAny_
[ simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing,
processQueuedRequests key env
processQueuedRequests key Nothing env
]
badgeCmdHook :: ChatController -> ChatCommand -> IO (Either (Either ChatError ChatResponse) ChatCommand)
@@ -206,26 +203,16 @@ runBadgeCmd :: ChatController -> ByteString -> IO (Either ChatError ChatResponse
runBadgeCmd cc cmd
| Right issueOpts <- A.parseOnly issueCmdP cmd =
issueBadgeCode cc issueOpts >>= \case
Right code -> pure $ Right CRCustomChatResponse {user_ = Nothing, response = "code " <> formatBadgeCode code}
Left e -> pure $ chatCmdError $ "issuing code: " <> e
Right code -> pure $ Right CRCustomChatResponse {user_ = Nothing, response = "Code: " <> formatBadgeCode code}
Left _ -> pure $ chatCmdError (T.unpack issueFailedText)
| Right code <- A.parseOnly revokeCmdP cmd =
revokeBadgeCode cc code >>= \case
Right Revoked -> pure $ Right CRCustomChatResponse {user_ = Nothing, response = "revoked"}
Right AlreadyRevoked -> pure $ chatCmdError "code was revoked already"
Right AlreadyRedeemed -> pure $ chatCmdError "code was redeemed already, so it cannot be revoked"
Right NoSuchCode -> pure $ chatCmdError "no such code"
Left e -> pure $ chatCmdError $ "revoking code: " <> e
| otherwise = pure $ chatCmdError "use: //issue supporter|legend|investor [months 1-255] [paid|unpaid|free], or //revoke <code>"
revokeWithTracker cc code <&> \case
Right response -> Right CRCustomChatResponse {user_ = Nothing, response}
Left e -> chatCmdError (T.unpack e)
| otherwise = pure $ chatCmdError $ "Usage: //issue supporter|legend|investor [months 1-" <> show maxMonths <> "] [paid|unpaid|free], or //revoke <code>"
revokeCmdP :: A.Parser BadgeCode
revokeCmdP =
"revoke " *> (A.takeWhile1 (not . isSpace) >>= maybe (fail "not a badge code") pure . parseBadgeCode . safeDecodeUtf8)
<* (A.skipSpace *> A.endOfInput)
revokeBadgeCode :: ChatController -> BadgeCode -> IO (Either String RevokeResult)
revokeBadgeCode cc code = do
now <- truncateToSecond <$> getCurrentTime
withDB' "revokeBadgeCode" cc $ \db -> revokeCode db (badgeCodeHash code) now
revokeCmdP = "revoke " *> codeP <* (A.skipSpace *> A.endOfInput)
issueCmdP :: A.Parser IssueCodeOpts
issueCmdP =
@@ -241,17 +228,8 @@ issueCmdP =
where
-- Integer, because attoparsec's decimal wraps silently at Int, so the guard would check a truncated count.
checkMonths n
| n >= 1 && n <= 255 = pure (fromInteger n)
| otherwise = fail "months must be between 1 and 255"
-- BadgeType decodes anything to BTUnknown, so a typo would issue an unusable code
badgeTypeP =
textTokenP >>= \case
BTUnknown t -> fail $ "unknown badge type " <> T.unpack t
bt -> pure bt
textTokenP :: TextEncoding a => A.Parser a
textTokenP = do
t <- A.takeWhile1 (not . isSpace)
maybe (fail "invalid value") pure $ textDecode $ safeDecodeUtf8 t
| n >= 1 && n <= fromIntegral maxMonths = pure (fromInteger n)
| otherwise = fail $ "months must be between 1 and " <> show maxMonths
data IssueCodeOpts = IssueCodeOpts
{ badgeType :: BadgeType,
@@ -259,60 +237,37 @@ data IssueCodeOpts = IssueCodeOpts
paymentStatus :: BadgeCodePaymentStatus
}
-- | The caller sees the code once; only its hash is stored, so a lost code cannot be recovered.
issueBadgeCode :: ChatController -> IssueCodeOpts -> IO (Either String BadgeCode)
issueBadgeCode cc IssueCodeOpts {badgeType, months, paymentStatus} = do
code <- randomBadgeCode $ random cc
now <- getCurrentTime
r <- withDB' "issueBadgeCode" cc $ \db -> insertBadgeCode db (badgeCodeHash code) badgeType months paymentStatus now
pure $ code <$ r
issueBadgeCode cc IssueCodeOpts {badgeType, months, paymentStatus} =
fmap fst <$> issueOneCode cc badgeType months paymentStatus singleUse
processQueuedRequests :: BadgeIssuerKey -> ServiceState -> IO ()
processQueuedRequests key env = do
processQueuedRequests :: BadgeIssuerKey -> Maybe (TQueue GroupEvent) -> ServiceState -> IO ()
processQueuedRequests key trackerQ_ env = do
cc <- atomically $ readTMVar $ serviceCC env
forever $ do
(u, reqId, sigKey, reqData) <- atomically $ readTQueue $ serviceRequestQ env
handleServiceRequest key cc u reqId sigKey reqData
processChatRedeems :: BadgeIssuerKey -> ServiceState -> IO ()
processChatRedeems key env = do
cc <- atomically $ readTMVar $ serviceCC env
forever $ do
(ct, msg) <- atomically $ readTQueue $ chatRedeemQ env
chatRedeem key cc ct msg
-- | Here the service generates the master key and can link the badge, so [dev] chat_redeem gates this.
chatRedeem :: BadgeIssuerKey -> ChatController -> Contact -> T.Text -> IO ()
chatRedeem key cc ct msg = case T.stripPrefix "/redeem" (T.strip msg) of
Just rest | not (T.null (T.strip rest)) -> do
masterKey <- generateMasterKey (random cc)
(purchaseKey, _) <- atomically $ C.generateKeyPair (random cc) :: IO (C.KeyPair 'C.Ed25519)
resp <- redeemCode key cc purchaseKey masterKey (T.strip rest)
sendMessage cc ct $ case resp of
BSPBadgeCredential {credential = Just cred} -> safeDecodeUtf8 $ LB.toStrict $ J.encode cred
BSPError {code} -> "error: " <> textEncode code
_ -> "unexpected response"
_ -> sendMessage cc ct "send: /redeem <code>"
handleServiceRequest key cc trackerQ_ u reqId sigKey reqData
badgePreStartHook :: BadgeServiceOpts -> ChatController -> IO ()
badgePreStartHook opts ChatController {config, chatStore} =
runBadgeServiceMigrations opts config chatStore
badgePostStartHook :: BadgeServiceOpts -> Bool -> ServiceState -> ChatController -> IO ()
badgePostStartHook BadgeServiceOpts {noAddress, testing} devRedeem env cc = do
badgePostStartHook :: BadgeServiceOpts -> ServiceState -> ChatController -> IO ()
badgePostStartHook BadgeServiceOpts {noAddress, testing} env cc = do
-- Core starts this False and gates service request delivery on it, so the hook must set it.
atomically $ writeTVar (processServiceRequests cc) True
readTVarIO (currentUser cc) >>= \case
Nothing -> putStrLn "No current user" >> exitFailure
Just _ -> do
unless noAddress $ initializeBotAddress' (not testing) (Just True) devRedeem cc
-- The address carries service RPC only, so contact requests are never auto-accepted.
unless noAddress $ initializeBotAddress' (not testing) (Just True) False cc
void $ atomically $ tryPutTMVar (serviceCC env) cc
handleServiceRequest :: BadgeIssuerKey -> ChatController -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO ()
handleServiceRequest key cc User {userId} reqId sigKey reqData = do
handleServiceRequest :: BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO ()
handleServiceRequest key cc trackerQ_ User {userId} reqId sigKey reqData = do
let reqIdT = safeDecodeUtf8 (strEncode reqId)
logInfo $ "badge service request " <> reqIdT
resp <- badgeServiceResponse key cc sigKey reqData
resp <- badgeServiceResponse key cc trackerQ_ sigKey reqData
sendChatCmd cc (APISendServiceResponse userId reqId (responseObject resp)) >>= \case
Right _ -> pure ()
Left e -> logError $ "badge service response failed for " <> reqIdT <> ": " <> tshow e
@@ -334,15 +289,15 @@ badgeErrorRetryAfter = \case
-- | The agent verified the signature, so sigKey is a key the sender holds; a differing purchaseKey would let a client claim a purchase it cannot sign for.
badgeServiceResponse :: BadgeIssuerKey -> ChatController -> Maybe C.PublicKeyEd25519 -> J.Object -> IO BadgeServiceResponse
badgeServiceResponse key cc sigKey reqData = case J.fromJSON (J.Object reqData) of
badgeServiceResponse :: BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> Maybe C.PublicKeyEd25519 -> J.Object -> IO BadgeServiceResponse
badgeServiceResponse key cc trackerQ_ sigKey reqData = case J.fromJSON (J.Object reqData) of
J.Error _ -> pure $ errorResponse BSEBadRequest
J.Success BadgeServiceRequest {version, purchaseKey, request}
| not (version `isCompatible` supportedBadgeServiceVRange) -> pure $ errorResponse BSEUnsupportedVersion
| purchaseKey /= sigKey -> pure $ errorResponse BSEBadRequest
| otherwise -> case request of
BSCRedeemBadgeCode {masterKey, code} -> case purchaseKey of
Just k -> redeemCode key cc k masterKey code
Just k -> redeemCode key cc trackerQ_ k masterKey code
Nothing -> pure $ errorResponse BSEBadRequest
BSCIssueBadge {balance} -> case purchaseKey of
Just k -> issueBadgeCmd key cc k balance
@@ -376,15 +331,15 @@ credentialResponse credential previousEntryId entries =
BSPBadgeCredential {credential, receipt = Nothing, statement = BadgeStatement {entries, previousEntryId}}
-- | Nothing is written until the credential is signed, so a signing failure leaves the code unspent.
redeemCode :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeMasterKey -> T.Text -> IO BadgeServiceResponse
redeemCode key cc purchaseKey masterKey codeText = case parseBadgeCode codeText of
redeemCode :: BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> C.PublicKeyEd25519 -> BadgeMasterKey -> T.Text -> IO BadgeServiceResponse
redeemCode key cc trackerQ_ purchaseKey masterKey codeText = case parseBadgeCode codeText of
Nothing -> pure $ errorResponse BSECodeInvalid
Just code -> do
now <- badgeNow cc
withDB "getBadgeCode" cc (readCode now code) >>= \case
Left _ -> pure $ errorResponse BSEInternal
Right (Left resp) -> pure resp
Right (Right IssuedCode {badgeCodeId, badgeType, months}) -> do
Right (Right IssuedCode {badgeCodeId, badgeType, months, redeemLimit}) -> do
(grantUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc
-- TODO [badges] a top-up grants onto an existing ledger, and must lapse before it or the
-- months it adds are counted from a start already in the past
@@ -395,41 +350,42 @@ redeemCode key cc purchaseKey masterKey codeText = case parseBadgeCode codeText
Just issued -> credentialForEntry key masterKey issued >>= \case
Left e -> logError ("badge service signing failed: " <> T.pack e) $> errorResponse BSEInternal
Right signed -> do
-- If the code was revoked or redeemed while signing, the claim fails. Read the code again to tell the client why.
-- If the code was revoked or used up while signing, the claim fails. Read the code again to tell the client why.
r <- withDB "writeCodeRedemption" cc $ \db ->
liftIO (createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType} now) >>= \case
Nothing ->
readCode now code db >>= \case
Left resp -> pure resp
Right _ -> logError "badge service: redeeming a code failed, but the code is neither redeemed nor revoked" $> errorResponse BSEInternal
Left resp -> pure (resp, False)
Right _ -> logError "badge service: redeeming a code failed, but the code has uses left and is not revoked" $> (errorResponse BSEInternal, False)
Just purchaseId -> liftIO $ do
appendLedgerPlan db purchaseId [granted] $ Just $ issuanceAfter granted signed
entries_ <- getLedgerEntries db purchaseId 0
pure $ maybe (errorResponse BSEInternal) (credentialResponse (Just $ snd signed) Nothing) entries_
pure $ fromRight (errorResponse BSEInternal) r
pure (maybe (errorResponse BSEInternal) (credentialResponse (Just $ snd signed) Nothing) entries_, True)
let (resp, claimed) = fromRight (errorResponse BSEInternal, False) r
when (claimed && hasTracker redeemLimit) $ refreshTracker cc trackerQ_ badgeCodeId code
pure resp
where
readCode now code db = liftIO $
getBadgeCode db (badgeCodeHash code) >>= \case
Nothing -> pure $ Left $ errorResponse BSECodeInvalid
Just c@IssuedCode {revokedAt, paymentStatus, expiresAt, redemption}
-- Revoked is checked first, so it answers as if the code never existed.
| Just _ <- revokedAt -> pure $ Left $ errorResponse BSECodeInvalid
-- Redeeming an unpaid code would issue a free badge, so unpaid is refused.
| CPSUnpaid <- paymentStatus -> pure $ Left $ errorResponse BSEPaymentPending
| otherwise ->
checkUnspent db redemption >>= \case
Left resp -> pure $ Left resp
Right ()
| maybe False (now >=) expiresAt -> pure $ Left $ errorResponse BSECodeExpired
| otherwise -> pure $ Right c
checkUnspent db = \case
CodeUnredeemed -> pure $ Right ()
CodeRedeemedUnreadable -> pure $ Left $ errorResponse BSEInternal
CodeRedeemed RedeemedCode {purchaseKey = k, badgePurchaseId, credential}
| k /= purchaseKey -> pure $ Left $ errorResponse BSECodeUsed
| otherwise ->
maybe (Left $ errorResponse BSEInternal) (Left . credentialResponse (Just credential) Nothing)
<$> getLedgerEntries db badgePurchaseId 0
Just c@IssuedCode {badgeCodeId, revokedAt, paymentStatus, expiresAt, redeemLimit, redeemCount} ->
getCodePurchaseForKey db badgeCodeId purchaseKey >>= \case
-- A key that already redeemed gets its credential back without a use, even if the code has since
-- expired or been revoked: a client whose reply was lost retries, and would otherwise lose the badge.
KeyRedeemed KeyPurchase {badgePurchaseId, credential} ->
maybe (Left $ errorResponse BSEInternal) (Left . credentialResponse (Just credential) Nothing)
<$> getLedgerEntries db badgePurchaseId 0
-- code_used would make the client drop its keys, so the holder could never get the badge back.
KeyRedeemedUnreadable ->
logError "badge service: a redeemed code's credential is missing or unreadable" $> Left (errorResponse BSEInternal)
KeyUnredeemed
-- Revoked is checked first, so it answers as if the code never existed.
| Just _ <- revokedAt -> pure $ Left $ errorResponse BSECodeInvalid
-- Redeeming an unpaid code would issue a free badge, so unpaid is refused.
| CPSUnpaid <- paymentStatus -> pure $ Left $ errorResponse BSEPaymentPending
| redeemCount >= redeemLimit -> pure $ Left $ errorResponse BSECodeUsed
| maybe False (now >=) expiresAt -> pure $ Left $ errorResponse BSECodeExpired
| otherwise -> pure $ Right c
-- | The purchase is reached through the verified signer key and no other way.
issueBadgeCmd :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeBalance -> IO BadgeServiceResponse
@@ -7,11 +7,17 @@
module BadgeService.Store
( IssuedCode (..),
CodeRedemption (..),
RedeemedCode (..),
KeyRedemption (..),
KeyPurchase (..),
NewCodePurchase (..),
ServicePurchase (..),
ManagedGroup (..),
getManagedGroup,
insertManagedGroup,
clearCodeGroupItems,
markOwnerBootstrapped,
getBadgeCode,
getCodePurchaseForKey,
purchaseKeyExists,
getPurchaseByKey,
getLedgerTip,
@@ -21,6 +27,10 @@ module BadgeService.Store
appendLedgerPlan,
createCodePurchase,
insertBadgeCode,
setCodeGroupItem,
CodeTracker (..),
getCodeTracker,
getEditableTrackers,
RevokeResult (..),
revokeCode,
)
@@ -31,6 +41,7 @@ import qualified Data.Aeson as J
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Int (Int64)
import Data.Maybe (isJust)
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Simplex.Chat.Badges (BadgeCredential, BadgeMasterKey (..), BadgeType)
@@ -38,7 +49,7 @@ import Simplex.Chat.Badges.Ledger
import Simplex.Chat.Badges.Service (StatementEntry (..))
import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus, BadgePurchaseStatus (..))
import Simplex.Chat.Store.Shared (insertedRowId)
import Simplex.Messaging.Agent.Store.DB (Binary (..))
import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..))
import qualified Simplex.Messaging.Agent.Store.DB as DB
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Util (maybeFirstRow, maybeFirstRow')
@@ -58,17 +69,17 @@ data IssuedCode = IssuedCode
paymentStatus :: BadgeCodePaymentStatus,
revokedAt :: Maybe UTCTime,
expiresAt :: Maybe UTCTime,
redemption :: CodeRedemption
redeemLimit :: Int,
redeemCount :: Int
}
data CodeRedemption
= CodeUnredeemed
| CodeRedeemed RedeemedCode
| CodeRedeemedUnreadable
data KeyRedemption
= KeyUnredeemed
| KeyRedeemed KeyPurchase
| KeyRedeemedUnreadable
data RedeemedCode = RedeemedCode
data KeyPurchase = KeyPurchase
{ badgePurchaseId :: Int64,
purchaseKey :: C.PublicKeyEd25519,
credential :: BadgeCredential
}
@@ -85,30 +96,81 @@ data ServicePurchase = ServicePurchase
badgeType :: BadgeType
}
data ManagedGroup = ManagedGroup
{ mgGroupId :: Int64,
mgGroupLink :: Text,
mgOwnerBootstrapped :: Bool
}
deriving (Eq)
-- The join link is a bearer secret, so it is left out.
instance Show ManagedGroup where
show ManagedGroup {mgGroupId, mgOwnerBootstrapped} = "managed group " <> show mgGroupId <> ", owner set up: " <> show mgOwnerBootstrapped
getManagedGroup :: DB.Connection -> IO (Maybe ManagedGroup)
getManagedGroup db =
maybeFirstRow toGroup $
DB.query_ db "SELECT group_id, group_link, owner_bootstrapped FROM sx_badge_service_group LIMIT 1"
where
toGroup (mgGroupId, mgGroupLink, BI mgOwnerBootstrapped) = ManagedGroup {mgGroupId, mgGroupLink, mgOwnerBootstrapped}
-- getManagedGroup reads with no ordering, so a second row would change which group is used.
insertManagedGroup :: DB.Connection -> Int64 -> Text -> UTCTime -> IO ()
insertManagedGroup db gid link now =
DB.execute
db
[sql|
INSERT INTO sx_badge_service_group (group_id, group_link, owner_bootstrapped, created_at)
SELECT ?,?,0,? WHERE NOT EXISTS (SELECT 1 FROM sx_badge_service_group)
|]
(gid, link, now)
-- A tracker's item id is only found in the group it was posted to, so a new group starts with none.
clearCodeGroupItems :: DB.Connection -> IO ()
clearCodeGroupItems db =
DB.execute_ db "UPDATE sx_badge_service_badge_codes SET group_item_id = NULL, group_item_sent_at = NULL WHERE group_item_id IS NOT NULL"
markOwnerBootstrapped :: DB.Connection -> Int64 -> IO Bool
markOwnerBootstrapped db gid =
(> 0)
<$> executeChanging
db
"UPDATE sx_badge_service_group SET owner_bootstrapped = 1 WHERE group_id = ? AND owner_bootstrapped = 0"
(Only gid)
getBadgeCode :: DB.Connection -> ByteString -> IO (Maybe IssuedCode)
getBadgeCode db codeHash =
maybeFirstRow toCode $
DB.query
db
[sql|
SELECT c.badge_code_id, c.badge_type, c.months, c.code_payment_status, c.revoked_at,
c.expires_at, p.badge_purchase_id, p.purchase_key, i.credential
FROM sx_badge_service_badge_codes c
LEFT JOIN sx_badge_service_badge_purchases p ON p.badge_code_id = c.badge_code_id
LEFT JOIN sx_badge_service_badge_issuances i ON i.badge_purchase_id = p.badge_purchase_id
WHERE c.code_hash = ?
ORDER BY i.period_end DESC
LIMIT 1
SELECT badge_code_id, badge_type, months, code_payment_status, revoked_at, expires_at, redeem_limit, redeem_count
FROM sx_badge_service_badge_codes
WHERE code_hash = ?
|]
(Only (Binary codeHash))
where
toCode (badgeCodeId, badgeType, months, paymentStatus, revokedAt, expiresAt, purchaseId_, purchaseKey_, credential_) =
IssuedCode {badgeCodeId, badgeType, months, paymentStatus, revokedAt, expiresAt, redemption = codeRedemption purchaseId_ purchaseKey_ credential_}
codeRedemption purchaseId_ purchaseKey_ credential_ = case (purchaseId_, purchaseKey_) of
(Just badgePurchaseId, Just purchaseKey) -> case decodeCredential =<< credential_ of
Just credential -> CodeRedeemed RedeemedCode {badgePurchaseId, purchaseKey, credential}
Nothing -> CodeRedeemedUnreadable
_ -> CodeUnredeemed
toCode (badgeCodeId, badgeType, months, paymentStatus, revokedAt, expiresAt, redeemLimit, redeemCount) =
IssuedCode {badgeCodeId, badgeType, months, paymentStatus, revokedAt, expiresAt, redeemLimit, redeemCount}
getCodePurchaseForKey :: DB.Connection -> Int64 -> C.PublicKeyEd25519 -> IO KeyRedemption
getCodePurchaseForKey db badgeCodeId key =
maybeFirstRow' KeyUnredeemed toRedemption $
DB.query
db
[sql|
SELECT p.badge_purchase_id, i.credential
FROM sx_badge_service_badge_purchases p
LEFT JOIN sx_badge_service_badge_issuances i ON i.badge_purchase_id = p.badge_purchase_id
WHERE p.badge_code_id = ? AND p.purchase_key = ?
ORDER BY i.period_end DESC
LIMIT 1
|]
(badgeCodeId, key)
where
toRedemption (badgePurchaseId, credential_) = case decodeCredential =<< credential_ of
Just credential -> KeyRedeemed KeyPurchase {badgePurchaseId, credential}
Nothing -> KeyRedeemedUnreadable
decodeCredential (Binary bs) = J.decodeStrict' bs
purchaseKeyExists :: DB.Connection -> C.PublicKeyEd25519 -> IO Bool
@@ -222,15 +284,14 @@ appendLedgerPlan db purchaseId rows issuance_ = do
((entryId, purchaseId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs) :. (balanceBadgeType, createdAt, createdAt, entryTypeT, creditType, debitType))
insertedRowId db
-- redeemed_at is stamped here, so this must run in the same transaction as the credential rows.
-- Mark the code as redeemed before adding the purchase. On Postgres, a revoke or redemption running
-- at the same time then waits, sees the code is taken, and fails.
-- The claim takes one use before adding the purchase, so a concurrent revoke or redemption waits on this row and sees the new count.
-- Run it in the credential's transaction.
createCodePurchase :: DB.Connection -> NewCodePurchase -> UTCTime -> IO (Maybe Int64)
createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey = BadgeMasterKey mk, badgeType} now = do
claimed <-
executeChanging
db
"UPDATE sx_badge_service_badge_codes SET redeemed_at = ? WHERE badge_code_id = ? AND redeemed_at IS NULL AND revoked_at IS NULL"
"UPDATE sx_badge_service_badge_codes SET redeem_count = redeem_count + 1, redeemed_at = ? WHERE badge_code_id = ? AND redeem_count < redeem_limit AND revoked_at IS NULL"
(now, badgeCodeId)
if claimed == 0
then pure Nothing
@@ -245,32 +306,79 @@ createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey = Bad
(purchaseKey, Binary mk, badgeType, badgeType, PSIssued, badgeCodeId, now, now)
Just <$> insertedRowId db
data RevokeResult = Revoked | AlreadyRevoked | AlreadyRedeemed | NoSuchCode
-- | Revoked and AlreadyRevoked carry the code id, so the caller can retire the code's group tracker,
-- or repair one an earlier revoke left live.
data RevokeResult = Revoked Int64 | AlreadyRevoked Int64 | AlreadyRedeemed | NoSuchCode
deriving (Eq, Show)
-- | A code that was already redeemed can't be revoked, because its badge was already given out.
-- | A code with no uses left can't be revoked, because every badge it grants was already given out.
revokeCode :: DB.Connection -> ByteString -> UTCTime -> IO RevokeResult
revokeCode db codeHash now = do
revoked <-
executeChanging
db
"UPDATE sx_badge_service_badge_codes SET revoked_at = ? WHERE code_hash = ? AND revoked_at IS NULL AND redeemed_at IS NULL"
"UPDATE sx_badge_service_badge_codes SET revoked_at = ? WHERE code_hash = ? AND revoked_at IS NULL AND redeem_count < redeem_limit"
(now, Binary codeHash)
if revoked > 0
then pure Revoked
else
maybeFirstRow' NoSuchCode refusal $
DB.query db "SELECT revoked_at FROM sx_badge_service_badge_codes WHERE code_hash = ?" (Only (Binary codeHash))
-- The result is read in the same transaction as the UPDATE, so the row it answers about is the row that changed.
maybeFirstRow' NoSuchCode (result revoked) $
DB.query db "SELECT badge_code_id, revoked_at FROM sx_badge_service_badge_codes WHERE code_hash = ?" (Only (Binary codeHash))
where
refusal :: Only (Maybe UTCTime) -> RevokeResult
refusal (Only revokedAt) = maybe AlreadyRedeemed (const AlreadyRevoked) revokedAt
result :: Int -> (Int64, Maybe UTCTime) -> RevokeResult
result revoked (badgeCodeId, revokedAt)
| revoked > 0 = Revoked badgeCodeId
| isJust revokedAt = AlreadyRevoked badgeCodeId
| otherwise = AlreadyRedeemed
insertBadgeCode :: DB.Connection -> ByteString -> BadgeType -> Int -> BadgeCodePaymentStatus -> UTCTime -> IO ()
insertBadgeCode db codeHash badgeType months paymentStatus now =
insertBadgeCode :: DB.Connection -> ByteString -> BadgeType -> Int -> BadgeCodePaymentStatus -> Int -> UTCTime -> IO Int64
insertBadgeCode db codeHash badgeType months paymentStatus redeemLimit now = do
DB.execute
db
[sql|
INSERT INTO sx_badge_service_badge_codes (code_hash, badge_type, months, code_payment_status, created_at)
VALUES (?,?,?,?,?)
INSERT INTO sx_badge_service_badge_codes (code_hash, badge_type, months, code_payment_status, redeem_limit, created_at)
VALUES (?,?,?,?,?,?)
|]
(Binary codeHash, badgeType, months, paymentStatus, now)
(Binary codeHash, badgeType, months, paymentStatus, redeemLimit, now)
insertedRowId db
setCodeGroupItem :: DB.Connection -> Int64 -> Int64 -> UTCTime -> IO ()
setCodeGroupItem db badgeCodeId itemId sentAt =
DB.execute
db
"UPDATE sx_badge_service_badge_codes SET group_item_id = ?, group_item_sent_at = ? WHERE badge_code_id = ?"
(itemId, sentAt, badgeCodeId)
data CodeTracker = CodeTracker
{ trackerItemId :: Int64,
trackerSentAt :: UTCTime,
redeemLimit :: Int,
redeemCount :: Int,
revokedAt :: Maybe UTCTime,
redeemedAt :: Maybe UTCTime
}
getCodeTracker :: DB.Connection -> Int64 -> IO (Maybe CodeTracker)
getCodeTracker db badgeCodeId =
maybeFirstRow toTracker $
DB.query
db
[sql|
SELECT group_item_id, group_item_sent_at, redeem_limit, redeem_count, revoked_at, redeemed_at
FROM sx_badge_service_badge_codes
WHERE badge_code_id = ? AND group_item_id IS NOT NULL AND group_item_sent_at IS NOT NULL
|]
(Only badgeCodeId)
where
toTracker (trackerItemId, trackerSentAt, redeemLimit, redeemCount, revokedAt, redeemedAt) =
CodeTracker {trackerItemId, trackerSentAt, redeemLimit, redeemCount, revokedAt, redeemedAt}
getEditableTrackers :: DB.Connection -> UTCTime -> IO [(Int64, Int64)]
getEditableTrackers db sentAfter =
DB.query
db
[sql|
SELECT badge_code_id, group_item_id
FROM sx_badge_service_badge_codes
WHERE group_item_id IS NOT NULL AND group_item_sent_at > ?
ORDER BY badge_code_id
|]
(Only sentAfter)
@@ -17,7 +17,8 @@ badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations
schemaMigrations :: [(String, Text, Maybe Text)]
schemaMigrations =
[ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema)
[ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema),
("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops)
]
-- | The client tables share this database, so the service tables are the same names behind a prefix.
@@ -109,6 +110,48 @@ DROP INDEX @idx_badge_purchases_code;
DROP TABLE @badge_codes;
|]
m20260918_badge_group_ops :: Text
m20260918_badge_group_ops =
withPrefix
servicePrefix
[r|
CREATE TABLE @group(
group_id BIGINT NOT NULL PRIMARY KEY,
group_link TEXT NOT NULL,
owner_bootstrapped SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL
);
ALTER TABLE @badge_codes ADD COLUMN redeem_limit INTEGER NOT NULL DEFAULT 1;
ALTER TABLE @badge_codes ADD COLUMN redeem_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE @badge_codes ADD COLUMN group_item_id BIGINT;
ALTER TABLE @badge_codes ADD COLUMN group_item_sent_at TIMESTAMPTZ;
-- Redemptions made before this migration must count against the new limit, or every code
-- redeemed already would read as unspent and could be redeemed once more.
UPDATE @badge_codes SET redeem_count = 1 WHERE redeemed_at IS NOT NULL;
DROP INDEX @idx_badge_purchases_code;
CREATE INDEX @idx_badge_purchases_code ON @badge_purchases(badge_code_id);
|]
-- The index stays non-unique, since a multi-use code may already have several purchases.
down_m20260918_badge_group_ops :: Text
down_m20260918_badge_group_ops =
withPrefix
servicePrefix
[r|
ALTER TABLE @badge_codes DROP COLUMN group_item_sent_at;
ALTER TABLE @badge_codes DROP COLUMN group_item_id;
ALTER TABLE @badge_codes DROP COLUMN redeem_count;
ALTER TABLE @badge_codes DROP COLUMN redeem_limit;
DROP TABLE @group;
|]
{- TODO [badges] deferred with the draft in M20260915_user_badges, service only.
ALTER TABLE @payments ADD COLUMN receipt_hash BYTEA;
@@ -18,7 +18,8 @@ badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations
schemaMigrations :: [(String, Query, Maybe Query)]
schemaMigrations =
[ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema)
[ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema),
("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops)
]
-- | The client tables share this database, so the service tables are the same names behind a prefix.
@@ -110,6 +111,48 @@ DROP INDEX @idx_badge_purchases_code;
DROP TABLE @badge_codes;
|]
m20260918_badge_group_ops :: Query
m20260918_badge_group_ops =
withPrefix
servicePrefix
[sql|
CREATE TABLE @group(
group_id INTEGER NOT NULL PRIMARY KEY,
group_link TEXT NOT NULL,
owner_bootstrapped INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
) STRICT;
ALTER TABLE @badge_codes ADD COLUMN redeem_limit INTEGER NOT NULL DEFAULT 1;
ALTER TABLE @badge_codes ADD COLUMN redeem_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE @badge_codes ADD COLUMN group_item_id INTEGER;
ALTER TABLE @badge_codes ADD COLUMN group_item_sent_at TEXT;
-- Redemptions made before this migration must count against the new limit, or every code
-- redeemed already would read as unspent and could be redeemed once more.
UPDATE @badge_codes SET redeem_count = 1 WHERE redeemed_at IS NOT NULL;
DROP INDEX @idx_badge_purchases_code;
CREATE INDEX @idx_badge_purchases_code ON @badge_purchases(badge_code_id);
|]
-- The index stays non-unique, since a multi-use code may already have several purchases.
down_m20260918_badge_group_ops :: Query
down_m20260918_badge_group_ops =
withPrefix
servicePrefix
[sql|
ALTER TABLE @badge_codes DROP COLUMN group_item_sent_at;
ALTER TABLE @badge_codes DROP COLUMN group_item_id;
ALTER TABLE @badge_codes DROP COLUMN redeem_count;
ALTER TABLE @badge_codes DROP COLUMN redeem_limit;
DROP TABLE @group;
|]
{- TODO [badges] deferred with the draft in M20260915_user_badges, service only.
ALTER TABLE @payments ADD COLUMN receipt_hash BLOB;
+2
View File
@@ -0,0 +1,2 @@
node_modules/
data/
+36
View File
@@ -0,0 +1,36 @@
# SimpleX Calculator
A calculator bot built with the [SimpleX Chat Node.js library](../../packages/simplex-chat-nodejs/).
Each user who connects via the bot's business address receives a calculator message with keys as commands. The bot replaces this message after each input.
- `×` and `÷` are applied before `+` and `-`, so `2 + 3 × 4 =` equals 14.
- `%` is computed as in Apple's calculator: `100 + 15 % =` equals 115.
- Tap `C` to clear the number, and `C` again to clear the calculation.
- Numbers are shown without exponent, up to 15 digits; larger results are shown as `Error`.
- You can also send numbers and keys like `+`, `=`, `add` or `c`. Expressions like `(2 + 3) × 4` are entered as one number, and the bot replies to them with the result.
- The bot deletes keys and numbers, tapped or sent, and logs each computed operation, such as `15 + 10 = 25`.
- After 10 minutes without input, the bot turns the calculator off and discards the calculation; tap any key to turn it on.
- The bot sends keys like `/+` to apps with chat protocol version 21 or later, and keys like `/add` to older apps.
## Run
```bash
npm install
npm run build
npm start
```
The bot prints its address and keeps its data in `./data`.
To add a SimpleX name to the address, register the name with the address short link, and run `npm start -- --domain yourname.simplex`. The name is kept when the bot starts without `--domain`.
To use the library from this repository, build it and run `npm install --no-save ../../packages/simplex-chat-nodejs` instead of `npm install`.
## Test
```bash
npm test
```
A local SMP server is started for the end-to-end test. On Linux, `smp-server` is downloaded from [simplexmq releases](https://github.com/simplex-chat/simplexmq/releases); to use another build, or on other systems, set `SMP_SERVER` to the path of `smp-server`.
@@ -0,0 +1,202 @@
import {describe, test, expect} from "vitest"
import {Calc, Key, calculatorText, initialCalc, press, textInput} from "./src/calculator.js"
type Input = {tap: string} | {text: string}
function run(inputs: Input[]): {calc: Calc, logLines: string[]} {
const logLines: string[] = []
const calc = inputs.reduce((current, input) => {
const [next, logLine] = "tap" in input ? press(current, input.tap as Key) : textInput(input.text)!.update(current)
if (logLine) logLines.push(logLine)
return next
}, initialCalc)
return {calc, logLines}
}
const tap = (keys: string) => run(keys.split(" ").map(key => ({tap: key})))
const type = (...texts: string[]) => run(texts.map(text => ({text})))
const display = (keys: string) => tap(keys).calc.display
describe("keys", () => {
test("enter numbers", () => {
expect(display("1 2 . 5")).toBe("12.5")
expect(display("0 0 7")).toBe("7")
expect(display(". 5")).toBe("0.5")
expect(display("1 . . 5")).toBe("1.5")
})
test("compute × and ÷ before + and -", () => {
expect(tap("2 + 2 =")).toEqual({calc: expect.objectContaining({display: "4"}), logLines: ["2 + 2 = 4"]})
expect(tap("2 + 3 × 4 =").logLines).toEqual(["3 × 4 = 12\n2 + 12 = 14"])
expect(tap("2 × 3 + 4 =").logLines).toEqual(["2 × 3 = 6", "6 + 4 = 10"])
expect(display("1 0 - 2 × 3 =")).toBe("4")
expect(display("1 0 - 2 - 3 =")).toBe("5")
expect(display("8 ÷ 4 ÷ 2 =")).toBe("1")
})
test("show the operand of the pressed operator", () => {
expect(display("2 + 3 ×")).toBe("3")
expect(display("2 × 3 +")).toBe("6")
expect(display("2 + 3 × 4 ×")).toBe("12")
expect(display("2 + 3 × 4 +")).toBe("14")
})
test("log each computed operation", () => {
expect(tap("1 5 + 1 0 + 5 + 1 =").logLines).toEqual(["15 + 10 = 25", "25 + 5 = 30", "30 + 1 = 31"])
expect(tap("2 + 3 × 4 × 5 +").logLines).toEqual(["3 × 4 = 12", "12 × 5 = 60\n2 + 60 = 62"])
expect(tap("2 + 3 × 4").logLines).toEqual([])
expect(tap("5 =").logLines).toEqual([])
})
test("replace operator", () => {
expect(tap("2 + × 3 =").logLines).toEqual(["2 × 3 = 6"])
expect(tap("2 + 3 × + 4 =").logLines).toEqual(["2 + 3 = 5", "5 + 4 = 9"])
expect(tap("2 × 3 + × 4 =").logLines).toEqual(["2 × 3 = 6", "6 × 4 = 24"])
})
test("continue from result", () => {
expect(tap("2 + 2 = + 3 =").logLines).toEqual(["2 + 2 = 4", "4 + 3 = 7"])
expect(display("2 + 2 = 5")).toBe("5")
})
test("percent", () => {
expect(display("1 0 0 + 1 5 %")).toBe("15")
expect(tap("1 0 0 + 1 5 % =").logLines).toEqual(["100 + 15% = 115"])
expect(display("1 0 0 - 1 5 %")).toBe("15")
expect(display("1 0 0 - 1 5 % =")).toBe("85")
expect(display("5 0 × 1 0 %")).toBe("0.1")
expect(display("5 0 × 1 0 % =")).toBe("5")
expect(display("5 0 ÷ 1 0 %")).toBe("0.1")
expect(display("5 0 ÷ 1 0 % =")).toBe("500")
expect(display("1 5 %")).toBe("0.15")
expect(display("1 0 0 + 2 × 1 5 %")).toBe("0.15")
expect(tap("1 0 0 + 2 × 1 5 % =").logLines).toEqual(["2 × 15% = 0.3\n100 + 0.3 = 100.3"])
expect(display("2 × 3 + 1 0 %")).toBe("0.6")
})
test("square root and sign", () => {
expect(display("9 √")).toBe("3")
expect(tap("1 + 9 √ =").logLines).toEqual(["1 + √9 = 4"])
expect(display("5 ±")).toBe("-5")
expect(display("5 ± ±")).toBe("5")
expect(display("± 5")).toBe("-5")
expect(display("2 + 2 = ±")).toBe("-4")
})
test("clear entry, then clear all", () => {
expect(tap("2 + 3 C 4 =").logLines).toEqual(["2 + 4 = 6"])
expect(tap("2 + 3 C C").calc).toEqual(initialCalc)
})
test("display without exponent", () => {
expect(display("0 . 1 + 0 . 2 =")).toBe("0.3")
expect(display("1 ÷ 3 =")).toBe("0.33333333333333")
expect(display("1 ÷ 1 0 0 0 0 0 0 0 =")).toBe("0.0000001")
expect(display("1 ÷ 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 =")).toBe("0.00000000000001")
expect(display(". 0 0 0 0 0 0 0 1 × . 0 0 0 0 0 0 0 1 =")).toBe("0")
})
test("limit digits and overflow", () => {
expect(display("1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7")).toBe("123456789012345")
expect(display("9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 + 1 =")).toBe("Error")
})
test("errors", () => {
expect(display("1 ÷ 0 =")).toBe("Error")
expect(display("2 ± √")).toBe("Error")
expect(display("1 ÷ 0 = 5")).toBe("5")
expect(display("1 ÷ 0 + 2 =")).toBe("Error")
expect(tap("1 ÷ 0 = C").calc).toEqual(initialCalc)
})
})
describe("typed messages", () => {
test("numbers and keys", () => {
expect(type("25", "+", "25", "=")).toEqual({calc: expect.objectContaining({display: "50"}), logLines: ["25 + 25 = 50"]})
expect(type("25", "add", "25", "eq").logLines).toEqual(["25 + 25 = 50"])
expect(type("6", "x", "7", "=").logLines).toEqual(["6 × 7 = 42"])
expect(type("5", "*", "5", "/", "2", "=").logLines).toEqual(["5 × 5 = 25", "25 ÷ 2 = 12.5"])
expect(type("25", "+", "3", "c", "4", "=").logLines).toEqual(["25 + 4 = 29"])
})
test("mixed with taps", () => {
expect(run([{text: "25"}, {tap: "+"}, {text: "25"}, {tap: "="}]).logLines).toEqual(["25 + 25 = 50"])
})
test("expressions are entered as a number", () => {
const result = (text: string) => type(text).calc.display
expect(result("2 + 2")).toBe("4")
expect(result("12 × 3 + 4")).toBe("40")
expect(result("12 * 3 + 4 =")).toBe("40")
expect(result("8 / 2")).toBe("4")
expect(result("3x3")).toBe("9")
expect(result("-5")).toBe("-5")
expect(result("2 × -3")).toBe("-6")
expect(result("100 + 15%")).toBe("115")
expect(result("42")).toBe("42")
expect(result("2 + 3 × 4")).toBe("14")
expect(result("(2 + 3) × 4")).toBe("20")
expect(result("2 × (3 + 4)")).toBe("14")
expect(result("((1 + 2) × 3)")).toBe("9")
expect(result("-(2 + 3)")).toBe("-5")
expect(result("100 + (10 + 5)%")).toBe("115")
expect(type("10", "×", "2 + 3", "=").logLines).toEqual(["10 × 5 = 50"])
expect(type("10", "+", "25=").calc.display).toBe("35")
})
test("result only for expressions", () => {
const result = (text: string) => textInput(text)?.result
expect(result("(2 + 3) × 4")).toBe("20")
expect(result("(2 + 3) × 4 =")).toBe("20")
expect(result("15%")).toBe("0.15")
expect(result("1 / 0")).toBe("Error")
expect(result("25")).toBeUndefined()
expect(result("-5")).toBeUndefined()
expect(result("25=")).toBeUndefined()
expect(result("+")).toBeUndefined()
})
test("overflow in typed numbers", () => {
expect(type("1234567890123456").calc.display).toBe("Error")
expect(type("123456789012345").calc.display).toBe("123456789012345")
})
test("reject other text", () => {
expect(textInput("hello")).toBeUndefined()
expect(textInput("2 ^ 3")).toBeUndefined()
expect(textInput("2.3.4")).toBeUndefined()
expect(textInput("(2 + 3")).toBeUndefined()
expect(textInput("2 + 3)")).toBeUndefined()
expect(textInput("()")).toBeUndefined()
expect(textInput("2(3)")).toBeUndefined()
expect(textInput("2 +")).toBeUndefined()
})
})
describe("calculator text", () => {
const nbsp = String.fromCharCode(0xa0)
test("symbol keys", () => {
expect(calculatorText(initialCalc, true)).toBe([
"*0*",
"/C /± /% /÷",
"/7 /8 /9 /×",
"/4 /5 /6 /-",
"/1 /2 /3 /+",
"/√ /0 /. /=",
].join("\n"))
})
test("switched off: keys without number", () => {
const [displayLine, ...rows] = calculatorText(undefined, true).split("\n")
expect(displayLine).toBe(`*${nbsp}*`)
expect(rows).toEqual(calculatorText(initialCalc, true).split("\n").slice(1))
})
test("word keys padded to equal width", () => {
const pad = (n: number) => `\`${nbsp.repeat(n)}\``
const [displayLine, firstRow] = calculatorText(initialCalc, false).split("\n")
expect(displayLine).toBe("*0*")
expect(firstRow).toBe(`/C ${pad(4)}/neg ${pad(2)}/pct ${pad(2)}/div ${pad(2)}`)
})
})

Some files were not shown because too many files have changed in this diff Show More