mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-06 05:37:47 +00:00
Merge branch 'master' into ep/parallel-tests
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
+11
-3
@@ -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)
|
||||
|
||||
+38
-6
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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 =
|
||||
|
||||
+51
-19
@@ -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}")
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+23
-11
@@ -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) {
|
||||
|
||||
+2
@@ -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) {
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+1
@@ -2470,6 +2470,7 @@ fun BoxScope.ChatItemsList(
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { listState.value.isScrollInProgress }
|
||||
.onCompletion { chatViewScrollState.value = false }
|
||||
.collect {
|
||||
chatViewScrollState.value = it
|
||||
}
|
||||
|
||||
+4
-8
@@ -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) {
|
||||
|
||||
+9
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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 -> {
|
||||
|
||||
+9
-4
@@ -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)) {
|
||||
|
||||
+9
-2
@@ -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,
|
||||
|
||||
+9
@@ -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>,
|
||||
|
||||
+2
@@ -38,6 +38,8 @@ object DatabaseUtils {
|
||||
passphrase.set(null)
|
||||
initVector.set(null)
|
||||
}
|
||||
|
||||
fun storage(): String? = cryptor.keyStorage(alias)
|
||||
}
|
||||
|
||||
fun hasAtLeastOneDatabase(rootDir: String): Boolean =
|
||||
|
||||
+1
-1
@@ -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,
|
||||
)
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+2
@@ -12,4 +12,6 @@ actual val cryptor: CryptorInterface = object : CryptorInterface {
|
||||
override fun deleteKey(alias: String) {
|
||||
// LALAL
|
||||
}
|
||||
|
||||
override fun keyStorage(alias: String): String? = null
|
||||
}
|
||||
|
||||
+11
-4
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -59,6 +59,7 @@ actual fun DatabaseEncryptionFooter(
|
||||
useKeychain: MutableState<Boolean>,
|
||||
chatDbEncrypted: Boolean?,
|
||||
storedKey: MutableState<Boolean>,
|
||||
keyStorage: String?,
|
||||
initialRandomDBPassphrase: MutableState<Boolean>,
|
||||
migration: Boolean,
|
||||
) {
|
||||
|
||||
+19
-1
@@ -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==",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
data/
|
||||
@@ -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
Reference in New Issue
Block a user