// C o p y r i g h t © 2 0 2 2 R a n g e p r o o f P t y L t d . A l l r i g h t s r e s e r v e d .
import Foundation
import AVFAudio
import Combine
import GRDB
import CallKit
import UserNotifications
import BackgroundTasks
import SessionMessagingKit
import SignalUtilitiesKit
import SessionUtilitiesKit
public final class NotificationServiceExtension : UNNotificationServiceExtension {
private var dependencies : Dependencies = Dependencies ( )
private var startTime : CFTimeInterval = 0
private var contentHandler : ( ( UNNotificationContent ) -> Void ) ?
private var request : UNNotificationRequest ?
private var hasCompleted : Atomic < Bool > = Atomic ( false )
public static let isFromRemoteKey = " remote " // s t r i n g l i n t : d i s a b l e
public static let threadIdKey = " Signal.AppNotificationsUserInfoKey.threadId " // s t r i n g l i n t : d i s a b l e
public static let threadVariantRaw = " Signal.AppNotificationsUserInfoKey.threadVariantRaw " // s t r i n g l i n t : d i s a b l e
public static let threadNotificationCounter = " Session.AppNotificationsUserInfoKey.threadNotificationCounter " // s t r i n g l i n t : d i s a b l e
private static let callPreOfferLargeNotificationSupressionDuration : TimeInterval = 30
// MARK: D i d r e c e i v e a r e m o t e p u s h n o t i f i c a t i o n r e q u e s t
override public func didReceive ( _ request : UNNotificationRequest , withContentHandler contentHandler : @ escaping ( UNNotificationContent ) -> Void ) {
self . startTime = CACurrentMediaTime ( )
self . contentHandler = contentHandler
self . request = request
// I t ' s t e c h n i c a l l y p o s s i b l e f o r ' c o m p l e t e S i l e n t l y ' t o b e c a l l e d t w i c e d u e t o t h e N S E t i m e o u t s o
self . hasCompleted . mutate { $0 = false }
// A b o r t i f t h e m a i n a p p i s r u n n i n g
guard ! ( UserDefaults . sharedLokiProject ? [ . isMainAppActive ] ) . defaulting ( to : false ) else {
Log . info ( " didReceive called while main app running. " )
return self . completeSilenty ( handledNotification : false , isMainAppAndActive : true )
}
guard let notificationContent = request . content . mutableCopy ( ) as ? UNMutableNotificationContent else {
Log . info ( " didReceive called with no content. " )
return self . completeSilenty ( handledNotification : false )
}
Log . info ( " didReceive called. " )
// / C r e a t e t h e c o n t e x t i f w e d o n ' t h a v e i t ( n e e d e d b e f o r e _ a n y _ i n t e r a c t i o n w i t h t h e d a t a b a s e )
if ! Singleton . hasAppContext {
Singleton . setup ( appContext : NotificationServiceExtensionContext ( ) )
}
// / P e r f o r m m a i n s e t u p ( c r e a t e a n e w ` D e p e n d e n c i e s ` i n s t a n c e e a c h t i m e s o w e d o n ' t n e e d t o w o r r y a b o u t s t a t e f r o m p r e v i o u s
// / n o t i f i c a t i o n s c a u s i n g i s s u e s w i t h n e w n o t i f i c a t i o n s
self . dependencies = Dependencies ( )
DispatchQueue . main . sync {
self . performSetup { [ weak self ] in
self ? . handleNotification ( notificationContent , isPerformingResetup : false )
}
}
}
private func handleNotification ( _ notificationContent : UNMutableNotificationContent , isPerformingResetup : Bool ) {
let ( maybeData , metadata , result ) = PushNotificationAPI . processNotification (
notificationContent : notificationContent ,
using : dependencies
)
guard
( result = = . success || result = = . legacySuccess ) ,
let data : Data = maybeData
else {
switch result {
// I f w e g o t a n e x p l i c i t f a i l u r e , o r w e g o t a s u c c e s s b u t n o c o n t e n t t h e n s h o w
// t h e f a l l b a c k n o t i f i c a t i o n
case . success , . legacySuccess , . failure , . legacyFailure :
return self . handleFailure ( for : notificationContent , error : . processing ( result ) )
// J u s t l o g i f t h e n o t i f i c a t i o n w a s t o o l o n g ( a ~ 2 k m e s s a g e s h o u l d b e a b l e t o f i t s o
// t h e s e w i l l m o s t c o m m o n l y b e c a l l o r c o n f i g m e s s a g e s )
case . successTooLong :
Log . info ( " Received too long notification for namespace: \( metadata . namespace ) , dataLength: \( metadata . dataLength ) . " )
return self . completeSilenty ( handledNotification : false )
case . legacyForceSilent :
Log . info ( " Ignoring non-group legacy notification. " )
return self . completeSilenty ( handledNotification : false )
case . failureNoContent :
Log . warn ( " Failed due to missing notification content. " )
return self . completeSilenty ( handledNotification : false )
}
}
let isCallOngoing : Bool = ( UserDefaults . sharedLokiProject ? [ . isCallOngoing ] )
. defaulting ( to : false )
// H A C K : I t i s i m p o r t a n t t o u s e w r i t e s y n c h r o n o u s l y h e r e t o a v o i d a r a c e c o n d i t i o n
// w h e r e t h e c o m p l e t e S i l e n t y ( ) i s c a l l e d b e f o r e t h e l o c a l n o t i f i c a t i o n r e q u e s t
// i s a d d e d t o n o t i f i c a t i o n c e n t e r
dependencies . storage . write { [ weak self , dependencies ] db in
do {
guard let processedMessage : ProcessedMessage = try Message . processRawReceivedMessageAsNotification ( db , data : data , metadata : metadata , using : dependencies ) else {
throw NotificationError . messageProcessing
}
switch processedMessage {
// / C u s t o m h a n d l e c o n f i g m e s s a g e s ( a s t h e y d o n ' t g e t h a n d l e d b y t h e n o r m a l ` M e s s a g e R e c e i v e r . h a n d l e ` c a l l
case . config ( let publicKey , let namespace , let serverHash , let serverTimestampMs , let data ) :
try LibSession . handleConfigMessages (
db ,
messages : [
ConfigMessageReceiveJob . Details . MessageInfo (
namespace : namespace ,
serverHash : serverHash ,
serverTimestampMs : serverTimestampMs ,
data : data
)
] ,
publicKey : publicKey ,
using : dependencies
)
// / D u e t o t h e w a y t h e ` C a l l M e s s a g e ` w o r k s w e n e e d t o c u s t o m h a n d l e i t ' s b e h a v i o u r w i t h i n t h e n o t i f i c a t i o n
// / e x t e n s i o n , f o r a l l o t h e r m e s s a g e t y p e s w e w a n t t o j u s t u s e t h e s t a n d a r d ` M e s s a g e R e c e i v e r . h a n d l e ` c a l l
case . standard ( let threadId , let threadVariant , _ , let messageInfo ) where messageInfo . message is CallMessage :
guard let callMessage = messageInfo . message as ? CallMessage else {
throw NotificationError . ignorableMessage
}
// T h r o w i f t h e m e s s a g e i s o u t d a t e d a n d s h o u l d n ' t b e p r o c e s s e d
try MessageReceiver . throwIfMessageOutdated (
db ,
message : messageInfo . message ,
threadId : threadId ,
threadVariant : threadVariant ,
using : dependencies
)
try MessageReceiver . handleCallMessage (
db ,
threadId : threadId ,
threadVariant : threadVariant ,
message : callMessage ,
using : dependencies
)
guard case . preOffer = callMessage . kind else {
throw NotificationError . ignorableMessage
}
let hasMicrophonePermission : Bool = ( AVAudioSession . sharedInstance ( ) . recordPermission = = . granted )
switch ( ( db [ . areCallsEnabled ] && hasMicrophonePermission ) , isCallOngoing ) {
case ( false , _ ) :
if
let sender : String = callMessage . sender ,
let interaction : Interaction = try MessageReceiver . insertCallInfoMessage (
db ,
for : callMessage ,
state : ( db [ . areCallsEnabled ] ? . permissionDeniedMicrophone : . permissionDenied ) ,
using : dependencies
)
{
let thread : SessionThread = try SessionThread
. fetchOrCreate (
db ,
id : sender ,
variant : . contact ,
shouldBeVisible : nil
)
// N o t i f y t h e u s e r i f t h e c a l l m e s s a g e w a s n ' t a l r e a d y r e a d
if ! interaction . wasRead {
SessionEnvironment . shared ? . notificationsManager . wrappedValue ?
. notifyUser (
db ,
forIncomingCall : interaction ,
in : thread ,
applicationState : . background
)
}
}
case ( true , true ) :
try MessageReceiver . handleIncomingCallOfferInBusyState (
db ,
message : callMessage
)
case ( true , false ) :
try MessageReceiver . insertCallInfoMessage ( db , for : callMessage , using : dependencies )
// P e r f o r m a n y r e q u i r e d p o s t - h a n d l i n g l o g i c
try MessageReceiver . postHandleMessage (
db ,
threadId : threadId ,
threadVariant : threadVariant ,
message : messageInfo . message
)
return self ? . handleSuccessForIncomingCall ( db , for : callMessage )
}
// P e r f o r m a n y r e q u i r e d p o s t - h a n d l i n g l o g i c
try MessageReceiver . postHandleMessage (
db ,
threadId : threadId ,
threadVariant : threadVariant ,
message : messageInfo . message
)
case . standard ( let threadId , let threadVariant , let proto , let messageInfo ) :
try MessageReceiver . handle (
db ,
threadId : threadId ,
threadVariant : threadVariant ,
message : messageInfo . message ,
serverExpirationTimestamp : messageInfo . serverExpirationTimestamp ,
associatedWithProto : proto ,
using : dependencies
)
}
db . afterNextTransaction (
onCommit : { _ in self ? . completeSilenty ( handledNotification : true ) } ,
onRollback : { _ in self ? . completeSilenty ( handledNotification : false ) }
)
}
catch {
// I f a n e r r o r o c c u r r e d w e w a n t t o r o l l b a c k t h e t r a n s a c t i o n ( b y t h r o w i n g ) a n d t h e n h a n d l e
// t h e e r r o r o u t s i d e o f t h e d a t a b a s e
let handleError = {
// D i s p a t c h t o t h e n e x t r u n l o o p t o e n s u r e w e a r e o u t o f t h e d a t a b a s e w r i t e t h r e a d b e f o r e
// h a n d l i n g t h e r e s u l t ( a n d s u s p e n d i n g t h e d a t a b a s e )
DispatchQueue . main . async {
switch error {
case MessageReceiverError . noGroupKeyPair :
Log . warn ( " Failed due to having no legacy group decryption keys. " )
self ? . completeSilenty ( handledNotification : false )
case MessageReceiverError . outdatedMessage :
Log . info ( " Ignoring notification for already seen message. " )
self ? . completeSilenty ( handledNotification : false )
case NotificationError . ignorableMessage :
Log . info ( " Ignoring message which requires no notification. " )
self ? . completeSilenty ( handledNotification : false )
case MessageReceiverError . duplicateMessage , MessageReceiverError . duplicateControlMessage ,
MessageReceiverError . duplicateMessageNewSnode :
Log . info ( " Ignoring duplicate message (probably received it just before going to the background). " )
self ? . completeSilenty ( handledNotification : false )
case NotificationError . messageProcessing :
self ? . handleFailure ( for : notificationContent , error : . messageProcessing )
case let msgError as MessageReceiverError :
self ? . handleFailure ( for : notificationContent , error : . messageHandling ( msgError ) )
default : self ? . handleFailure ( for : notificationContent , error : . other ( error ) )
}
}
}
db . afterNextTransaction (
onCommit : { _ in handleError ( ) } ,
onRollback : { _ in handleError ( ) }
)
throw error
}
}
}
// MARK: S e t u p
private func performSetup ( completion : @ escaping ( ) -> Void ) {
Log . info ( " Performing setup. " )
_ = AppVersion . shared
// FIXME: R e m o v e t h e s e o n c e t h e d a t a b a s e i n s t a n c e i s f u l l y m a n a g e d v i a ` D e p e n d e n c i e s `
if AppSetup . hasRun {
dependencies . storage . resumeDatabaseAccess ( )
dependencies . storage . reconfigureDatabase ( )
dependencies . caches . mutate ( cache : . general ) { $0 . clearCachedUserPublicKey ( ) }
// I f w e h a d a l r e a d y d o n e a s e t u p t h e n ` l i b S e s s i o n ` w o n ' t h a v e b e e n r e - s e t u p s o
// w e n e e d t o d o s o n o w ( t h i s e n s u r e s i t h a s t h e c o r r e c t u s e r k e y s a s w e l l )
LibSession . clearMemoryState ( using : dependencies )
dependencies . storage . read { [ dependencies ] db in
LibSession . loadState (
db ,
userPublicKey : getUserHexEncodedPublicKey ( db , using : dependencies ) ,
ed25519SecretKey : Identity . fetchUserEd25519KeyPair ( db ) ? . secretKey ,
using : dependencies
)
}
}
AppSetup . setupEnvironment (
retrySetupIfDatabaseInvalid : true ,
appSpecificBlock : {
Log . setup ( with : Logger (
primaryPrefix : " NotificationServiceExtension " , // s t r i n g l i n t : d i s a b l e
level : . info ,
customDirectory : " \( FileManager . default . appSharedDataDirectoryPath ) /Logs/NotificationExtension " , // s t r i n g l i n t : d i s a b l e
forceNSLog : true
) )
SessionEnvironment . shared ? . notificationsManager . mutate {
$0 = NSENotificationPresenter ( )
}
// S e t u p L i b S e s s i o n
LibSession . addLogger ( )
} ,
migrationsCompletion : { [ weak self , dependencies ] result , _ in
switch result {
case . failure ( let error ) :
Log . error ( " Failed to complete migrations: \( error ) . " )
self ? . completeSilenty ( handledNotification : false )
case . success :
DispatchQueue . main . async {
// E n s u r e s t o r a g e i s a c t u a l l y v a l i d
guard dependencies . storage . isValid else {
Log . error ( " Storage invalid. " )
self ? . completeSilenty ( handledNotification : false )
return
}
// W e s h o u l d n e v e r r e c e i v e a n o n - v o i p n o t i f i c a t i o n o n a n a p p t h a t d o e s n ' t s u p p o r t
// a p p e x t e n s i o n s s i n c e w e h a v e t o i n f o r m t h e s e r v i c e w e w a n t e d t h e s e , s o i n t h e o r y
// t h i s p a t h s h o u l d n e v e r o c c u r . H o w e v e r , t h e s e r v i c e d o e s h a v e o u r p u s h t o k e n
// s o i t i s p o s s i b l e t h a t c o u l d c h a n g e i n t h e f u t u r e . I f i t d o e s , d o n o t h i n g
// a n d d o n ' t d i s t u r b t h e u s e r . M e s s a g e s w i l l b e p r o c e s s e d w h e n t h e y o p e n t h e a p p .
guard dependencies . storage [ . isReadyForAppExtensions ] else {
Log . error ( " Not ready for extensions. " )
self ? . completeSilenty ( handledNotification : false )
return
}
// I f t h e a p p w a s n ' t r e a d y t h e n m a r k i t a s r e a d y n o w
if ! Singleton . appReadiness . isAppReady {
// N o t e t h a t t h i s d o e s m u c h m o r e t h a n s e t a f l a g ; i t w i l l a l s o r u n a l l d e f e r r e d b l o c k s .
Singleton . appReadiness . setAppReady ( )
}
completion ( )
}
}
} ,
using : dependencies
)
}
// MARK: H a n d l e c o m p l e t i o n
override public func serviceExtensionTimeWillExpire ( ) {
// C a l l e d j u s t b e f o r e t h e e x t e n s i o n w i l l b e t e r m i n a t e d b y t h e s y s t e m .
// U s e t h i s a s a n o p p o r t u n i t y t o d e l i v e r y o u r " b e s t a t t e m p t " a t m o d i f i e d c o n t e n t , o t h e r w i s e t h e o r i g i n a l p u s h p a y l o a d w i l l b e u s e d .
Log . warn ( " Execution time expired. " )
completeSilenty ( handledNotification : false )
}
private func completeSilenty ( handledNotification : Bool , isMainAppAndActive : Bool = false ) {
// E n s u r e w e o n l y r u n t h i s o n c e
guard
hasCompleted . mutate ( { hasCompleted in
let wasCompleted : Bool = hasCompleted
hasCompleted = true
return wasCompleted
} ) = = false
else { return }
let silentContent : UNMutableNotificationContent = UNMutableNotificationContent ( )
if ! isMainAppAndActive {
silentContent . badge = dependencies . storage
. read { db in try Interaction . fetchUnreadCount ( db ) }
. map { NSNumber ( value : $0 ) }
. defaulting ( to : NSNumber ( value : 0 ) )
dependencies . storage . suspendDatabaseAccess ( )
}
let duration : CFTimeInterval = ( CACurrentMediaTime ( ) - startTime )
Log . info ( handledNotification ? " Completed after handling notification in \( . seconds ( duration ) , unit : . ms ) . " : " Completed silently after \( . seconds ( duration ) , unit : . ms ) . " )
Log . flush ( )
self . contentHandler ! ( silentContent )
}
private func handleSuccessForIncomingCall ( _ db : Database , for callMessage : CallMessage ) {
if #available ( iOSApplicationExtension 14.5 , * ) , Preferences . isCallKitSupported {
guard let caller : String = callMessage . sender , let timestamp = callMessage . sentTimestamp else { return }
let reportCall : ( ) -> ( ) = { [ weak self ] in
let payload : JSON = [
" uuid " : callMessage . uuid , // s t r i n g l i n t : d i s a b l e
" caller " : caller , // s t r i n g l i n t : d i s a b l e
" timestamp " : timestamp // s t r i n g l i n t : d i s a b l e
]
CXProvider . reportNewIncomingVoIPPushPayload ( payload ) { error in
if let error = error {
Log . error ( " Failed to notify main app of call message: \( error ) . " )
Storage . shared . read { db in
self ? . handleFailureForVoIP ( db , for : callMessage )
}
}
else {
Log . info ( " Successfully notified main app of call message. " )
UserDefaults . sharedLokiProject ? [ . lastCallPreOffer ] = Date ( )
self ? . completeSilenty ( handledNotification : true )
}
}
}
db . afterNextTransaction (
onCommit : { _ in reportCall ( ) } ,
onRollback : { _ in reportCall ( ) }
)
}
else {
self . handleFailureForVoIP ( db , for : callMessage )
}
}
private func handleFailureForVoIP ( _ db : Database , for callMessage : CallMessage ) {
let notificationContent = UNMutableNotificationContent ( )
notificationContent . userInfo = [ NotificationServiceExtension . isFromRemoteKey : true ]
notificationContent . title = " Session "
notificationContent . badge = ( try ? Interaction . fetchUnreadCount ( db ) )
. map { NSNumber ( value : $0 ) }
. defaulting ( to : NSNumber ( value : 0 ) )
if let sender : String = callMessage . sender {
let senderDisplayName : String = Profile . displayName ( db , id : sender , threadVariant : . contact )
notificationContent . body = " \( senderDisplayName ) is calling... "
}
else {
notificationContent . body = " Incoming call... "
}
let identifier = self . request ? . identifier ? ? UUID ( ) . uuidString
let request = UNNotificationRequest ( identifier : identifier , content : notificationContent , trigger : nil )
let semaphore = DispatchSemaphore ( value : 0 )
UNUserNotificationCenter . current ( ) . add ( request ) { error in
if let error = error {
Log . error ( " Failed to add notification request due to error: \( error ) . " )
}
semaphore . signal ( )
}
semaphore . wait ( )
Log . info ( " Add remote notification request. " )
db . afterNextTransaction (
onCommit : { [ weak self ] _ in self ? . completeSilenty ( handledNotification : true ) } ,
onRollback : { [ weak self ] _ in self ? . completeSilenty ( handledNotification : false ) }
)
}
private func handleFailure ( for content : UNMutableNotificationContent , error : NotificationError ) {
dependencies . storage . suspendDatabaseAccess ( )
let duration : CFTimeInterval = ( CACurrentMediaTime ( ) - startTime )
Log . error ( " Show generic failure message after \( . seconds ( duration ) , unit : . ms ) due to error: \( error ) . " )
Log . flush ( )
content . title = " Session "
content . body = " APN_Message " . localized ( )
let userInfo : [ String : Any ] = [ NotificationServiceExtension . isFromRemoteKey : true ]
content . userInfo = userInfo
contentHandler ! ( content )
hasCompleted . mutate { $0 = true }
}
}