From 36739fdf04c8e16368818bc5787cd8a03d88aa1b Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 29 May 2025 15:02:52 +0200 Subject: [PATCH 01/13] List and set ntf servers --- src/Simplex/Chat/Controller.hs | 3 +++ src/Simplex/Chat/Library/Commands.hs | 4 ++++ src/Simplex/Chat/View.hs | 9 +++++++++ 3 files changed, 16 insertions(+) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 97bf26fd84..fbdf862bc6 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -350,6 +350,8 @@ data ChatCommand | APISetConnectionAlias Int64 LocalAlias | APISetUserUIThemes UserId (Maybe UIThemeEntityOverrides) | APISetChatUIThemes ChatRef (Maybe UIThemeEntityOverrides) + | APIGetNtfServers + | APISetNtfServers [NtfServer] | APIGetNtfToken | APIRegisterToken DeviceToken NotificationsMode | APIVerifyToken DeviceToken C.CbNonce ByteString @@ -717,6 +719,7 @@ data ChatResponse | CRNewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRNewMemberContactSentInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRCallInvitations {callInvitations :: [RcvCallInvitation]} + | CRNtfServers {ntfServers :: [NtfServer]} | CRNtfTokenStatus {status :: NtfTknStatus} | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode, ntfServer :: NtfServer} | CRNtfConns {ntfConns :: [NtfConn]} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 20f9468cd6..423a255a14 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1304,6 +1304,8 @@ processChatCommand' vr = \case liftIO $ setGroupUIThemes db user g uiThemes ok user _ -> throwCmdError "not supported" + APIGetNtfServers -> withUser $ \_ -> CRNtfServers <$> lift (withAgent' getNtfServers) + APISetNtfServers servers -> withUser $ \_ -> lift (withAgent' (`setNtfServers` servers)) >> ok_ APIGetNtfToken -> withUser' $ \_ -> crNtfToken <$> withAgent getNtfToken APIRegisterToken token mode -> withUser $ \_ -> CRNtfTokenStatus <$> withAgent (\a -> registerNtfToken a token mode) @@ -4098,6 +4100,8 @@ chatCommandP = "/_set prefs @" *> (APISetContactPrefs <$> A.decimal <* A.space <*> jsonP), "/_set theme user " *> (APISetUserUIThemes <$> A.decimal <*> optional (A.space *> jsonP)), "/_set theme " *> (APISetChatUIThemes <$> chatRefP <*> optional (A.space *> jsonP)), + "/_ntf servers" $> APIGetNtfServers, + "/_ntf servers " *> (APISetNtfServers . map SMP.protoServer <$> protocolServersP), "/_ntf get" $> APIGetNtfToken, "/_ntf register " *> (APIRegisterToken <$> strP_ <*> strP), "/_ntf verify " *> (APIVerifyToken <$> strP <* A.space <*> strP <* A.space <*> strP), diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index c13b164693..e582f072e5 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -235,6 +235,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRNewMemberContactSentInv u _ct g m -> ttyUser u ["sent invitation to connect directly to member " <> ttyGroup' g <> " " <> ttyMember m] CRCallInvitations _ -> [] CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"] + CRNtfServers ntfServers -> viewNtfServers ntfServers CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode srv -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode) <> ", server: " <> sShow srv] CRNtfConns {ntfConns} -> map (\NtfConn {agentConnId, expectedMsg_} -> plain $ show agentConnId <> " " <> show expectedMsg_) ntfConns @@ -2201,6 +2202,14 @@ viewVersionInfo logLevel CoreVersionInfo {version, simplexmqVersion, simplexmqCo parens :: (IsString a, Semigroup a) => a -> a parens s = " (" <> s <> ")" +viewNtfServers :: [SMP.NtfServer] -> [StyledString] +viewNtfServers = \case + [] -> ["No remote NTF server"] + s -> map viewNtfServer s + where + viewNtfServer s = + plain $ SMP.legacyStrEncodeServer s + viewRemoteHosts :: [RemoteHostInfo] -> [StyledString] viewRemoteHosts = \case [] -> ["No remote hosts"] From c3618c1b0d2f7160a72f53f52b1c91735f246413 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Jun 2025 11:19:49 +0200 Subject: [PATCH 02/13] Treat ntf servers as other user servers Once ntf servers will be able to send webpush requests they won't be tight to an application developer like for iOS where ntf servers have to know a secret. They will all have their own VAPID key. So it will be possible for users to choose what servers they want to use. --- src/Simplex/Chat.hs | 49 ++++++++++++------- src/Simplex/Chat/Controller.hs | 7 +-- src/Simplex/Chat/Library/Commands.hs | 68 +++++++++++++++------------ src/Simplex/Chat/Library/Internal.hs | 3 +- src/Simplex/Chat/Mobile.hs | 1 + src/Simplex/Chat/Operators.hs | 51 ++++++++++++-------- src/Simplex/Chat/Operators/Presets.hs | 3 ++ src/Simplex/Chat/Options.hs | 16 ++++++- src/Simplex/Chat/Store/Profiles.hs | 10 ++-- src/Simplex/Chat/Terminal.hs | 5 +- src/Simplex/Chat/View.hs | 14 ++---- 11 files changed, 134 insertions(+), 93 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 6b554d29c4..6995cf0304 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -72,17 +72,20 @@ defaultChatConfig = smp = simplexChatSMPServers, useSMP = 4, xftp = map (presetServer True) $ L.toList defaultXFTPServers, - useXFTP = 3 + useXFTP = 3, + ntf = map (presetServer True) $ L.toList _defaultNtfServers, + useNTF = 3 }, PresetOperator { operator = Just operatorFlux, smp = fluxSMPServers, useSMP = 3, xftp = fluxXFTPServers, - useXFTP = 3 + useXFTP = 3, + ntf = fluxNTFServers, + useNTF = 3 } ], - ntf = _defaultNtfServers, netCfg = defaultNetworkConfig }, -- please note: if these servers are changed, this option needs to be split to two, @@ -125,7 +128,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, presetServers, inlineFiles, deviceNameForRemote, confirmMigrations} - ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, deviceName, highlyAvailable, yesToUpMigrations}, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize} + ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, ntfServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, deviceName, highlyAvailable, yesToUpMigrations}, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize} backgroundMode = do let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} confirmMigrations' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations @@ -138,7 +141,8 @@ newChatController opDomains = operatorDomains $ mapMaybe operatorWithId $ zip [1 ..] rndSrvs agentSMP <- randomServerCfgs "agent SMP servers" SPSMP opDomains rndSrvs agentXFTP <- randomServerCfgs "agent XFTP servers" SPXFTP opDomains rndSrvs - let randomAgentServers = RandomAgentServers {smpServers = agentSMP, xftpServers = agentXFTP} + agentNTF <- randomServerCfgs "agent NTF servers" SPNTF opDomains rndSrvs + let randomAgentServers = RandomAgentServers {smpServers = agentSMP, xftpServers = agentXFTP, ntfServers = agentNTF} currentRemoteHost <- newTVarIO Nothing servers <- withTransaction chatStore $ \db -> agentServers db config randomPresetServers randomAgentServers smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore backgroundMode @@ -220,47 +224,56 @@ newChatController where PresetServers {operators, netCfg} = presetServers netCfg' = updateNetworkConfig netCfg simpleNetCfg - operators' = case (smpServers, xftpServers) of - ([], []) -> operators - (smpSrvs, []) -> L.map disableSMP operators <> [custom smpSrvs []] - ([], xftpSrvs) -> L.map disableXFTP operators <> [custom [] xftpSrvs] - (smpSrvs, xftpSrvs) -> [custom smpSrvs xftpSrvs] + operators' = case (smpServers, xftpServers, ntfServers) of + ([], [], []) -> operators + (smpSrvs, [], []) -> L.map disableSMP operators <> [custom smpSrvs [] []] + ([], xftpSrvs, []) -> L.map disableXFTP operators <> [custom [] xftpSrvs []] + (smpSrvs, xftpSrvs, []) -> L.map disableSMP operators <> L.map disableXFTP operators <> [custom smpSrvs xftpSrvs []] + ([], [], ntfSrvs) -> L.map disableNTF operators <> [custom [] [] ntfSrvs] + (smpSrvs, [], ntfSrvs) -> L.map disableSMP operators <> L.map disableNTF operators <> [custom smpSrvs [] ntfSrvs] + ([], xftpSrvs, ntfSrvs) -> L.map disableXFTP operators <> L.map disableNTF operators <> [custom [] xftpSrvs ntfSrvs] + (smpSrvs, xftpSrvs, ntfSrvs) -> [custom smpSrvs xftpSrvs ntfSrvs] disableSMP op@PresetOperator {smp} = (op :: PresetOperator) {smp = map disableSrv smp} disableXFTP op@PresetOperator {xftp} = (op :: PresetOperator) {xftp = map disableSrv xftp} + disableNTF op@PresetOperator {ntf} = (op :: PresetOperator) {ntf = map disableSrv ntf} disableSrv :: forall p. NewUserServer p -> NewUserServer p disableSrv srv = (srv :: NewUserServer p) {enabled = False} - custom smpSrvs xftpSrvs = + custom smpSrvs xftpSrvs ntfSrvs = PresetOperator { operator = Nothing, smp = map newUserServer smpSrvs, useSMP = 0, xftp = map newUserServer xftpSrvs, - useXFTP = 0 + useXFTP = 0, + ntf = map newUserServer ntfSrvs, + useNTF = 0 } randomServerCfgs :: UserProtocol p => String -> SProtocolType p -> [(Text, ServerOperator)] -> [PresetOperator] -> IO (NonEmpty (ServerCfg p)) randomServerCfgs name p opDomains rndSrvs = toJustOrError name $ L.nonEmpty $ agentServerCfgs p opDomains $ concatMap (pServers p) rndSrvs agentServers :: DB.Connection -> ChatConfig -> NonEmpty PresetOperator -> RandomAgentServers -> IO InitialAgentServers - agentServers db ChatConfig {presetServers = PresetServers {ntf, netCfg}, presetDomains} presetOps as = do + agentServers db ChatConfig {presetServers = PresetServers {netCfg}, presetDomains} presetOps as = do users <- getUsers db ops <- getUpdateServerOperators db presetOps (null users) let opDomains = operatorDomains $ mapMaybe snd ops - (smp', xftp') <- unzip <$> mapM (getServers ops opDomains) users - pure InitialAgentServers {smp = M.fromList (optServers smp' smpServers), xftp = M.fromList (optServers xftp' xftpServers), ntf, netCfg, presetDomains} + (smp', xftp', ntf') <- unzip3 <$> mapM (getServers ops opDomains) users + pure InitialAgentServers {smp = M.fromList (optServers smp' smpServers), xftp = M.fromList (optServers xftp' xftpServers), ntf = M.fromList (optServers ntf' ntfServers), netCfg, presetDomains} where optServers :: [(UserId, NonEmpty (ServerCfg p))] -> [ProtoServerWithAuth p] -> [(UserId, NonEmpty (ServerCfg p))] optServers srvs overrides_ = case L.nonEmpty overrides_ of Just overrides -> map (second $ const $ L.map (presetServerCfg True allRoles Nothing) overrides) srvs Nothing -> srvs - getServers :: [(Maybe PresetOperator, Maybe ServerOperator)] -> [(Text, ServerOperator)] -> User -> IO ((UserId, NonEmpty (ServerCfg 'PSMP)), (UserId, NonEmpty (ServerCfg 'PXFTP))) + getServers :: [(Maybe PresetOperator, Maybe ServerOperator)] -> [(Text, ServerOperator)] -> User -> IO ((UserId, NonEmpty (ServerCfg 'PSMP)), (UserId, NonEmpty (ServerCfg 'PXFTP)), (UserId, NonEmpty (ServerCfg 'PNTF))) getServers ops opDomains user' = do smpSrvs <- getProtocolServers db SPSMP user' xftpSrvs <- getProtocolServers db SPXFTP user' - uss <- groupByOperator' (ops, smpSrvs, xftpSrvs) + ntfSrvs <- getProtocolServers db SPNTF user' + uss <- groupByOperator' (ops, smpSrvs, xftpSrvs, ntfSrvs) ts <- getCurrentTime uss' <- mapM (setUserServers' db user' ts . updatedUserServers) uss let auId = aUserId user' - pure $ bimap (auId,) (auId,) $ useServers as opDomains uss' + pure $ auIdMap auId $ useServers as opDomains uss' + auIdMap auId (a, b, c) = ((auId, a), (auId, b), (auId, c)) chooseRandomServers :: PresetServers -> IO (NonEmpty PresetOperator) chooseRandomServers PresetServers {operators} = diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index fbdf862bc6..aff6b3f6dc 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -163,7 +163,8 @@ data ChatConfig = ChatConfig data RandomAgentServers = RandomAgentServers { smpServers :: NonEmpty (ServerCfg 'PSMP), - xftpServers :: NonEmpty (ServerCfg 'PXFTP) + xftpServers :: NonEmpty (ServerCfg 'PXFTP), + ntfServers :: NonEmpty (ServerCfg 'PNTF) } deriving (Show) @@ -185,7 +186,6 @@ defaultChatHooks = ChatHooks Nothing Nothing Nothing data PresetServers = PresetServers { operators :: NonEmpty PresetOperator, - ntf :: [NtfServer], netCfg :: NetworkConfig } deriving (Show) @@ -350,8 +350,6 @@ data ChatCommand | APISetConnectionAlias Int64 LocalAlias | APISetUserUIThemes UserId (Maybe UIThemeEntityOverrides) | APISetChatUIThemes ChatRef (Maybe UIThemeEntityOverrides) - | APIGetNtfServers - | APISetNtfServers [NtfServer] | APIGetNtfToken | APIRegisterToken DeviceToken NotificationsMode | APIVerifyToken DeviceToken C.CbNonce ByteString @@ -719,7 +717,6 @@ data ChatResponse | CRNewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRNewMemberContactSentInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRCallInvitations {callInvitations :: [RcvCallInvitation]} - | CRNtfServers {ntfServers :: [NtfServer]} | CRNtfTokenStatus {status :: NtfTknStatus} | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode, ntfServer :: NtfServer} | CRNtfConns {ntfConns :: [NtfConn]} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 423a255a14..8e5a7dc8e0 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -99,7 +99,7 @@ import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern IKPQOff, pattern IKPQOn, pattern PQEncOff, pattern PQSupportOff, pattern PQSupportOn) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (base64P) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), MsgFlags (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), MsgFlags (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol, NtfServerWithAuth) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) import qualified Simplex.Messaging.TMap as TM @@ -131,7 +131,7 @@ import Simplex.Messaging.Agent.Store.Common (withConnection) import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) #endif -_defaultNtfServers :: [NtfServer] +_defaultNtfServers :: NonEmpty NtfServerWithAuth _defaultNtfServers = [ -- "ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,5ex3mupcazy3zlky64ab27phjhijpemsiby33qzq3pliejipbtx5xgad.onion" "ntf://KmpZNNXiVZJx_G2T7jRUmDFxWXM3OAnunz3uLT0tqAA=@ntf3.simplex.im,pxculznuryunjdvtvh6s6szmanyadumpbmvevgdpe4wk5c65unyt4yid.onion", @@ -262,11 +262,12 @@ updateNetworkConfig cfg SimpleNetCfg {socksProxy, socksMode, hostMode, requiredH cfg3 = maybe cfg2 (\tcpTimeout -> cfg2 {tcpTimeout, tcpConnectTimeout = (tcpTimeout * 3) `div` 2}) tcpTimeout_ in cfg3 {socksProxy, socksMode, hostMode, requiredHostMode, smpWebPortServers, logTLSErrors} -useServers :: Foldable f => RandomAgentServers -> [(Text, ServerOperator)] -> f UserOperatorServers -> (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP)) +useServers :: Foldable f => RandomAgentServers -> [(Text, ServerOperator)] -> f UserOperatorServers -> (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP), NonEmpty (ServerCfg 'PNTF)) useServers as opDomains uss = let smp' = useServerCfgs SPSMP as opDomains $ concatMap (servers' SPSMP) uss xftp' = useServerCfgs SPXFTP as opDomains $ concatMap (servers' SPXFTP) uss - in (smp', xftp') + ntf' = useServerCfgs SPNTF as opDomains $ concatMap (servers' SPNTF) uss + in (smp', xftp', ntf') execChatCommand :: Maybe RemoteHostId -> ByteString -> CM' (Either ChatError ChatResponse) execChatCommand rh s = @@ -316,8 +317,8 @@ processChatCommand' vr = \case forM_ users $ \User {localDisplayName = n, activeUser, viewPwdHash} -> when (n == displayName) . throwChatError $ if activeUser || isNothing viewPwdHash then CEUserExists displayName else CEInvalidDisplayName {displayName, validName = ""} - (uss, (smp', xftp')) <- chooseServers =<< readTVarIO u - auId <- withAgent $ \a -> createUser a smp' xftp' + (uss, (smp', xftp', ntf')) <- chooseServers =<< readTVarIO u + auId <- withAgent $ \a -> createUser a smp' xftp' ntf' ts <- liftIO $ getCurrentTime >>= if pastTimestamp then coupleDaysAgo else pure user <- withFastStore $ \db -> do user <- createUserRecordAt db (AgentUserId auId) p True ts @@ -332,7 +333,7 @@ processChatCommand' vr = \case createPresetContactCards db user = do createContact db user simplexStatusContactProfile createContact db user simplexTeamContactProfile - chooseServers :: Maybe User -> CM ([UpdatedUserOperatorServers], (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP))) + chooseServers :: Maybe User -> CM ([UpdatedUserOperatorServers], (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP), NonEmpty (ServerCfg 'PNTF))) chooseServers user_ = do as <- asks randomAgentServers mapM (withFastStore . flip getUserServers >=> liftIO . groupByOperator) user_ >>= \case @@ -343,12 +344,12 @@ processChatCommand' vr = \case Nothing -> do ps <- asks randomPresetServers uss <- presetUserServers <$> withFastStore' (\db -> getUpdateServerOperators db ps True) - let RandomAgentServers {smpServers = smp', xftpServers = xftp'} = as - pure (uss, (smp', xftp')) + let RandomAgentServers {smpServers = smp', xftpServers = xftp', ntfServers = ntf'} = as + pure (uss, (smp', xftp', ntf')) copyServers :: UserOperatorServers -> UpdatedUserOperatorServers - copyServers UserOperatorServers {operator, smpServers, xftpServers} = + copyServers UserOperatorServers {operator, smpServers, xftpServers, ntfServers} = let new srv = AUS SDBNew srv {serverId = DBNewEntity} - in UpdatedUserOperatorServers {operator, smpServers = map new smpServers, xftpServers = map new xftpServers} + in UpdatedUserOperatorServers {operator, smpServers = map new smpServers, xftpServers = map new xftpServers, ntfServers = map new ntfServers} coupleDaysAgo t = (`addUTCTime` t) . fromInteger . negate . (+ (2 * day)) <$> randomRIO (0, day) day = 86400 ListUsers -> CRUsersList <$> withFastStore' getUsersInfo @@ -1304,12 +1305,10 @@ processChatCommand' vr = \case liftIO $ setGroupUIThemes db user g uiThemes ok user _ -> throwCmdError "not supported" - APIGetNtfServers -> withUser $ \_ -> CRNtfServers <$> lift (withAgent' getNtfServers) - APISetNtfServers servers -> withUser $ \_ -> lift (withAgent' (`setNtfServers` servers)) >> ok_ APIGetNtfToken -> withUser' $ \_ -> crNtfToken <$> withAgent getNtfToken - APIRegisterToken token mode -> withUser $ \_ -> - CRNtfTokenStatus <$> withAgent (\a -> registerNtfToken a token mode) - APIVerifyToken token nonce code -> withUser $ \_ -> withAgent (\a -> verifyNtfToken a token nonce code) >> ok_ + APIRegisterToken token mode -> withUser' $ \_u@User {userId} -> + CRNtfTokenStatus <$> withAgent (\a -> registerNtfToken a userId token mode) + APIVerifyToken token nonce code -> withUser $ \_u@User {userId} -> withAgent (\a -> verifyNtfToken a userId token nonce code) >> ok_ APICheckToken token -> withUser $ \_ -> CRNtfTokenStatus <$> withAgent (`checkNtfToken` token) APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) >> ok_ @@ -1360,16 +1359,18 @@ processChatCommand' vr = \case ops' = map Just ops <> [Nothing] opDomains = operatorDomains ops liftIO $ fmap (opsConds,) . mapM (getServers db as ops' opDomains) =<< getUsers db - lift $ withAgent' $ \a -> forM_ srvs $ \(auId, (smp', xftp')) -> do + lift $ withAgent' $ \a -> forM_ srvs $ \(auId, (smp', xftp', ntf')) -> do setProtocolServers a auId smp' setProtocolServers a auId xftp' + setProtocolServers a auId ntf' pure $ CRServerOperatorConditions opsConds where - getServers :: DB.Connection -> RandomAgentServers -> [Maybe ServerOperator] -> [(Text, ServerOperator)] -> User -> IO (UserId, (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP))) + getServers :: DB.Connection -> RandomAgentServers -> [Maybe ServerOperator] -> [(Text, ServerOperator)] -> User -> IO (UserId, (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP), NonEmpty (ServerCfg 'PNTF))) getServers db as ops opDomains user = do smpSrvs <- getProtocolServers db SPSMP user xftpSrvs <- getProtocolServers db SPXFTP user - uss <- groupByOperator (ops, smpSrvs, xftpSrvs) + ntfSrvs <- getProtocolServers db SPNTF user + uss <- groupByOperator (ops, smpSrvs, xftpSrvs, ntfSrvs) pure $ (aUserId user,) $ useServers as opDomains uss SetServerOperators operatorsRoles -> do ops <- serverOperators <$> withFastStore getServerOperators @@ -1393,9 +1394,10 @@ processChatCommand' vr = \case lift $ withAgent' $ \a -> do let auId = aUserId user opDomains = operatorDomains $ mapMaybe operator' $ L.toList uss - (smp', xftp') = useServers as opDomains uss + (smp', xftp', ntf') = useServers as opDomains uss setProtocolServers a auId smp' setProtocolServers a auId xftp' + setProtocolServers a auId ntf' ok_ APIValidateServers userId userServers -> withUserId userId $ \user -> CRUserServersValidation user <$> validateAllUsersServers userId userServers @@ -2685,7 +2687,8 @@ processChatCommand' vr = \case users <- getUsers db smpServers <- getServers db user SPSMP xftpServers <- getServers db user SPXFTP - let presentedServersSummary = toPresentedServersSummary agentServersSummary users user smpServers xftpServers _defaultNtfServers + ntfServers <- getServers db user SPNTF + let presentedServersSummary = toPresentedServersSummary agentServersSummary users user smpServers xftpServers ntfServers pure $ CRAgentServersSummary user presentedServersSummary where getServers :: ProtocolTypeI p => DB.Connection -> User -> SProtocolType p -> IO [ProtocolServer p] @@ -3577,18 +3580,21 @@ processChatCommand' vr = \case ChatRef CTGroup gId scope -> a $ SRGroup gId scope _ -> throwCmdError "not supported" -protocolServers :: UserProtocol p => SProtocolType p -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -protocolServers p (operators, smpServers, xftpServers) = case p of - SPSMP -> (operators, smpServers, []) - SPXFTP -> (operators, [], xftpServers) +protocolServers :: UserProtocol p => SProtocolType p -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserServer 'PNTF]) -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserServer 'PNTF]) +protocolServers p (operators, smpServers, xftpServers, ntfServers) = case p of + SPSMP -> (operators, smpServers, [], []) + SPXFTP -> (operators, [], xftpServers, []) + SPNTF -> (operators, [], [], ntfServers) -- disable preset and replace custom servers (groupByOperator always adds custom) updatedServers :: forall p. UserProtocol p => SProtocolType p -> [AUserServer p] -> UserOperatorServers -> UpdatedUserOperatorServers -updatedServers p' srvs UserOperatorServers {operator, smpServers, xftpServers} = case p' of - SPSMP -> u (updateSrvs smpServers, map (AUS SDBStored) xftpServers) - SPXFTP -> u (map (AUS SDBStored) smpServers, updateSrvs xftpServers) +updatedServers p' srvs UserOperatorServers {operator, smpServers, xftpServers, ntfServers} = case p' of + SPSMP -> u (updateSrvs smpServers, map (AUS SDBStored) xftpServers, map (AUS SDBStored) ntfServers) + SPXFTP -> u (map (AUS SDBStored) smpServers, updateSrvs xftpServers, map (AUS SDBStored) ntfServers) + SPNTF -> u (map (AUS SDBStored) smpServers, map (AUS SDBStored) xftpServers, updateSrvs ntfServers) where - u = uncurry $ UpdatedUserOperatorServers operator + u :: ([AUserServer 'PSMP], [AUserServer 'PXFTP], [AUserServer 'PNTF]) -> UpdatedUserOperatorServers + u (smp, xftp, ntf) = UpdatedUserOperatorServers operator smp xftp ntf updateSrvs :: [UserServer p] -> [AUserServer p] updateSrvs pSrvs = map disableSrv pSrvs <> maybe srvs (const []) operator disableSrv srv@UserServer {preset} = @@ -4100,8 +4106,6 @@ chatCommandP = "/_set prefs @" *> (APISetContactPrefs <$> A.decimal <* A.space <*> jsonP), "/_set theme user " *> (APISetUserUIThemes <$> A.decimal <*> optional (A.space *> jsonP)), "/_set theme " *> (APISetChatUIThemes <$> chatRefP <*> optional (A.space *> jsonP)), - "/_ntf servers" $> APIGetNtfServers, - "/_ntf servers " *> (APISetNtfServers . map SMP.protoServer <$> protocolServersP), "/_ntf get" $> APIGetNtfToken, "/_ntf register " *> (APIRegisterToken <$> strP_ <*> strP), "/_ntf verify " *> (APIVerifyToken <$> strP <* A.space <*> strP <* A.space <*> strP), @@ -4126,8 +4130,10 @@ chatCommandP = "/ntf test " *> (TestProtoServer . AProtoServerWithAuth SPNTF <$> strP), "/smp " *> (SetUserProtoServers (AProtocolType SPSMP) . map (AProtoServerWithAuth SPSMP) <$> protocolServersP), "/xftp " *> (SetUserProtoServers (AProtocolType SPXFTP) . map (AProtoServerWithAuth SPXFTP) <$> protocolServersP), + "/ntf " *> (SetUserProtoServers (AProtocolType SPNTF) . map (AProtoServerWithAuth SPNTF) <$> protocolServersP), "/smp" $> GetUserProtoServers (AProtocolType SPSMP), "/xftp" $> GetUserProtoServers (AProtocolType SPXFTP), + "/ntf" $> GetUserProtoServers (AProtocolType SPNTF), "/_operators" $> APIGetServerOperators, "/_operators " *> (APISetServerOperators <$> jsonP), "/operators " *> (SetServerOperators . L.fromList <$> operatorRolesP `A.sepBy1` A.char ','), diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index bdc5e4b920..e121dc525b 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -146,13 +146,14 @@ withFileLock name = withEntityLock name . CLFile {-# INLINE withFileLock #-} useServerCfgs :: forall p. UserProtocol p => SProtocolType p -> RandomAgentServers -> [(Text, ServerOperator)] -> [UserServer p] -> NonEmpty (ServerCfg p) -useServerCfgs p RandomAgentServers {smpServers, xftpServers} opDomains = +useServerCfgs p RandomAgentServers {smpServers, xftpServers, ntfServers} opDomains = fromMaybe (rndAgentServers p) . L.nonEmpty . agentServerCfgs p opDomains where rndAgentServers :: SProtocolType p -> NonEmpty (ServerCfg p) rndAgentServers = \case SPSMP -> smpServers SPXFTP -> xftpServers + SPNTF -> ntfServers contactCITimed :: Contact -> CM (Maybe CITimed) contactCITimed ct = sndContactCITimed False ct Nothing diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 194fc1bb06..d03bdc6d18 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -208,6 +208,7 @@ mobileChatOpts dbOptions = { dbOptions, smpServers = [], xftpServers = [], + ntfServers = [], simpleNetCfg = defaultSimpleNetCfg, logLevel = CLLImportant, logConnections = False, diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 8c4490a2c4..a434e9e69f 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -212,6 +212,7 @@ operatorRoles :: UserProtocol p => SProtocolType p -> ServerOperator -> ServerRo operatorRoles p op = case p of SPSMP -> smpRoles op SPXFTP -> xftpRoles op + SPNTF -> ServerRoles{proxy = False, storage = False} conditionsAccepted :: ServerOperator -> Bool conditionsAccepted ServerOperator {conditionsAcceptance} = case conditionsAcceptance of @@ -221,14 +222,16 @@ conditionsAccepted ServerOperator {conditionsAcceptance} = case conditionsAccept data UserOperatorServers = UserOperatorServers { operator :: Maybe ServerOperator, smpServers :: [UserServer 'PSMP], - xftpServers :: [UserServer 'PXFTP] + xftpServers :: [UserServer 'PXFTP], + ntfServers :: [UserServer 'PNTF] } deriving (Show) data UpdatedUserOperatorServers = UpdatedUserOperatorServers { operator :: Maybe ServerOperator, smpServers :: [AUserServer 'PSMP], - xftpServers :: [AUserServer 'PXFTP] + xftpServers :: [AUserServer 'PXFTP], + ntfServers :: [AUserServer 'PNTF] } deriving (Show) @@ -245,17 +248,19 @@ instance UserServersClass UserOperatorServers where type AServer UserOperatorServers = UserServer' 'DBStored operator' UserOperatorServers {operator} = operator aUserServer' = AUS SDBStored - servers' p UserOperatorServers {smpServers, xftpServers} = case p of + servers' p UserOperatorServers {smpServers, xftpServers, ntfServers} = case p of SPSMP -> smpServers SPXFTP -> xftpServers + SPNTF -> ntfServers instance UserServersClass UpdatedUserOperatorServers where type AServer UpdatedUserOperatorServers = AUserServer operator' UpdatedUserOperatorServers {operator} = operator aUserServer' = id - servers' p UpdatedUserOperatorServers {smpServers, xftpServers} = case p of + servers' p UpdatedUserOperatorServers {smpServers, xftpServers, ntfServers} = case p of SPSMP -> smpServers SPXFTP -> xftpServers + SPNTF -> ntfServers type UserServer p = UserServer' 'DBStored p @@ -284,7 +289,9 @@ data PresetOperator = PresetOperator smp :: [NewUserServer 'PSMP], useSMP :: Int, xftp :: [NewUserServer 'PXFTP], - useXFTP :: Int + useXFTP :: Int, + ntf :: [NewUserServer 'PNTF], + useNTF :: Int } deriving (Show) @@ -292,14 +299,16 @@ pOperator :: PresetOperator -> Maybe NewServerOperator pOperator PresetOperator {operator} = operator pServers :: UserProtocol p => SProtocolType p -> PresetOperator -> [NewUserServer p] -pServers p PresetOperator {smp, xftp} = case p of +pServers p PresetOperator {smp, xftp, ntf} = case p of SPSMP -> smp SPXFTP -> xftp + SPNTF -> ntf operatorServersToUse :: UserProtocol p => SProtocolType p -> PresetOperator -> Int -operatorServersToUse p PresetOperator {useSMP, useXFTP} = case p of +operatorServersToUse p PresetOperator {useSMP, useXFTP, useNTF} = case p of SPSMP -> useSMP SPXFTP -> useXFTP + SPNTF -> useNTF presetServer' :: Bool -> ProtocolServer p -> NewUserServer p presetServer' enabled = presetServer enabled . (`ProtoServerWithAuth` Nothing) @@ -341,8 +350,8 @@ usageConditionsToAdd' prevCommit sourceCommit newUser createdAt = \case presetUserServers :: [(Maybe PresetOperator, Maybe ServerOperator)] -> [UpdatedUserOperatorServers] presetUserServers = mapMaybe $ \(presetOp_, op) -> mkUS op <$> presetOp_ where - mkUS op PresetOperator {smp, xftp} = - UpdatedUserOperatorServers op (map (AUS SDBNew) smp) (map (AUS SDBNew) xftp) + mkUS op PresetOperator {smp, xftp, ntf} = + UpdatedUserOperatorServers op (map (AUS SDBNew) smp) (map (AUS SDBNew) xftp) (map (AUS SDBNew) ntf) -- This function should be used inside DB transaction to update operators. -- It allows to add/remove/update preset operators in the database preserving enabled and roles settings, @@ -362,13 +371,13 @@ updatedServerOperators presetOps storedOps = -- This function should be used inside DB transaction to update servers. updatedUserServers :: (Maybe PresetOperator, UserOperatorServers) -> UpdatedUserOperatorServers -updatedUserServers (presetOp_, UserOperatorServers {operator, smpServers, xftpServers}) = - UpdatedUserOperatorServers {operator, smpServers = smp', xftpServers = xftp'} +updatedUserServers (presetOp_, UserOperatorServers {operator, smpServers, xftpServers, ntfServers}) = + UpdatedUserOperatorServers {operator, smpServers = smp', xftpServers = xftp', ntfServers = ntf'} where stored = map (AUS SDBStored) - (smp', xftp') = case presetOp_ of - Nothing -> (stored smpServers, stored xftpServers) - Just presetOp -> (updated SPSMP smpServers, updated SPXFTP xftpServers) + (smp', xftp', ntf') = case presetOp_ of + Nothing -> (stored smpServers, stored xftpServers, stored ntfServers) + Just presetOp -> (updated SPSMP smpServers, updated SPXFTP xftpServers, updated SPNTF ntfServers) where updated :: forall p. UserProtocol p => SProtocolType p -> [UserServer p] -> [AUserServer p] updated p srvs = map userServer presetSrvs <> stored (filter customServer srvs) @@ -419,34 +428,36 @@ instance Box ((,) (Maybe a)) where box = (Nothing,) unbox = snd -groupByOperator :: ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [UserOperatorServers] -groupByOperator (ops, smpSrvs, xftpSrvs) = map runIdentity <$> groupByOperator_ (map Identity ops, smpSrvs, xftpSrvs) +groupByOperator :: ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserServer 'PNTF]) -> IO [UserOperatorServers] +groupByOperator (ops, smpSrvs, xftpSrvs, ntfSrvs) = map runIdentity <$> groupByOperator_ (map Identity ops, smpSrvs, xftpSrvs, ntfSrvs) -- For the initial app start this function relies on tuple being Functor/Box -- to preserve the information about operator being DBNew or DBStored -groupByOperator' :: ([(Maybe PresetOperator, Maybe ServerOperator)], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [(Maybe PresetOperator, UserOperatorServers)] +groupByOperator' :: ([(Maybe PresetOperator, Maybe ServerOperator)], [UserServer 'PSMP], [UserServer 'PXFTP], [UserServer 'PNTF]) -> IO [(Maybe PresetOperator, UserOperatorServers)] groupByOperator' = groupByOperator_ {-# INLINE groupByOperator' #-} -groupByOperator_ :: forall f. (Box f, Traversable f) => ([f (Maybe ServerOperator)], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [f UserOperatorServers] -groupByOperator_ (ops, smpSrvs, xftpSrvs) = do +groupByOperator_ :: forall f. (Box f, Traversable f) => ([f (Maybe ServerOperator)], [UserServer 'PSMP], [UserServer 'PXFTP], [UserServer 'PNTF]) -> IO [f UserOperatorServers] +groupByOperator_ (ops, smpSrvs, xftpSrvs, ntfSrvs) = do let ops' = mapMaybe sequence ops customOp_ = find (isNothing . unbox) ops ss <- mapM ((\op -> (serverDomains (unbox op),) <$> newIORef (mkUS . Just <$> op))) ops' custom <- newIORef $ maybe (box $ mkUS Nothing) (mkUS <$>) customOp_ mapM_ (addServer ss custom addSMP) (reverse smpSrvs) mapM_ (addServer ss custom addXFTP) (reverse xftpSrvs) + mapM_ (addServer ss custom addNTF) (reverse ntfSrvs) opSrvs <- mapM (readIORef . snd) ss customSrvs <- readIORef custom pure $ opSrvs <> [customSrvs] where - mkUS op = UserOperatorServers op [] [] + mkUS op = UserOperatorServers op [] [] [] addServer :: [([Text], IORef (f UserOperatorServers))] -> IORef (f UserOperatorServers) -> (UserServer p -> UserOperatorServers -> UserOperatorServers) -> UserServer p -> IO () addServer ss custom add srv = let v = maybe custom snd $ find (\(ds, _) -> any (\d -> any (matchingHost d) (srvHost srv)) ds) ss in atomicModifyIORef'_ v (add srv <$>) addSMP srv s@UserOperatorServers {smpServers} = (s :: UserOperatorServers) {smpServers = srv : smpServers} addXFTP srv s@UserOperatorServers {xftpServers} = (s :: UserOperatorServers) {xftpServers = srv : xftpServers} + addNTF srv s@UserOperatorServers {ntfServers} = (s :: UserOperatorServers) {ntfServers = srv : ntfServers} data UserServersError = USENoServers {protocol :: AProtocolType, user :: Maybe User} diff --git a/src/Simplex/Chat/Operators/Presets.hs b/src/Simplex/Chat/Operators/Presets.hs index 06c2e19fab..6980ac627d 100644 --- a/src/Simplex/Chat/Operators/Presets.hs +++ b/src/Simplex/Chat/Operators/Presets.hs @@ -115,3 +115,6 @@ fluxXFTPServers = "xftp://Rh19D5e4Eez37DEE9hAlXDB3gZa1BdFYJTPgJWPO9OI=@xftp5.simplexonflux.com,q7itltdn32hjmgcqwhow4tay5ijetng3ur32bolssw32fvc5jrwvozad.onion", "xftp://0AznwoyfX8Od9T_acp1QeeKtxUi676IBIiQjXVwbdyU=@xftp6.simplexonflux.com,upvzf23ou6nrmaf3qgnhd6cn3d74tvivlmz3p7wdfwq6fhthjrjiiqid.onion" ] + +fluxNTFServers :: [NewUserServer 'PNTF] +fluxNTFServers = [] diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index ff2af56b85..30f96f59c9 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -32,7 +32,7 @@ import Simplex.FileTransfer.Description (mb) import Simplex.Messaging.Client (HostMode (..), SMPWebPortServers (..), SocksMode (..), textToHostMode) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (parseAll) -import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI, SMPServerWithAuth, XFTPServerWithAuth) +import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI, SMPServerWithAuth, XFTPServerWithAuth, NtfServerWithAuth) import Simplex.Messaging.Transport.Client (SocksProxyWithAuth (..), SocksAuth (..), defaultSocksProxyWithAuth) import Simplex.Chat.Options.DB @@ -56,6 +56,7 @@ data CoreChatOpts = CoreChatOpts { dbOptions :: ChatDbOpts, smpServers :: [SMPServerWithAuth], xftpServers :: [XFTPServerWithAuth], + ntfServers :: [NtfServerWithAuth], simpleNetCfg :: SimpleNetCfg, logLevel :: ChatLogLevel, logConnections :: Bool, @@ -107,6 +108,18 @@ coreChatOptsP appDir defaultDbName = do ) <> value [] ) + ntfServers <- + option + parseProtocolServers + ( long "ntf-server" + <> metavar "SERVER" + <> help + ( ("Space-separated list of NTF server(s) to use (each server can have more than one hostname)." <> "\n") + <> ("If you pass multiple servers, surround the entire list in quotes." <> "\n") + <> "Examples: ntf1.example.com, \"ntf1.example.com ntf2.example.com ntf3.example.com\"" + ) + <> value [] + ) socksProxy <- flag' (Just defaultSocksProxyWithAuth) (short 'x' <> help "Use local SOCKS5 proxy at :9050") <|> option @@ -241,6 +254,7 @@ coreChatOptsP appDir defaultDbName = do { dbOptions, smpServers, xftpServers, + ntfServers, simpleNetCfg = SimpleNetCfg { socksProxy, diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index a7dc154d9d..6678f85e53 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -624,12 +624,13 @@ getServerOperators db = do let conditionsAction = usageConditionsAction ops currentConditions now pure ServerOperatorConditions {serverOperators = ops, currentConditions, conditionsAction} -getUserServers :: DB.Connection -> User -> ExceptT StoreError IO ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) +getUserServers :: DB.Connection -> User -> ExceptT StoreError IO ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserServer 'PNTF]) getUserServers db user = - (,,) + (,,,) <$> (map Just . serverOperators <$> getServerOperators db) <*> liftIO (getProtocolServers db SPSMP user) <*> liftIO (getProtocolServers db SPXFTP user) + <*> liftIO (getProtocolServers db SPNTF user) setServerOperators :: DB.Connection -> NonEmpty ServerOperator -> IO () setServerOperators db ops = do @@ -842,11 +843,12 @@ setUserServers :: DB.Connection -> User -> UTCTime -> UpdatedUserOperatorServers setUserServers db user ts = checkConstraint SEUniqueID . liftIO . setUserServers' db user ts setUserServers' :: DB.Connection -> User -> UTCTime -> UpdatedUserOperatorServers -> IO UserOperatorServers -setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, smpServers, xftpServers} = do +setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, smpServers, xftpServers, ntfServers} = do mapM_ (updateServerOperator db ts) operator smpSrvs' <- catMaybes <$> mapM (upsertOrDelete SPSMP) smpServers xftpSrvs' <- catMaybes <$> mapM (upsertOrDelete SPXFTP) xftpServers - pure UserOperatorServers {operator, smpServers = smpSrvs', xftpServers = xftpSrvs'} + ntfSrvs' <- catMaybes <$> mapM (upsertOrDelete SPNTF) ntfServers + pure UserOperatorServers {operator, smpServers = smpSrvs', xftpServers = xftpSrvs', ntfServers = ntfSrvs'} where upsertOrDelete :: ProtocolTypeI p => SProtocolType p -> AUserServer p -> IO (Maybe (UserServer p)) upsertOrDelete p (AUS _ s@UserServer {serverId, deleted}) = case serverId of diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index e432343839..ed50b1a14b 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -50,10 +50,11 @@ terminalChatConfig = ], useSMP = 3, xftp = map (presetServer True) $ L.toList defaultXFTPServers, - useXFTP = 3 + useXFTP = 3, + ntf = map (presetServer True) $ L.toList _defaultNtfServers, + useNTF = 3 } ], - ntf = _defaultNtfServers, netCfg = defaultNetworkConfig { smpProxyMode = SPMUnknown, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index e582f072e5..41605e45d6 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -235,7 +235,6 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRNewMemberContactSentInv u _ct g m -> ttyUser u ["sent invitation to connect directly to member " <> ttyGroup' g <> " " <> ttyMember m] CRCallInvitations _ -> [] CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"] - CRNtfServers ntfServers -> viewNtfServers ntfServers CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode srv -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode) <> ", server: " <> sShow srv] CRNtfConns {ntfConns} -> map (\NtfConn {agentConnId, expectedMsg_} -> plain $ show agentConnId <> " " <> show expectedMsg_) ntfConns @@ -1390,11 +1389,12 @@ viewUserPrivacy User {userId} User {userId = userId', localDisplayName = n', sho ] viewUserServers :: UserOperatorServers -> [StyledString] -viewUserServers (UserOperatorServers _ [] []) = [] -viewUserServers UserOperatorServers {operator, smpServers, xftpServers} = +viewUserServers (UserOperatorServers _ [] [] []) = [] +viewUserServers UserOperatorServers {operator, smpServers, xftpServers, ntfServers} = [plain $ maybe "Your servers" shortViewOperator operator] <> viewServers SPSMP smpServers <> viewServers SPXFTP xftpServers + <> viewServers SPNTF ntfServers where viewServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [UserServer p] -> [StyledString] viewServers _ [] = [] @@ -2202,14 +2202,6 @@ viewVersionInfo logLevel CoreVersionInfo {version, simplexmqVersion, simplexmqCo parens :: (IsString a, Semigroup a) => a -> a parens s = " (" <> s <> ")" -viewNtfServers :: [SMP.NtfServer] -> [StyledString] -viewNtfServers = \case - [] -> ["No remote NTF server"] - s -> map viewNtfServer s - where - viewNtfServer s = - plain $ SMP.legacyStrEncodeServer s - viewRemoteHosts :: [RemoteHostInfo] -> [StyledString] viewRemoteHosts = \case [] -> ["No remote hosts"] From 7ebf707540c1b1eacfb9df39ed46361fbe0e1b54 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 31 Jul 2025 16:21:25 +0200 Subject: [PATCH 03/13] Allow users to set their NTF servers in server view --- .../chat/simplex/common/model/SimpleXAPI.kt | 33 ++++++++++++++ .../networkAndServers/NetworkAndServers.kt | 9 ++++ .../networkAndServers/NewServerView.kt | 5 ++- .../networkAndServers/OperatorView.kt | 7 ++- .../networkAndServers/ProtocolServersView.kt | 43 +++++++++++++++++-- .../commonMain/resources/MR/base/strings.xml | 4 ++ 6 files changed, 95 insertions(+), 6 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 7cb2d9fe5e..70d302cf10 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -3906,6 +3906,7 @@ class DBEncryptionConfig(val currentKey: String, val newKey: String) @Serializable enum class ServerProtocol { + @SerialName("ntf") NTF, @SerialName("smp") SMP, @SerialName("xftp") XFTP; } @@ -4023,6 +4024,12 @@ data class ServerOperator( val serverDomains: List, val conditionsAcceptance: ConditionsAcceptance, val enabled: Boolean, + /** + * Roles for ntf servers + * + * default to (true, true), for transition from old ServerOperator + */ + val ntfRoles: ServerRoles = ServerRoles(true, true), val smpRoles: ServerRoles, val xftpRoles: ServerRoles, ) { @@ -4044,6 +4051,7 @@ data class ServerOperator( serverDomains = listOf("simplex.im"), conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null, autoAccepted = false), enabled = true, + ntfRoles = ServerRoles(storage = true, proxy = true), smpRoles = ServerRoles(storage = true, proxy = true), xftpRoles = ServerRoles(storage = true, proxy = true) ) @@ -4061,6 +4069,7 @@ data class ServerOperator( other.serverDomains == this.serverDomains && other.conditionsAcceptance == this.conditionsAcceptance && other.enabled == this.enabled && + other.ntfRoles == this.ntfRoles && other.smpRoles == this.smpRoles && other.xftpRoles == this.xftpRoles } @@ -4073,6 +4082,7 @@ data class ServerOperator( result = 31 * result + serverDomains.hashCode() result = 31 * result + conditionsAcceptance.hashCode() result = 31 * result + enabled.hashCode() + result = 31 * result + ntfRoles.hashCode() result = 31 * result + smpRoles.hashCode() result = 31 * result + xftpRoles.hashCode() return result @@ -4111,6 +4121,7 @@ data class ServerRoles( @Serializable data class UserOperatorServers( val operator: ServerOperator?, + val ntfServers: List = listOf(), val smpServers: List, val xftpServers: List ) { @@ -4126,6 +4137,7 @@ data class UserOperatorServers( serverDomains = emptyList(), conditionsAcceptance = ConditionsAcceptance.Accepted(null, autoAccepted = false), enabled = false, + ntfRoles = ServerRoles(storage = true, proxy = true), smpRoles = ServerRoles(storage = true, proxy = true), xftpRoles = ServerRoles(storage = true, proxy = true) ) @@ -4133,12 +4145,14 @@ data class UserOperatorServers( companion object { val sampleData1 = UserOperatorServers( operator = ServerOperator.sampleData1, + ntfServers = listOf(), smpServers = listOf(UserServer.sampleData.preset), xftpServers = listOf(UserServer.sampleData.xftpPreset) ) val sampleDataNilOperator = UserOperatorServers( operator = null, + ntfServers = listOf(), smpServers = listOf(UserServer.sampleData.preset), xftpServers = listOf(UserServer.sampleData.xftpPreset) ) @@ -4154,6 +4168,7 @@ sealed class UserServersError { val globalError: String? get() = when (this.protocol_) { + ServerProtocol.NTF -> globalNTFError ServerProtocol.SMP -> globalSMPError ServerProtocol.XFTP -> globalXFTPError } @@ -4202,6 +4217,24 @@ sealed class UserServersError { null } + val globalNTFError: String? + get() = if (this.protocol_ == ServerProtocol.SMP) { + when (this) { + is NoServers -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_notif_servers_configured)}" } + ?: generalGetString(MR.strings.no_notif_servers_configured) + + is StorageMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_notif_servers_configured_for_receiving)}" } + ?: generalGetString(MR.strings.no_notif_servers_configured_for_receiving) + + is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_notif_servers_configured_for_receiving)}" } + ?: generalGetString(MR.strings.no_notif_servers_configured_for_receiving) + + else -> null + } + } else { + null + } + private fun userStr(user: UserRef): String { return String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt index 98f671ddc4..b658734ef7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt @@ -932,6 +932,15 @@ fun globalXFTPServersError(serverErrors: List): String? { return null } +fun globalNTFServersError(serverErrors: List): String? { + for (err in serverErrors) { + if (err.globalNTFError != null) { + return err.globalNTFError + } + } + return null +} + fun findDuplicateHosts(serverErrors: List): Set { val duplicateHostsList = serverErrors.mapNotNull { err -> if (err is UserServersError.DuplicateServer) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt index 1ec2534ab1..c2f659ba0f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt @@ -107,12 +107,15 @@ fun addServer( val operatorServers = updatedUserServers[operatorIndex] // Create a mutable copy of the smpServers or xftpServers and add the server when (serverProtocol) { + ServerProtocol.NTF -> { + // We use a single ntf server + updatedUserServers[operatorIndex] = operatorServers.copy(ntfServers = listOf(server)) + } ServerProtocol.SMP -> { val updatedSMPServers = operatorServers.smpServers.toMutableList() updatedSMPServers.add(server) updatedUserServers[operatorIndex] = operatorServers.copy(smpServers = updatedSMPServers) } - ServerProtocol.XFTP -> { val updatedXFTPServers = operatorServers.xftpServers.toMutableList() updatedXFTPServers.add(server) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index cc72387875..baa667bb8d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -387,14 +387,19 @@ fun OperatorViewLayout( testing = testing, smpServers = userServers.value[operatorIndex].smpServers, xftpServers = userServers.value[operatorIndex].xftpServers, + ntfServers = userServers.value[operatorIndex].ntfServers, ) { p, l -> when (p) { + ServerProtocol.NTF -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + ntfServers = l + ) + } ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { this[operatorIndex] = this[operatorIndex].copy( xftpServers = l ) } - ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { this[operatorIndex] = this[operatorIndex].copy( smpServers = l diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt index 63bf8b1dc4..4276df28a6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt @@ -148,7 +148,32 @@ fun YourServersViewLayout( } } + if (userServers.value[operatorIndex].ntfServers.any { !it.deleted }) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.notif_servers).uppercase()) { + userServers.value[operatorIndex].ntfServers.forEachIndexed { i, server -> + if (server.deleted) return@forEachIndexed + SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.NTF) }) { + ProtocolServerViewLink( + srv = server, + serverProtocol = ServerProtocol.NTF, + duplicateHosts = duplicateHosts + ) + } + } + } + val ntfErr = globalNTFServersError(serverErrors.value) + if (ntfErr != null) { + SectionCustomFooter { + ServersErrorFooter(ntfErr) + } + } else { + SectionTextFooter(generalGetString(MR.strings.ntf_servers_per_user)) + } + } + if ( + userServers.value[operatorIndex].ntfServers.any { !it.deleted } || userServers.value[operatorIndex].smpServers.any { !it.deleted } || userServers.value[operatorIndex].xftpServers.any { !it.deleted } ) { @@ -178,14 +203,19 @@ fun YourServersViewLayout( testing = testing, smpServers = userServers.value[operatorIndex].smpServers, xftpServers = userServers.value[operatorIndex].xftpServers, + ntfServers = userServers.value[operatorIndex].ntfServers, ) { p, l -> when (p) { + ServerProtocol.NTF -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + ntfServers = l + ) + } ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { this[operatorIndex] = this[operatorIndex].copy( xftpServers = l ) } - ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { this[operatorIndex] = this[operatorIndex].copy( smpServers = l @@ -204,16 +234,17 @@ fun YourServersViewLayout( fun TestServersButton( smpServers: List, xftpServers: List, + ntfServers: List, testing: MutableState, onUpdate: (ServerProtocol, List) -> Unit ) { val scope = rememberCoroutineScope() - val disabled = derivedStateOf { (smpServers.none { it.enabled } && xftpServers.none { it.enabled }) || testing.value } + val disabled = derivedStateOf { (smpServers.none { it.enabled } && xftpServers.none { it.enabled } && ntfServers.none { it.enabled }) || testing.value } SectionItemView( { scope.launch { - testServers(testing, smpServers, xftpServers, chatModel, onUpdate) + testServers(testing, smpServers, xftpServers, ntfServers, chatModel, onUpdate) } }, disabled = disabled.value @@ -303,6 +334,7 @@ private suspend fun testServers( testing: MutableState, smpServers: List, xftpServers: List, + ntfServers: List, m: ChatModel, onUpdate: (ServerProtocol, List) -> Unit ) { @@ -310,11 +342,14 @@ private suspend fun testServers( onUpdate(ServerProtocol.SMP, smpResetStatus) val xftpResetStatus = resetTestStatus(xftpServers) onUpdate(ServerProtocol.XFTP, xftpResetStatus) + val ntfResetStatus = resetTestStatus(ntfServers) + onUpdate(ServerProtocol.NTF, ntfResetStatus) testing.value = true val smpFailures = runServersTest(smpResetStatus, m) { onUpdate(ServerProtocol.SMP, it) } val xftpFailures = runServersTest(xftpResetStatus, m) { onUpdate(ServerProtocol.XFTP, it) } + val ntfFailures = runServersTest(ntfResetStatus, m) { onUpdate(ServerProtocol.NTF, it) } testing.value = false - val fs = smpFailures + xftpFailures + val fs = smpFailures + xftpFailures + ntfFailures if (fs.isNotEmpty()) { val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n") AlertManager.shared.showAlertMsg( diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index b5bf2efaff..e9922a0448 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -131,6 +131,8 @@ No media & file servers. No servers to send files. No servers to receive files. + No notification servers. + No servers to notify. For chat profile %s: Errors in servers configuration. Error accepting conditions @@ -898,6 +900,7 @@ The servers for new connections of your current chat profile Save servers? Media & file servers + Notifications servers XFTP servers Configured XFTP servers Other XFTP servers @@ -1921,6 +1924,7 @@ Use for files To send The servers for new files of your current chat profile + The servers for your notifications. Added media & file servers Open conditions Open changes From 5a311609d1a88f0bf03fe524bc60386a45559511 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 1 Aug 2025 15:15:31 +0200 Subject: [PATCH 04/13] Add UI to select Instant notifications Prepare for UnifiedPush support --- apps/multiplatform/android/build.gradle.kts | 5 + .../main/java/chat/simplex/app/SimplexApp.kt | 12 + .../java/chat/simplex/app/SimplexService.kt | 5 + .../chat/simplex/app/platform/PushManager.kt | 267 ++++++++++++++++++ .../chat/simplex/common/helpers/Extensions.kt | 2 +- .../chat/simplex/common/model/SimpleXAPI.kt | 3 +- .../chat/simplex/common/platform/Platform.kt | 1 + .../usersettings/NotificationsSettingsView.kt | 17 +- .../commonMain/resources/MR/base/strings.xml | 6 + 9 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 8fc9d104d2..c3769d5ea6 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -144,6 +144,11 @@ dependencies { //Camera Permission implementation("com.google.accompanist:accompanist-permissions:0.34.0") + // Push notifications + implementation("org.unifiedpush.android:connector:3.0.10") + // Allow using Play Services if available + implementation("org.unifiedpush.android:embedded-fcm-distributor:3.0.0") + //implementation("androidx.compose.material:material-icons-extended:$compose_version") //implementation("androidx.compose.ui:ui-util:$compose_version") diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 5545595dc6..6e668c441e 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -236,6 +236,16 @@ class SimplexApp: Application(), LifecycleEventObserver { if (mode != NotificationsMode.PERIODIC) { MessagesFetcherWorker.cancelAll() } + if (mode == NotificationsMode.INSTANT) { + CoroutineScope(Dispatchers.Default).launch { + SimplexService.initUnifiedPush(this) { + // Change notifications mode only if everything is correctly setup + chatModel.controller.appPrefs.notificationsMode.set(mode) + } + } + } else { + chatModel.controller.appPrefs.notificationsMode.set(mode) + } SimplexService.showBackgroundServiceNoticeIfNeeded(showOffAlert = false) } @@ -245,6 +255,7 @@ class SimplexApp: Application(), LifecycleEventObserver { NotificationsMode.SERVICE -> CoroutineScope(Dispatchers.Default).launch { platform.androidServiceStart() } NotificationsMode.PERIODIC -> SimplexApp.context.schedulePeriodicWakeUp() NotificationsMode.OFF -> {} + NotificationsMode.INSTANT -> {} } } @@ -369,6 +380,7 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidCreateActiveCallState(): Closeable = ActiveCallState() override val androidApiLevel: Int get() = Build.VERSION.SDK_INT + override val supportsPushNotifications = true } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt index ad86759ba9..6a1013c2f9 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt @@ -22,6 +22,7 @@ import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import androidx.work.* import chat.simplex.app.model.NtfManager +import chat.simplex.app.platform.PushManager import chat.simplex.common.AppLock import chat.simplex.common.helpers.requiresIgnoringBattery import chat.simplex.common.model.ChatController @@ -419,6 +420,10 @@ class SimplexService: Service() { private fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE) + suspend fun initUnifiedPush(scope: CoroutineScope, onSuccess: () -> Unit) { + PushManager.initUnifiedPush(androidAppContext, scope, onSuccess) + } + fun showBackgroundServiceNoticeIfNeeded(showOffAlert: Boolean = true) { val appPrefs = ChatController.appPrefs val mode = appPrefs.notificationsMode.get() diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt new file mode 100644 index 0000000000..2c94373073 --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt @@ -0,0 +1,267 @@ +package chat.simplex.app.platform + +import SectionItemView +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.getUserServers +import chat.simplex.common.platform.Log +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.networkAndServers.showAddServerDialog +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.* +import org.unifiedpush.android.connector.UnifiedPush + +/** + * Object with functions to interact with push services + */ +object PushManager { + private const val TAG = "PushManager" + + /** + * Check if a NTF server is setup and ask user's push distributor + * + * Show an alert if NTF server isn't define, + * If a single distrib is available, use it + * If many, ask distributor to use with a dialog + * Else alert about missing service + */ + suspend fun initUnifiedPush(context: Context, scope: CoroutineScope, onSuccess: () -> Unit) { + val userServers = getUserServers(null) ?: listOf() + if (!userServers.hasNtfServer()) { + Log.d(TAG, "User doesn't have any NTF server") + showMissingNTFDialog(scope, userServers) + // After coming back from the server view, users will have to click on "Instant" again + return + } + val distributors = UnifiedPush.getDistributors(context) + when (distributors.size) { + 0 -> { + Log.d(TAG, "No distributor found") + showMissingPushServiceDialog() + } + 1 -> { + Log.d(TAG, "Found only one distributor installed") + UnifiedPush.saveDistributor(context, distributors.first()) + register(context) + onSuccess() + } + else -> { + Log.d(TAG, "Found many distributors installed") + showSelectPushServiceIntroDialog { + showSelectPushServiceDialog(context, distributors) { + UnifiedPush.saveDistributor(context, it) + register(context) + onSuccess + } + } + } + } + } + + private fun register(context: Context) { + // TODO: add VAPID + UnifiedPush.register(context) + } + + /** + * Check if any NTF server is recorded + */ + private fun List.hasNtfServer(): Boolean { + // TODO: check if ntf server has a VAPID key + return this.any { it.ntfServers.any() } + } + + /** + * Show a dialog to inform about missing NTF server + */ + private fun showMissingNTFDialog(scope: CoroutineScope, userServers: List) = AlertManager.shared.showAlert { + AlertDialog( + onDismissRequest = AlertManager.shared::hideAlert, + title = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + painterResource(MR.images.ic_warning), + contentDescription = null, // The icon doesn't add any meaning and must not be transcripted with screen readers + ) + Text( + stringResource(MR.strings.icon_descr_instant_notifications), + fontWeight = FontWeight.Bold + ) + } + }, + text = { + Text(stringResource(MR.strings.warning_push_needs_ntf_server)) + }, + // Go to user's servers settings + confirmButton = { + TextButton(onClick = { + AlertManager.shared.hideAlert() + showAddServerDialog(scope, userServers) + }) { Text(stringResource(MR.strings.smp_servers_add)) } + }, + // Ignore + dismissButton = { + TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(android.R.string.cancel)) } + }, + shape = RoundedCornerShape(corner = CornerSize(25.dp)) + ) + } + + /** + * Dialog to add a server, manually or with a QR code + */ + private fun showAddServerDialog(scope: CoroutineScope, userServers: List) { + val userServersState = mutableStateOf(userServers) + val serverErrors = mutableStateOf(listOf()) + showAddServerDialog(scope, userServersState, serverErrors, null) + } + + /** + * Show a dialog to inform about missing push service + */ + private fun showMissingPushServiceDialog() = AlertManager.shared.showAlert { + AlertDialog( + onDismissRequest = AlertManager.shared::hideAlert, + title = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + painterResource(MR.images.ic_warning), + contentDescription = null, // The icon doesn't add any meaning and must not be transcripted with screen readers + ) + Text( + stringResource(MR.strings.icon_descr_instant_notifications), + fontWeight = FontWeight.Bold + ) + } + }, + text = { + Text( + buildAnnotatedString { + append(stringResource(MR.strings.warning_push_needs_push_service)) + withLink(LinkAnnotation.Url(url = "https://unifiedpush.org")) { + withStyle(style = SpanStyle(color = MaterialTheme.colors.primary)) { + append("https://unifiedpush.org") + } + } + } + ) + }, + confirmButton = { + TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(android.R.string.ok)) } + }, + shape = RoundedCornerShape(corner = CornerSize(25.dp)) + ) + } + + /** + * Show an intro to explain why they need to select a push service + */ + private fun showSelectPushServiceIntroDialog(onConfirm: () -> Unit) = AlertManager.shared.showAlert { + AlertDialog( + onDismissRequest = AlertManager.shared::hideAlert, + title = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + painterResource(MR.images.ic_notifications), + contentDescription = null, // The icon doesn't add any meaning and must not be transcripted with screen readers + ) + Text( + stringResource(MR.strings.icon_descr_instant_notifications), + fontWeight = FontWeight.Bold + ) + } + }, + text = { + Text(stringResource(MR.strings.select_push_service_intro)) + }, + confirmButton = { + TextButton( + onClick = { + AlertManager.shared.hideAlert() + onConfirm() + } + ) { Text(stringResource(MR.strings.select_push_service)) } + }, + dismissButton = { + TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(android.R.string.cancel)) } + }, + shape = RoundedCornerShape(corner = CornerSize(25.dp)) + ) + } + + + /** + * Show a dialog to select push service + * + * @param distributors List of installed distributors' packageName + * @param onSelected run when a distributor is selected, its param is the selected distrib packageName + */ + private fun showSelectPushServiceDialog(context: Context, distributors: List, onSelected: (String) -> Unit) = AlertManager.shared.showAlert { + AlertDialog( + onDismissRequest = AlertManager.shared::hideAlert, + title = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + painterResource(MR.images.ic_notifications), + contentDescription = null, // The icon doesn't add any meaning and must not be transcripted with screen readers + ) + Text( + stringResource(MR.strings.select_push_service), + fontWeight = FontWeight.Bold + ) + } + }, + text = { + Column { + distributors.map { + if (it == context.packageName) it to "Play Services" + else it to context.getApplicationName(it) + }.forEach { + SectionItemView({ + AlertManager.shared.hideAlert() + onSelected(it.first) + }) { + Text(it.second) + } + } + } + }, + confirmButton = { + TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(android.R.string.cancel)) } + }, + shape = RoundedCornerShape(corner = CornerSize(25.dp)) + ) + } + + private fun Context.getApplicationName(applicationId: String): String { + return try { + val ai = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getApplicationInfo( + applicationId, + PackageManager.ApplicationInfoFlags.of( + PackageManager.GET_META_DATA.toLong() + ) + ) + } else { + packageManager.getApplicationInfo(applicationId, 0) + } + packageManager.getApplicationLabel(ai) + } catch (e: PackageManager.NameNotFoundException) { + applicationId + } as String + } + +} \ No newline at end of file diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Extensions.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Extensions.kt index 540533e5ad..6ddf0c35ba 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Extensions.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Extensions.kt @@ -9,7 +9,7 @@ val NotificationsMode.requiresIgnoringBatterySinceSdk: Int get() = when(this) { NotificationsMode.OFF -> Int.MAX_VALUE NotificationsMode.PERIODIC -> Build.VERSION_CODES.M NotificationsMode.SERVICE -> Build.VERSION_CODES.S - /*INSTANT -> Int.MAX_VALUE - for Firebase notifications */ + NotificationsMode.INSTANT -> Int.MAX_VALUE } val NotificationsMode.requiresIgnoringBattery diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 70d302cf10..646004db68 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -7323,7 +7323,7 @@ sealed class RemoteCtrlError { } enum class NotificationsMode() { - OFF, PERIODIC, SERVICE, /*INSTANT - for Firebase notifications */; + OFF, PERIODIC, SERVICE, INSTANT; companion object { val default: NotificationsMode = SERVICE @@ -7550,6 +7550,7 @@ enum class AppSettingsNotificationMode { companion object { fun from(mode: NotificationsMode): AppSettingsNotificationMode = when (mode) { + NotificationsMode.INSTANT -> INSTANT NotificationsMode.SERVICE -> INSTANT NotificationsMode.PERIODIC -> PERIODIC NotificationsMode.OFF -> OFF diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index 448100bc17..47d9b5a1ad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -33,6 +33,7 @@ interface PlatformInterface { @Composable fun androidLockPortraitOrientation() {} suspend fun androidAskToAllowBackgroundCalls(): Boolean = true @Composable fun desktopShowAppUpdateNotice() {} + val supportsPushNotifications: Boolean get() = false } /** * Multiplatform project has separate directories per platform + common directory that contains directories per platform + common for all of them. diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt index 5af5d5fb90..d04a9da3b4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt @@ -136,6 +136,15 @@ private fun notificationModes(): List> { AnnotatedString(generalGetString(MR.strings.notifications_mode_service_desc)), ) ) + if (platform.supportsPushNotifications) { + res.add( + ValueTitleDesc( + NotificationsMode.INSTANT, + generalGetString(MR.strings.notifications_mode_instant), + AnnotatedString(generalGetString(MR.strings.notifications_mode_instant_desc)) + ) + ) + } return res } @@ -167,6 +176,10 @@ fun notificationPreviewModes(): List> { } fun changeNotificationsMode(mode: NotificationsMode, chatModel: ChatModel) { - chatModel.controller.appPrefs.notificationsMode.set(mode) - platform.androidNotificationsModeChanged(mode) + // the preference is updated in androidNotificationsModeChanged for Android + if (appPlatform.isAndroid) { + platform.androidNotificationsModeChanged(mode) + } else { + chatModel.controller.appPrefs.notificationsMode.set(mode) + } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index e9922a0448..cbd701f907 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -260,9 +260,11 @@ Runs when app is open Starts periodically Always on + Instant App can receive notifications only when it\'s running, no background service will be started Checks new messages every 10 minutes for up to 1 minute Background service is always running – notifications will be shown as soon as the messages are available. + Receive notifications instantly using an external push service. Message text Contact name Hidden @@ -274,6 +276,10 @@ New contact request Connected Error showing notification, contact developers. + You first need to set a NTF server to get push notifications.\n\nIt will send the notifications to your push provider. + You don\'t have any push service installed on your device.\n\nPlease installed one and try again.\n\nFor more information, visit\ + Select Push Service + Multiple push services are installed on your system, please select the service you wish to use. SimpleX Lock From 6ab0f3222a1ac9d1e1b61b56aaae87aa61afdac2 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 5 Aug 2025 15:39:32 +0200 Subject: [PATCH 05/13] Add commands to verify and deleted saved ntf token --- src/Simplex/Chat/Controller.hs | 2 ++ src/Simplex/Chat/Library/Commands.hs | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index aff6b3f6dc..5a3d7a2a59 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -353,8 +353,10 @@ data ChatCommand | APIGetNtfToken | APIRegisterToken DeviceToken NotificationsMode | APIVerifyToken DeviceToken C.CbNonce ByteString + | APIVerifySavedToken ByteString | APICheckToken DeviceToken | APIDeleteToken DeviceToken + | APIDeleteSavedToken | APIGetNtfConns {nonce :: C.CbNonce, encNtfInfo :: ByteString} | APIGetConnNtfMessages (NonEmpty ConnMsgReq) | APIAddMember GroupId ContactId GroupMemberRole diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 8e5a7dc8e0..3e6673a37c 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1309,9 +1309,11 @@ processChatCommand' vr = \case APIRegisterToken token mode -> withUser' $ \_u@User {userId} -> CRNtfTokenStatus <$> withAgent (\a -> registerNtfToken a userId token mode) APIVerifyToken token nonce code -> withUser $ \_u@User {userId} -> withAgent (\a -> verifyNtfToken a userId token nonce code) >> ok_ + APIVerifySavedToken code -> withUser $ \_u@User {userId} -> withAgent (\a -> verifySavedNtfToken a userId code) >> ok_ APICheckToken token -> withUser $ \_ -> CRNtfTokenStatus <$> withAgent (`checkNtfToken` token) APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) >> ok_ + APIDeleteSavedToken -> withUser $ \_ -> withAgent deleteSavedNtfToken >> ok_ APIGetNtfConns nonce encNtfInfo -> withUser $ \_ -> do ntfInfos <- withAgent $ \a -> getNotificationConns a nonce encNtfInfo (errs, ntfMsgs) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (getMsgConn db) (L.toList ntfInfos)) @@ -4109,8 +4111,10 @@ chatCommandP = "/_ntf get" $> APIGetNtfToken, "/_ntf register " *> (APIRegisterToken <$> strP_ <*> strP), "/_ntf verify " *> (APIVerifyToken <$> strP <* A.space <*> strP <* A.space <*> strP), + "/_ntf verify " *> (APIVerifySavedToken <$> strP), "/_ntf check " *> (APICheckToken <$> strP), "/_ntf delete " *> (APIDeleteToken <$> strP), + "/_ntf delete saved" $> APIDeleteSavedToken, "/_ntf conns " *> (APIGetNtfConns <$> strP <* A.space <*> strP), "/_ntf conn messages " *> (APIGetConnNtfMessages <$> connMsgsP), "/_add #" *> (APIAddMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole), From 9305acc3f13d26160d353eeccdee3f901e7695d2 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 1 Aug 2025 16:23:27 +0200 Subject: [PATCH 06/13] Add support for Push notifications --- .../android/src/main/AndroidManifest.xml | 7 ++ .../main/java/chat/simplex/app/PushService.kt | 82 +++++++++++++++++++ .../chat/simplex/app/platform/PushManager.kt | 18 ++-- .../chat/simplex/common/model/SimpleXAPI.kt | 13 ++- .../networkAndServers/OperatorView.kt | 31 ++++--- .../networkAndServers/ProtocolServerView.kt | 6 +- .../networkAndServers/ProtocolServersView.kt | 26 ++++++ .../commonMain/resources/MR/base/strings.xml | 1 + cabal.project | 9 +- scripts/nix/sha256map.nix | 1 + 10 files changed, 165 insertions(+), 29 deletions(-) create mode 100644 apps/multiplatform/android/src/main/java/chat/simplex/app/PushService.kt diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index 0470977bcd..96322c0a4f 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -185,6 +185,13 @@ android:foregroundServiceType="mediaPlayback|microphone|camera|remoteMessaging" /> + + + + + + onVerification(pn.verification) + } + // TODO: Start same job than the periodic service ? + // Receiving the push notif is enough to wake the app and fetch msgs + // But it may not be enough when the phone is in doze, or with some + // vendors + } + + override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { + Log.d(TAG, "onNewEndpoint") + endpoint.pubKeySet ?: run { + // Should not happen + Log.w(TAG, "Missing pubKeySet") + return + } + CoroutineScope(Dispatchers.Default).launch { + chatModel.controller.sendCmd( + null, + CC.APIRegisterWebPush(endpoint.url, endpoint.pubKeySet!!.auth, endpoint.pubKeySet!!.pubKey), + log = true + ) + } + } + + override fun onRegistrationFailed(reason: FailedReason, instance: String) { + Log.d(TAG, "onRegistrationFailed: $reason") + // TODO: notification to inform about failed registration + } + + override fun onUnregistered(instance: String) { + Log.d(TAG, "onUnregistered") + // TODO: notification to inform about unregistration + CoroutineScope(Dispatchers.Default).launch { + chatModel.controller.sendCmd( + null, + CC.APIDeleteSavedNtf(), + log = true + ) + } + } + + companion object { + private const val TAG = "PushService" + } +} \ No newline at end of file diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt index 2c94373073..a96c4a435f 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.getUserServers import chat.simplex.common.platform.Log +import chat.simplex.common.platform.chatModel import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.networkAndServers.showAddServerDialog import chat.simplex.res.MR @@ -39,10 +40,11 @@ object PushManager { * Else alert about missing service */ suspend fun initUnifiedPush(context: Context, scope: CoroutineScope, onSuccess: () -> Unit) { - val userServers = getUserServers(null) ?: listOf() + val rh = chatModel.remoteHostId() + val userServers = getUserServers(rh) ?: listOf() if (!userServers.hasNtfServer()) { Log.d(TAG, "User doesn't have any NTF server") - showMissingNTFDialog(scope, userServers) + showMissingNTFDialog(scope, rh, userServers) // After coming back from the server view, users will have to click on "Instant" again return } @@ -64,7 +66,7 @@ object PushManager { showSelectPushServiceDialog(context, distributors) { UnifiedPush.saveDistributor(context, it) register(context) - onSuccess + onSuccess() } } } @@ -81,13 +83,13 @@ object PushManager { */ private fun List.hasNtfServer(): Boolean { // TODO: check if ntf server has a VAPID key - return this.any { it.ntfServers.any() } + return this.any { it.ntfServers.any { s -> s.enabled } } } /** * Show a dialog to inform about missing NTF server */ - private fun showMissingNTFDialog(scope: CoroutineScope, userServers: List) = AlertManager.shared.showAlert { + private fun showMissingNTFDialog(scope: CoroutineScope, rh: Long?, userServers: List) = AlertManager.shared.showAlert { AlertDialog( onDismissRequest = AlertManager.shared::hideAlert, title = { @@ -109,7 +111,7 @@ object PushManager { confirmButton = { TextButton(onClick = { AlertManager.shared.hideAlert() - showAddServerDialog(scope, userServers) + showAddServerDialog(scope, rh, userServers) }) { Text(stringResource(MR.strings.smp_servers_add)) } }, // Ignore @@ -123,10 +125,10 @@ object PushManager { /** * Dialog to add a server, manually or with a QR code */ - private fun showAddServerDialog(scope: CoroutineScope, userServers: List) { + private fun showAddServerDialog(scope: CoroutineScope, rh: Long?, userServers: List) { val userServersState = mutableStateOf(userServers) val serverErrors = mutableStateOf(listOf()) - showAddServerDialog(scope, userServersState, serverErrors, null) + showAddServerDialog(scope, userServersState, serverErrors, rh) } /** diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 646004db68..a7badd03a4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -3464,6 +3464,9 @@ sealed class CC { class ResetAgentServersStats(): CC() class GetAgentSubsTotal(val userId: Long): CC() class GetAgentServersSummary(val userId: Long): CC() + class APIRegisterWebPush(val endpoint: String, val auth: String, val p256dh: String): CC() + class APIVerifySavedNtf(val code: String): CC() + class APIDeleteSavedNtf(): CC() val cmdString: String get() = when (this) { is Console -> cmd @@ -3656,6 +3659,9 @@ sealed class CC { is ResetAgentServersStats -> "/reset servers stats" is GetAgentSubsTotal -> "/get subs total $userId" is GetAgentServersSummary -> "/get servers summary $userId" + is APIRegisterWebPush -> "/_ntf register webpush $endpoint $auth $p256dh INSTANT" + is APIDeleteSavedNtf -> "/_ntf delete saved" + is APIVerifySavedNtf -> "/_ntf verify $code" } val cmdType: String get() = when (this) { @@ -3814,6 +3820,9 @@ sealed class CC { is ResetAgentServersStats -> "resetAgentServersStats" is GetAgentSubsTotal -> "getAgentSubsTotal" is GetAgentServersSummary -> "getAgentServersSummary" + is APIRegisterWebPush -> "apiRegisterWebPush" + is APIDeleteSavedNtf -> "apiDeleteSavedNtf" + is APIVerifySavedNtf -> "apiVerifySavedNtf" } data class ItemRange(val from: Long, val to: Long) @@ -6952,7 +6961,7 @@ sealed class AgentErrorType { is CMD -> "CMD ${cmdErr.string} $errContext" is CONN -> "CONN ${connErr.string}" is SMP -> "SMP ${smpErr.string}" - // is NTF -> "NTF ${ntfErr.string}" + is NTF -> "NTF ${ntfErr.string}" is XFTP -> "XFTP ${xftpErr.string}" is PROXY -> "PROXY $proxyServer $relayServer ${proxyErr.string}" is RCP -> "RCP ${rcpErr.string}" @@ -6965,7 +6974,7 @@ sealed class AgentErrorType { @Serializable @SerialName("CMD") class CMD(val cmdErr: CommandErrorType, val errContext: String): AgentErrorType() @Serializable @SerialName("CONN") class CONN(val connErr: ConnectionErrorType): AgentErrorType() @Serializable @SerialName("SMP") class SMP(val serverAddress: String, val smpErr: SMPErrorType): AgentErrorType() - // @Serializable @SerialName("NTF") class NTF(val ntfErr: SMPErrorType): AgentErrorType() + @Serializable @SerialName("NTF") class NTF(val ntfErr: SMPErrorType): AgentErrorType() @Serializable @SerialName("XFTP") class XFTP(val xftpErr: XFTPErrorType): AgentErrorType() @Serializable @SerialName("PROXY") class PROXY(val proxyServer: String, val relayServer: String, val proxyErr: ProxyClientError): AgentErrorType() @Serializable @SerialName("RCP") class RCP(val rcpErr: RCErrorType): AgentErrorType() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index baa667bb8d..3220f66868 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -101,27 +101,26 @@ fun navigateToProtocolView( userServers = userServers, serverErrors = serverErrors, onDelete = { - if (protocol == ServerProtocol.SMP) { - deleteSMPServer(userServers, operatorIndex, serverIndex) - } else { - deleteXFTPServer(userServers, operatorIndex, serverIndex) + when (protocol) { + ServerProtocol.NTF -> deleteNTFServer(userServers, operatorIndex, serverIndex) + ServerProtocol.SMP -> deleteSMPServer(userServers, operatorIndex, serverIndex) + ServerProtocol.XFTP -> deleteXFTPServer(userServers, operatorIndex, serverIndex) } close() }, onUpdate = { updatedServer -> userServers.value = userServers.value.toMutableList().apply { - this[operatorIndex] = this[operatorIndex].copy( - smpServers = if (protocol == ServerProtocol.SMP) { - this[operatorIndex].smpServers.toMutableList().apply { - this[serverIndex] = updatedServer - } - } else this[operatorIndex].smpServers, - xftpServers = if (protocol == ServerProtocol.XFTP) { - this[operatorIndex].xftpServers.toMutableList().apply { - this[serverIndex] = updatedServer - } - } else this[operatorIndex].xftpServers - ) + if (platform.supportsPushNotifications && protocol == ServerProtocol.NTF && updatedServer.enabled) { + // We keep a single ntf server, if the updatedServer is enabled, we disable all other ntf servers first + this.replaceAll { op -> + op.copy(ntfServers = op.ntfServers.map { server -> server.copy(enabled = false).also { s -> Log.d(TAG, "ntf: $s")} }) + } + } + this[operatorIndex] = when (protocol) { + ServerProtocol.NTF -> this[operatorIndex].copy(ntfServers = this[operatorIndex].ntfServers.toMutableList().apply { this[serverIndex] = updatedServer }) + ServerProtocol.SMP -> this[operatorIndex].copy(smpServers = this[operatorIndex].smpServers.toMutableList().apply { this[serverIndex] = updatedServer }) + ServerProtocol.XFTP -> this[operatorIndex].copy(xftpServers = this[operatorIndex].xftpServers.toMutableList().apply { this[serverIndex] = updatedServer }) + } } }, close = close, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt index bebc96a28c..9f5ebb34b2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt @@ -115,7 +115,11 @@ private fun ProtocolServerLayout( onDelete: () -> Unit, ) { ColumnWithScrollBar { - AppBarTitle(stringResource(if (serverProtocol == ServerProtocol.XFTP) MR.strings.xftp_server else MR.strings.smp_server)) + AppBarTitle(stringResource(when (serverProtocol) { + ServerProtocol.NTF -> MR.strings.ntf_server + ServerProtocol.XFTP -> MR.strings.xftp_server + ServerProtocol.SMP -> MR.strings.smp_server + })) if (server.value.preset) { PresetServer(server, testing, testServer) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt index 4276df28a6..9d3d307fc8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt @@ -389,6 +389,32 @@ private suspend fun runServersTest(servers: List, m: ChatModel, onUp return fs } +fun deleteNTFServer( + userServers: MutableState>, + operatorServersIndex: Int, + serverIndex: Int +) { + val serverIsSaved = userServers.value[operatorServersIndex].ntfServers[serverIndex].serverId != null + + if (serverIsSaved) { + userServers.value = userServers.value.toMutableList().apply { + this[operatorServersIndex] = this[operatorServersIndex].copy( + ntfServers = this[operatorServersIndex].ntfServers.toMutableList().apply { + this[serverIndex] = this[serverIndex].copy(deleted = true) + } + ) + } + } else { + userServers.value = userServers.value.toMutableList().apply { + this[operatorServersIndex] = this[operatorServersIndex].copy( + ntfServers = this[operatorServersIndex].ntfServers.toMutableList().apply { + this.removeAt(serverIndex) + } + ) + } + } +} + fun deleteXFTPServer( userServers: MutableState>, operatorServersIndex: Int, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index cbd701f907..39daacf355 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2642,6 +2642,7 @@ Starting from %s. SMP server XFTP server + NTF server Reconnect attempts Sent directly diff --git a/cabal.project b/cabal.project index 48b75a86cd..9b82856f8e 100644 --- a/cabal.project +++ b/cabal.project @@ -11,8 +11,13 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git - location: https://github.com/simplex-chat/simplexmq.git - tag: d352d518c2b3a42bc7a298954dde799422e1457f + location: https://codeberg.org/s1m/sxmq.git + tag: f5720a254104d70b33ac1479ffa9d24ba9988b59 + +-- source-repository-package +-- type: git +-- location: https://github.com/simplex-chat/simplexmq.git +-- tag: d352d518c2b3a42bc7a298954dde799422e1457f source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 68a6054ef0..ffdf834552 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,4 +1,5 @@ { + "https://codeberg.org/s1m/sxmq.git"."f5720a254104d70b33ac1479ffa9d24ba9988b59" = "cf9a74de0d05afa60a74aa8aa54205c718286b9c16c5f0f398a24d7fa7f7b1ff"; "https://github.com/simplex-chat/simplexmq.git"."d352d518c2b3a42bc7a298954dde799422e1457f" = "1rha84pfpaqx3mf218szkfra334vhijqf17hanxqmp1sicfbf1x3"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; From 88be23f139d5a681da1016841c67b2f6cac215fb Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 5 Aug 2025 15:52:48 +0200 Subject: [PATCH 07/13] Do not show NTF servers if not supported by platform --- .../networkAndServers/ProtocolServersView.kt | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt index 9d3d307fc8..0c39bd372f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt @@ -148,38 +148,41 @@ fun YourServersViewLayout( } } - if (userServers.value[operatorIndex].ntfServers.any { !it.deleted }) { - SectionDividerSpaced() - SectionView(generalGetString(MR.strings.notif_servers).uppercase()) { - userServers.value[operatorIndex].ntfServers.forEachIndexed { i, server -> - if (server.deleted) return@forEachIndexed - SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.NTF) }) { - ProtocolServerViewLink( - srv = server, - serverProtocol = ServerProtocol.NTF, - duplicateHosts = duplicateHosts - ) + if (platform.supportsPushNotifications) { + if (userServers.value[operatorIndex].ntfServers.any { !it.deleted }) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.notif_servers).uppercase()) { + userServers.value[operatorIndex].ntfServers.forEachIndexed { i, server -> + if (server.deleted) return@forEachIndexed + SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.NTF) }) { + ProtocolServerViewLink( + srv = server, + serverProtocol = ServerProtocol.NTF, + duplicateHosts = duplicateHosts + ) + } } } - } - val ntfErr = globalNTFServersError(serverErrors.value) - if (ntfErr != null) { - SectionCustomFooter { - ServersErrorFooter(ntfErr) + val ntfErr = globalNTFServersError(serverErrors.value) + if (ntfErr != null) { + SectionCustomFooter { + ServersErrorFooter(ntfErr) + } + } else { + SectionTextFooter(generalGetString(MR.strings.ntf_servers_per_user)) } - } else { - SectionTextFooter(generalGetString(MR.strings.ntf_servers_per_user)) } } if ( - userServers.value[operatorIndex].ntfServers.any { !it.deleted } || + userServers.value[operatorIndex].ntfServers.any { !it.deleted } && platform.supportsPushNotifications || userServers.value[operatorIndex].smpServers.any { !it.deleted } || userServers.value[operatorIndex].xftpServers.any { !it.deleted } ) { SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) } + SectionView { SettingsActionItem( painterResource(MR.images.ic_add), From 44a611314aac6da06fe7560c48bf7dc17d8674ff Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 5 Aug 2025 16:08:37 +0200 Subject: [PATCH 08/13] Register for push notifications on app startup --- .../src/main/java/chat/simplex/app/SimplexApp.kt | 2 ++ .../java/chat/simplex/app/platform/PushManager.kt | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 6e668c441e..b4b8247df2 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -20,6 +20,7 @@ import androidx.work.* import chat.simplex.app.MainActivity.Companion.OLD_ANDROID_UI_FLAGS import chat.simplex.app.model.NtfManager import chat.simplex.app.model.NtfManager.AcceptCallAction +import chat.simplex.app.platform.PushManager import chat.simplex.app.views.call.CallActivity import chat.simplex.common.helpers.* import chat.simplex.common.model.* @@ -269,6 +270,7 @@ class SimplexApp: Application(), LifecycleEventObserver { // Prevents from showing "Enable notifications" alert when onboarding wasn't complete yet if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) { SimplexService.showBackgroundServiceNoticeIfNeeded() + PushManager.initStart(context) if (appPrefs.notificationsMode.get() == NotificationsMode.SERVICE) withBGApi { platform.androidServiceStart() diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt index a96c4a435f..f1534cb1db 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt @@ -73,6 +73,20 @@ object PushManager { } } + /** + * Register to UnifiedPush distributor if any is already used + * + * To run when the app starts; call [chat.simplex.app.PushService.onUnregistered] + * if the distributor is uninstalled + */ + fun initStart(context: Context) { + Log.d(TAG, "Init UnifiedPush during app startup") + //TODO: limit to once a day to reduce registrations to ntf server ? + UnifiedPush.getAckDistributor(context)?.let { + register(context) + } + } + private fun register(context: Context) { // TODO: add VAPID UnifiedPush.register(context) From 4683d47259153c795780e5b5cbfdb213660b1e12 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 6 Aug 2025 10:49:50 +0200 Subject: [PATCH 09/13] Refactor MessagesFetcherWorker - Duration parameter was used for the timeout - The actual duration was harcoded with delays - When the worker times out, it means there are pending messages, therefor a new worker must be restarted now - Therefor we need to differenciate initialDelay and interval delay - All of this is more efficient if we use ms instead of seconds - We give an option to do not periodically restart the worker (intervalMs = 0) - Add comments --- .../chat/simplex/app/MessagesFetcherWorker.kt | 93 +++++++++++++++---- 1 file changed, 77 insertions(+), 16 deletions(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt index 9264ca6abf..005ec860b0 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt @@ -15,15 +15,30 @@ import java.util.concurrent.TimeUnit object MessagesFetcherWorker { private const val UNIQUE_WORK_TAG = BuildConfig.APPLICATION_ID + ".UNIQUE_MESSAGES_FETCHER" - fun scheduleWork(intervalSec: Int = 600, durationSec: Int = 60) { - val initialDelaySec = intervalSec.toLong() - Log.d(TAG, "Worker: scheduling work to run at ${Date(System.currentTimeMillis() + initialDelaySec * 1000)} for $durationSec sec") + /** + * Schedule [MessagesFetcherWork] + * + * @param initialDelayMs Delay in milliseconds before starting the jobs, usually = [intervalMs] + * @param intervalMs Interval in milliseconds between the end of a jobs and the start of a new one. + * If set to `0`, the job isn't restarted + * @param timeoutMs Timeout in milliseconds until we stop a job, if there are still new messages, a new job is started directly + * @param durationMs Duration in milliseconds to wait for new messages + */ + fun scheduleWork( + initialDelayMs: Int = MessagesFetcherWork.DEFAULT_INTERVAL_MS, + intervalMs: Int = MessagesFetcherWork.DEFAULT_INTERVAL_MS, + timeoutMs: Int = MessagesFetcherWork.DEFAULT_TIMEOUT_MS, + durationMs: Int = MessagesFetcherWork.DEFAULT_DURATION_MS + ) { + Int.MAX_VALUE + Log.d(TAG, "Worker: scheduling work to run at ${Date(System.currentTimeMillis() + initialDelayMs)} for $timeoutMs ms max") val periodicWorkRequest = OneTimeWorkRequest.Builder(MessagesFetcherWork::class.java) - .setInitialDelay(initialDelaySec, TimeUnit.SECONDS) + .setInitialDelay(initialDelayMs.toLong(), TimeUnit.MILLISECONDS) .setInputData( Data.Builder() - .putInt(MessagesFetcherWork.INPUT_DATA_INTERVAL, intervalSec) - .putInt(MessagesFetcherWork.INPUT_DATA_DURATION, durationSec) + .putInt(MessagesFetcherWork.INPUT_DATA_INTERVAL, intervalMs) + .putInt(MessagesFetcherWork.INPUT_DATA_TIMEOUT, timeoutMs) + .putInt(MessagesFetcherWork.INPUT_DATA_DURATION, durationMs) .build() ) .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) @@ -45,25 +60,49 @@ class MessagesFetcherWork( workerParams: WorkerParameters ): CoroutineWorker(context, workerParams) { companion object { + /** + * Interval, in milliseconds, between the end of a job and the start of a new one + */ const val INPUT_DATA_INTERVAL = "interval" + + /** + * Timeout, in milliseconds, until we force stop the jobs, if there are new messages, a new job is started directly + */ + const val INPUT_DATA_TIMEOUT = "timeout" + + /** + * Minimum duration, in milliseconds, to wait for new messages + */ const val INPUT_DATA_DURATION = "duration" - private const val WAIT_AFTER_LAST_MESSAGE: Long = 10_000 + const val DEFAULT_DURATION_MS = 10_000 + const val DEFAULT_TIMEOUT_MS = 60_000 + const val DEFAULT_INTERVAL_MS = 600_000 } override suspend fun doWork(): Result { // Skip when Simplex service is currently working + val durationMs = inputData.getInt(INPUT_DATA_DURATION, DEFAULT_DURATION_MS) + val timeoutMs = inputData.getInt(INPUT_DATA_TIMEOUT, DEFAULT_TIMEOUT_MS) + val intervalMs = inputData.getInt(INPUT_DATA_INTERVAL, DEFAULT_INTERVAL_MS) + // initialDelayMs may be = 0 + // in this case, reschedule won't start a worker. + var initialDelayMs = intervalMs if (SimplexService.getServiceState(SimplexApp.context) == SimplexService.ServiceState.STARTED) { - reschedule() + reschedule( + initialDelayMs = initialDelayMs, + intervalMs = intervalMs, + timeoutMs = timeoutMs, + durationMs = durationMs + ) return Result.success() } - val durationSeconds = inputData.getInt(INPUT_DATA_DURATION, 60) var shouldReschedule = true try { // In case of self-destruct is enabled the initialization process will not start in SimplexApp, Let's start it here if (DatabaseUtils.ksSelfDestructPassword.get() != null && chatModel.chatDbStatus.value == null) { initChatControllerOnStart() } - withTimeout(durationSeconds * 1000L) { + withTimeout(timeoutMs.toLong()) { val chatController = ChatController SimplexService.waitDbMigrationEnds(chatController) val chatDbStatus = chatController.chatModel.chatDbStatus.value @@ -75,26 +114,48 @@ class MessagesFetcherWork( } Log.w(TAG, "Worker: starting work") // Give some time to start receiving messages - delay(10_000) + delay(durationMs + 200L) while (!isStopped) { - if (chatController.lastMsgReceivedTimestamp + WAIT_AFTER_LAST_MESSAGE < System.currentTimeMillis()) { + val lastMsgMs = System.currentTimeMillis() - chatController.lastMsgReceivedTimestamp + if (lastMsgMs > durationMs) { Log.d(TAG, "Worker: work is done") break } - delay(5000) + delay(durationMs - lastMsgMs + 200L) } } } catch (_: TimeoutCancellationException) { // When timeout happens - Log.d(TAG, "Worker: work is done (took $durationSeconds sec)") + Log.d(TAG, "Worker: Still work to do, restarting a new work (took $durationMs sec)") + // We reschedule a new work now (in 200 ms) + shouldReschedule = true + initialDelayMs = 200 } catch (_: CancellationException) { // When user opens the app while the worker is still working Log.d(TAG, "Worker: interrupted") } catch (e: Exception) { Log.d(TAG, "Worker: unexpected exception: ${e.stackTraceToString()}") } - if (shouldReschedule) reschedule() + if (shouldReschedule) { + reschedule( + initialDelayMs = initialDelayMs, + intervalMs = intervalMs, + timeoutMs = timeoutMs, + durationMs = durationMs + ) + } return Result.success() } - private fun reschedule() = MessagesFetcherWorker.scheduleWork() + /** + * Reschedule a work if [initialDelayMs] > 0 + */ + private fun reschedule( + initialDelayMs: Int, + intervalMs: Int, + timeoutMs: Int, + durationMs: Int + ) = { + if (initialDelayMs > 0) + MessagesFetcherWorker.scheduleWork(initialDelayMs, intervalMs, timeoutMs, durationMs) + } } From 8969987fe1a04152980ed971130c79adf862a686 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 6 Aug 2025 10:52:07 +0200 Subject: [PATCH 10/13] Use MessagesFetcherWorker on new push messages --- .../android/src/main/java/chat/simplex/app/PushService.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/PushService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/PushService.kt index 7f8257d849..f3cfb06679 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/PushService.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/PushService.kt @@ -37,10 +37,7 @@ class PushService: PushService() { when { pn.verification != null -> onVerification(pn.verification) } - // TODO: Start same job than the periodic service ? - // Receiving the push notif is enough to wake the app and fetch msgs - // But it may not be enough when the phone is in doze, or with some - // vendors + MessagesFetcherWorker.scheduleWork(200, 0) } override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { From 775983aba9a3a941966a7e21069e246295d39ed6 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 6 Aug 2025 11:39:57 +0200 Subject: [PATCH 11/13] Add notifications for UnifiedPush unregistrations/failures --- .../main/java/chat/simplex/app/PushService.kt | 17 +++++++++++++++-- .../simplex/app/model/NtfManager.android.kt | 3 +++ .../commonMain/resources/MR/base/strings.xml | 4 ++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/PushService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/PushService.kt index f3cfb06679..37d3a72ad1 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/PushService.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/PushService.kt @@ -3,6 +3,9 @@ package chat.simplex.app import chat.simplex.common.model.CC import chat.simplex.common.platform.Log import chat.simplex.common.platform.chatModel +import chat.simplex.common.platform.ntfManager +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -58,12 +61,22 @@ class PushService: PushService() { override fun onRegistrationFailed(reason: FailedReason, instance: String) { Log.d(TAG, "onRegistrationFailed: $reason") - // TODO: notification to inform about failed registration + val title = generalGetString(MR.strings.icon_descr_instant_notifications) + val text = when (reason) { + FailedReason.NETWORK -> generalGetString(MR.strings.unifiedpush_registration_failed_network) + FailedReason.VAPID_REQUIRED, // Should not happen, VAPID will be required + FailedReason.INTERNAL_ERROR -> generalGetString(MR.strings.unifiedpush_registration_failed_unknown) + FailedReason.ACTION_REQUIRED -> generalGetString(MR.strings.unifiedpush_registration_failed_action) + + } + ntfManager.showMessage(title, text) } override fun onUnregistered(instance: String) { Log.d(TAG, "onUnregistered") - // TODO: notification to inform about unregistration + val title = generalGetString(MR.strings.icon_descr_instant_notifications) + val text = generalGetString(MR.strings.unifiedpush_unregistered) + ntfManager.showMessage(title, text) CoroutineScope(Dispatchers.Default).launch { chatModel.controller.sendCmd( null, diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt index cf19589d4a..b4da80716b 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt @@ -231,6 +231,9 @@ object NtfManager { val builder = NotificationCompat.Builder(context, MessageChannel) .setContentTitle(title) .setContentText(text) + .setStyle( + NotificationCompat.BigTextStyle().bigText(text) + ) .setPriority(NotificationCompat.PRIORITY_HIGH) .setGroup(MessageGroup) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 39daacf355..5879f49218 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -280,6 +280,10 @@ You don\'t have any push service installed on your device.\n\nPlease installed one and try again.\n\nFor more information, visit\ Select Push Service Multiple push services are installed on your system, please select the service you wish to use. + SimpleX is no longer registered with your UnifiedPush distributor. The distributor may have been uninstalled or been logged out. You should reset your notifications settings. + Registration with your UnifiedPush distributor failed due to a network issue. Please try again when your network is back. + Registration with your UnifiedPush distributor failed due to a missing requirement from your distributor. + Registration with your UnifiedPush distributor failed due to an unknown issue. This could be a missing requirement from your distributor. SimpleX Lock From 68b0b3e55cb446e2ccf88970bd4b55890adf2678 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 19 Aug 2025 11:45:04 +0200 Subject: [PATCH 12/13] Add extras to client protocol_servers --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 1 + src/Simplex/Chat/Store/Profiles.hs | 22 +++++++++---------- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +++- .../SQLite/Migrations/M20250815_extras.hs | 18 +++++++++++++++ .../Store/SQLite/Migrations/chat_schema.sql | 1 + 7 files changed, 36 insertions(+), 14 deletions(-) create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20250815_extras.hs diff --git a/cabal.project b/cabal.project index 9b82856f8e..9c1b8a8c8d 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://codeberg.org/s1m/sxmq.git - tag: f5720a254104d70b33ac1479ffa9d24ba9988b59 + tag: a9ef465c0af3829c58fb5e45627d9673aa5b1ee7 -- source-repository-package -- type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index ffdf834552..d4dfe1e5c2 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://codeberg.org/s1m/sxmq.git"."f5720a254104d70b33ac1479ffa9d24ba9988b59" = "cf9a74de0d05afa60a74aa8aa54205c718286b9c16c5f0f398a24d7fa7f7b1ff"; + "https://codeberg.org/s1m/sxmq.git"."a9ef465c0af3829c58fb5e45627d9673aa5b1ee7" = "5921e554cd08372335a415d7c6edcb8d83893f77fffcf5370edaaff7fe47bea6"; "https://github.com/simplex-chat/simplexmq.git"."d352d518c2b3a42bc7a298954dde799422e1457f" = "1rha84pfpaqx3mf218szkfra334vhijqf17hanxqmp1sicfbf1x3"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 553f2ec6cd..c889eb3c74 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -238,6 +238,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20250402_short_links Simplex.Chat.Store.SQLite.Migrations.M20250512_member_admission Simplex.Chat.Store.SQLite.Migrations.M20250513_group_scope + Simplex.Chat.Store.SQLite.Migrations.M20250815_extras other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 6678f85e53..ec11845d46 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -109,7 +109,7 @@ import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON) -import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode) +import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode, Extras) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) #if defined(dbPostgres) @@ -571,15 +571,15 @@ getProtocolServers db p User {userId} = <$> DB.query db [sql| - SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled + SELECT smp_server_id, host, port, key_hash, extras, basic_auth, preset, tested, enabled FROM protocol_servers WHERE user_id = ? AND protocol = ? |] (userId, decodeLatin1 $ strEncode p) where - toUserServer :: (DBEntityId, NonEmpty TransportHost, String, C.KeyHash, Maybe Text, BoolInt, Maybe BoolInt, BoolInt) -> UserServer p - toUserServer (serverId, host, port, keyHash, auth_, BI preset, tested, BI enabled) = - let server = ProtoServerWithAuth (ProtocolServer p host port keyHash) (BasicAuth . encodeUtf8 <$> auth_) + toUserServer :: (DBEntityId, NonEmpty TransportHost, String, C.KeyHash, Maybe Extras, Maybe Text, BoolInt, Maybe BoolInt, BoolInt) -> UserServer p + toUserServer (serverId, host, port, keyHash, extras, auth_, BI preset, tested, BI enabled) = + let server = ProtoServerWithAuth (ProtocolServer p host port keyHash extras) (BasicAuth . encodeUtf8 <$> auth_) in UserServer {serverId, server, preset, tested = unBI <$> tested, enabled, deleted = False} insertProtocolServer :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> UTCTime -> NewUserServer p -> IO (UserServer p) @@ -588,8 +588,8 @@ insertProtocolServer db p User {userId} ts srv@UserServer {server, preset, teste db [sql| INSERT INTO protocol_servers - (protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?) + (protocol, host, port, key_hash, extras, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] (serverColumns p server :. (BI preset, BI <$> tested, BI enabled, userId, ts, ts)) sId <- insertedRowId db @@ -601,17 +601,17 @@ updateProtocolServer db p ts UserServer {serverId, server, preset, tested, enabl db [sql| UPDATE protocol_servers - SET protocol = ?, host = ?, port = ?, key_hash = ?, basic_auth = ?, + SET protocol = ?, host = ?, port = ?, key_hash = ?, extras = ?, basic_auth = ?, preset = ?, tested = ?, enabled = ?, updated_at = ? WHERE smp_server_id = ? |] (serverColumns p server :. (BI preset, BI <$> tested, BI enabled, ts, serverId)) -serverColumns :: ProtocolTypeI p => SProtocolType p -> ProtoServerWithAuth p -> (Text, NonEmpty TransportHost, String, C.KeyHash, Maybe Text) -serverColumns p (ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_) = +serverColumns :: ProtocolTypeI p => SProtocolType p -> ProtoServerWithAuth p -> (Text, NonEmpty TransportHost, String, C.KeyHash, Maybe Extras, Maybe Text) +serverColumns p (ProtoServerWithAuth ProtocolServer {host, port, keyHash, extras} auth_) = let protocol = decodeLatin1 $ strEncode p auth = safeDecodeUtf8 . unBasicAuth <$> auth_ - in (protocol, host, port, keyHash, auth) + in (protocol, host, port, keyHash, extras, auth) getServerOperators :: DB.Connection -> ExceptT StoreError IO ServerOperatorConditions getServerOperators db = do diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 871cb62220..dafb1fc717 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -131,6 +131,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20250130_indexes import Simplex.Chat.Store.SQLite.Migrations.M20250402_short_links import Simplex.Chat.Store.SQLite.Migrations.M20250512_member_admission import Simplex.Chat.Store.SQLite.Migrations.M20250513_group_scope +import Simplex.Chat.Store.SQLite.Migrations.M20250815_extras import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -261,7 +262,8 @@ schemaMigrations = ("20250130_indexes", m20250130_indexes, Just down_m20250130_indexes), ("20250402_short_links", m20250402_short_links, Just down_m20250402_short_links), ("20250512_member_admission", m20250512_member_admission, Just down_m20250512_member_admission), - ("20250513_group_scope", m20250513_group_scope, Just down_m20250513_group_scope) + ("20250513_group_scope", m20250513_group_scope, Just down_m20250513_group_scope), + ("20250815_extras", m20250815_extras, Just down_m20250815_extras) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250815_extras.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250815_extras.hs new file mode 100644 index 0000000000..fb6233af40 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250815_extras.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250815_extras where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250815_extras :: Query +m20250815_extras = + [sql| +ALTER TABLE protocol_servers ADD COLUMN extras TEXT; +|] + +down_m20250815_extras :: Query +down_m20250815_extras = + [sql| +ALTER TABLE protocol_servers DROP COLUMN extras; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 91670ffc7b..33e0aa9069 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -464,6 +464,7 @@ CREATE TABLE IF NOT EXISTS "protocol_servers"( host TEXT NOT NULL, port TEXT NOT NULL, key_hash BLOB NOT NULL, + extras TEXT, basic_auth TEXT, preset INTEGER NOT NULL DEFAULT 0, tested INTEGER, From ddb596fb52406bcc56ef0d644010a619fe6bae65 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 19 Aug 2025 11:47:05 +0200 Subject: [PATCH 13/13] Register for push notifications with VAPID key --- .../main/java/chat/simplex/app/SimplexApp.kt | 10 ++- .../chat/simplex/app/platform/PushManager.kt | 82 ++++++++++++++++--- .../commonMain/resources/MR/base/strings.xml | 2 + cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 5 files changed, 81 insertions(+), 17 deletions(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index b4b8247df2..da6c68ab76 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -270,11 +270,15 @@ class SimplexApp: Application(), LifecycleEventObserver { // Prevents from showing "Enable notifications" alert when onboarding wasn't complete yet if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) { SimplexService.showBackgroundServiceNoticeIfNeeded() - PushManager.initStart(context) - if (appPrefs.notificationsMode.get() == NotificationsMode.SERVICE) - withBGApi { + when (appPrefs.notificationsMode.get()) { + NotificationsMode.SERVICE -> withBGApi { platform.androidServiceStart() } + NotificationsMode.INSTANT -> withBGApi { + PushManager.initStart(context) + } + else -> {} + } } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt index f1534cb1db..7d0a573489 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt @@ -24,12 +24,14 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import org.unifiedpush.android.connector.UnifiedPush +import androidx.core.net.toUri /** * Object with functions to interact with push services */ object PushManager { private const val TAG = "PushManager" + private val VAPID_REGEX = Regex("^[A-Za-z0-9_-]{87}(=+)?$") /** * Check if a NTF server is setup and ask user's push distributor @@ -42,12 +44,20 @@ object PushManager { suspend fun initUnifiedPush(context: Context, scope: CoroutineScope, onSuccess: () -> Unit) { val rh = chatModel.remoteHostId() val userServers = getUserServers(rh) ?: listOf() - if (!userServers.hasNtfServer()) { + val userNtfServer = userServers.userNtfServer() + ?: run { Log.d(TAG, "User doesn't have any NTF server") showMissingNTFDialog(scope, rh, userServers) // After coming back from the server view, users will have to click on "Instant" again return } + val vapid = userNtfServer.vapid() + ?: run { + Log.d(TAG, "User NTF Server doesn't have any VAPID FP") + showNTFNoVAPIDDialog(scope, rh, userServers) + // After coming back from the server view, users will have to click on "Instant" again + return + } val distributors = UnifiedPush.getDistributors(context) when (distributors.size) { 0 -> { @@ -57,7 +67,7 @@ object PushManager { 1 -> { Log.d(TAG, "Found only one distributor installed") UnifiedPush.saveDistributor(context, distributors.first()) - register(context) + register(context, vapid) onSuccess() } else -> { @@ -65,7 +75,7 @@ object PushManager { showSelectPushServiceIntroDialog { showSelectPushServiceDialog(context, distributors) { UnifiedPush.saveDistributor(context, it) - register(context) + register(context, vapid) onSuccess() } } @@ -79,27 +89,40 @@ object PushManager { * To run when the app starts; call [chat.simplex.app.PushService.onUnregistered] * if the distributor is uninstalled */ - fun initStart(context: Context) { + suspend fun initStart(context: Context) { Log.d(TAG, "Init UnifiedPush during app startup") //TODO: limit to once a day to reduce registrations to ntf server ? UnifiedPush.getAckDistributor(context)?.let { - register(context) + val vapid = getUserServers(chatModel.remoteHostId()) + ?.userNtfServer() + ?.vapid() + // Unregister if the user deleted the ntf server or change it with one without vapid + ?: return UnifiedPush.unregister(context) + register(context, vapid) } } - private fun register(context: Context) { - // TODO: add VAPID - UnifiedPush.register(context) + private fun register(context: Context, vapid: String) { + UnifiedPush.register(context, vapid = vapid) } /** - * Check if any NTF server is recorded + * Get user NTF server */ - private fun List.hasNtfServer(): Boolean { - // TODO: check if ntf server has a VAPID key - return this.any { it.ntfServers.any { s -> s.enabled } } + private fun List.userNtfServer(): UserServer? { + return this.firstNotNullOfOrNull { + it.ntfServers.firstOrNull { s -> s.enabled } + } } + /** + * Get vapid fingerprint from the URI + */ + private fun UserServer.vapid(): String ? = server + .toUri() + .getQueryParameter("vapid") + ?.takeIf { VAPID_REGEX.matches(it) } + /** * Show a dialog to inform about missing NTF server */ @@ -136,6 +159,41 @@ object PushManager { ) } + /** + * Show a dialog to inform about missing VAPID with the NTF server + */ + private fun showNTFNoVAPIDDialog(scope: CoroutineScope, rh: Long?, userServers: List) = AlertManager.shared.showAlert { + AlertDialog( + onDismissRequest = AlertManager.shared::hideAlert, + title = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + painterResource(MR.images.ic_warning), + contentDescription = null, // The icon doesn't add any meaning and must not be transcripted with screen readers + ) + Text( + stringResource(MR.strings.icon_descr_instant_notifications), + fontWeight = FontWeight.Bold + ) + } + }, + text = { + Text(stringResource(MR.strings.warning_push_needs_ntf_server_with_vapid)) + }, + // Go to user's servers settings + confirmButton = { + TextButton(onClick = { + AlertManager.shared.hideAlert() + showAddServerDialog(scope, rh, userServers) + }) { Text(stringResource(MR.strings.smp_servers_add)) } + }, + // Ignore + dismissButton = { + TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(android.R.string.cancel)) } + }, + shape = RoundedCornerShape(corner = CornerSize(25.dp)) + ) + } /** * Dialog to add a server, manually or with a QR code */ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 5879f49218..2987ff9aca 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -277,7 +277,9 @@ Connected Error showing notification, contact developers. You first need to set a NTF server to get push notifications.\n\nIt will send the notifications to your push provider. + Your NTF server doesn\'t support VAPID, which is required to get push notifications. You don\'t have any push service installed on your device.\n\nPlease installed one and try again.\n\nFor more information, visit\ + Your NTF server has been removed or has changed and doesn\'t support VAPID, which is required to get push notifications. Select Push Service Multiple push services are installed on your system, please select the service you wish to use. SimpleX is no longer registered with your UnifiedPush distributor. The distributor may have been uninstalled or been logged out. You should reset your notifications settings. diff --git a/cabal.project b/cabal.project index 9c1b8a8c8d..3eb7d516f8 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://codeberg.org/s1m/sxmq.git - tag: a9ef465c0af3829c58fb5e45627d9673aa5b1ee7 + tag: f28a2052ae88c59653993ca0e409231b5ed00b11 -- source-repository-package -- type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index d4dfe1e5c2..e4a0d027b2 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://codeberg.org/s1m/sxmq.git"."a9ef465c0af3829c58fb5e45627d9673aa5b1ee7" = "5921e554cd08372335a415d7c6edcb8d83893f77fffcf5370edaaff7fe47bea6"; + "https://codeberg.org/s1m/sxmq.git"."f28a2052ae88c59653993ca0e409231b5ed00b11" = "86128d5cdb952e28460b4450042622807e0b683de5598817cac80e85ecee8c3f"; "https://github.com/simplex-chat/simplexmq.git"."d352d518c2b3a42bc7a298954dde799422e1457f" = "1rha84pfpaqx3mf218szkfra334vhijqf17hanxqmp1sicfbf1x3"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";