Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion webtrit_callkeep/lib/src/callkeep_connections.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class CallkeepConnections {
///
/// Returns a [Future] resolving to a [CallkeepConnection] if found, or null otherwise.
Future<CallkeepConnection?> getConnection(String callId) {
if (!kIsWeb && defaultTargetPlatform != TargetPlatform.android) {
if (!kIsWeb && defaultTargetPlatform != TargetPlatform.android && defaultTargetPlatform != TargetPlatform.iOS) {
return Future.value(null);
}

Expand Down
24 changes: 24 additions & 0 deletions webtrit_callkeep_ios/ios/Classes/Generated.h
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,27 @@ typedef NS_ENUM(NSUInteger, WTPCallRequestErrorEnum) {
- (instancetype)initWithValue:(WTPCallRequestErrorEnum)value;
@end

typedef NS_ENUM(NSUInteger, WTPCallkeepConnectionState) {
WTPCallkeepConnectionStateStateNew = 0,
WTPCallkeepConnectionStateStateActive = 1,
WTPCallkeepConnectionStateStateHolding = 2,
WTPCallkeepConnectionStateStateDisconnected = 3,
};

/// Wrapper for WTPCallkeepConnectionState to allow for nullability.
@interface WTPCallkeepConnectionStateBox : NSObject
@property(nonatomic, assign) WTPCallkeepConnectionState value;
- (instancetype)initWithValue:(WTPCallkeepConnectionState)value;
@end

@class WTPIOSOptions;
@class WTPAndroidOptions;
@class WTPOptions;
@class WTPHandle;
@class WTPEndCallReason;
@class WTPIncomingCallError;
@class WTPCallRequestError;
@class WTPCallkeepConnection;

@interface WTPIOSOptions : NSObject
/// `init` unavailable to enforce nonnull fields, see the `make` class method.
Expand Down Expand Up @@ -164,6 +178,15 @@ typedef NS_ENUM(NSUInteger, WTPCallRequestErrorEnum) {
@property(nonatomic, assign) WTPCallRequestErrorEnum value;
@end

@interface WTPCallkeepConnection : NSObject
/// `init` unavailable to enforce nonnull fields, see the `make` class method.
- (instancetype)init NS_UNAVAILABLE;
+ (instancetype)makeWithCallId:(NSString *)callId
state:(WTPCallkeepConnectionState)state;
@property(nonatomic, copy) NSString * callId;
@property(nonatomic, assign) WTPCallkeepConnectionState state;
@end

/// The codec used by all APIs.
NSObject<FlutterMessageCodec> *WTGetGeneratedCodec(void);

Expand Down Expand Up @@ -194,6 +217,7 @@ extern void SetUpWTPHostAndroidServiceApiWithSuffix(id<FlutterBinaryMessenger> b
- (void)setMuted:(NSString *)uuidString muted:(BOOL)muted completion:(void (^)(WTPCallRequestError *_Nullable, FlutterError *_Nullable))completion;
- (void)setSpeaker:(NSString *)uuidString enabled:(BOOL)enabled completion:(void (^)(WTPCallRequestError *_Nullable, FlutterError *_Nullable))completion;
- (void)sendDTMF:(NSString *)uuidString key:(NSString *)key completion:(void (^)(WTPCallRequestError *_Nullable, FlutterError *_Nullable))completion;
- (void)getConnectionWithUuidString:(NSString *)uuidString completion:(void (^)(WTPCallkeepConnection *_Nullable, FlutterError *_Nullable))completion;
@end

extern void SetUpWTPHostApi(id<FlutterBinaryMessenger> binaryMessenger, NSObject<WTPHostApi> *_Nullable api);
Expand Down
102 changes: 88 additions & 14 deletions webtrit_callkeep_ios/ios/Classes/Generated.m
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ - (instancetype)initWithValue:(WTPCallRequestErrorEnum)value {
}
@end

@implementation WTPCallkeepConnectionStateBox
- (instancetype)initWithValue:(WTPCallkeepConnectionState)value {
self = [super init];
if (self) {
_value = value;
}
return self;
}
@end

@interface WTPIOSOptions ()
+ (WTPIOSOptions *)fromList:(NSArray<id> *)list;
+ (nullable WTPIOSOptions *)nullableFromList:(NSArray<id> *)list;
Expand Down Expand Up @@ -123,6 +133,12 @@ + (nullable WTPCallRequestError *)nullableFromList:(NSArray<id> *)list;
- (NSArray<id> *)toList;
@end

@interface WTPCallkeepConnection ()
+ (WTPCallkeepConnection *)fromList:(NSArray<id> *)list;
+ (nullable WTPCallkeepConnection *)nullableFromList:(NSArray<id> *)list;
- (NSArray<id> *)toList;
@end

@implementation WTPIOSOptions
+ (instancetype)makeWithLocalizedName:(NSString *)localizedName
ringtoneSound:(nullable NSString *)ringtoneSound
Expand Down Expand Up @@ -330,6 +346,32 @@ + (nullable WTPCallRequestError *)nullableFromList:(NSArray<id> *)list {
}
@end

@implementation WTPCallkeepConnection
+ (instancetype)makeWithCallId:(NSString *)callId
state:(WTPCallkeepConnectionState)state {
WTPCallkeepConnection* pigeonResult = [[WTPCallkeepConnection alloc] init];
pigeonResult.callId = callId;
pigeonResult.state = state;
return pigeonResult;
}
+ (WTPCallkeepConnection *)fromList:(NSArray<id> *)list {
WTPCallkeepConnection *pigeonResult = [[WTPCallkeepConnection alloc] init];
pigeonResult.callId = GetNullableObjectAtIndex(list, 0);
WTPCallkeepConnectionStateBox *boxedWTPCallkeepConnectionState = GetNullableObjectAtIndex(list, 1);
pigeonResult.state = boxedWTPCallkeepConnectionState.value;
return pigeonResult;
}
+ (nullable WTPCallkeepConnection *)nullableFromList:(NSArray<id> *)list {
return (list) ? [WTPCallkeepConnection fromList:list] : nil;
}
- (NSArray<id> *)toList {
return @[
self.callId ?: [NSNull null],
[[WTPCallkeepConnectionStateBox alloc] initWithValue:self.state],
];
}
@end

@interface WTGeneratedPigeonCodecReader : FlutterStandardReader
@end
@implementation WTGeneratedPigeonCodecReader
Expand All @@ -355,20 +397,26 @@ - (nullable id)readValueOfType:(UInt8)type {
NSNumber *enumAsNumber = [self readValue];
return enumAsNumber == nil ? nil : [[WTPCallRequestErrorEnumBox alloc] initWithValue:[enumAsNumber integerValue]];
}
case 134:
return [WTPIOSOptions fromList:[self readValue]];
case 134: {
NSNumber *enumAsNumber = [self readValue];
return enumAsNumber == nil ? nil : [[WTPCallkeepConnectionStateBox alloc] initWithValue:[enumAsNumber integerValue]];
}
case 135:
return [WTPAndroidOptions fromList:[self readValue]];
return [WTPIOSOptions fromList:[self readValue]];
case 136:
return [WTPOptions fromList:[self readValue]];
return [WTPAndroidOptions fromList:[self readValue]];
case 137:
return [WTPHandle fromList:[self readValue]];
return [WTPOptions fromList:[self readValue]];
case 138:
return [WTPEndCallReason fromList:[self readValue]];
return [WTPHandle fromList:[self readValue]];
case 139:
return [WTPIncomingCallError fromList:[self readValue]];
return [WTPEndCallReason fromList:[self readValue]];
case 140:
return [WTPIncomingCallError fromList:[self readValue]];
case 141:
return [WTPCallRequestError fromList:[self readValue]];
case 142:
return [WTPCallkeepConnection fromList:[self readValue]];
default:
return [super readValueOfType:type];
}
Expand Down Expand Up @@ -399,26 +447,33 @@ - (void)writeValue:(id)value {
WTPCallRequestErrorEnumBox *box = (WTPCallRequestErrorEnumBox *)value;
[self writeByte:133];
[self writeValue:(value == nil ? [NSNull null] : [NSNumber numberWithInteger:box.value])];
} else if ([value isKindOfClass:[WTPIOSOptions class]]) {
} else if ([value isKindOfClass:[WTPCallkeepConnectionStateBox class]]) {
WTPCallkeepConnectionStateBox *box = (WTPCallkeepConnectionStateBox *)value;
[self writeByte:134];
[self writeValue:(value == nil ? [NSNull null] : [NSNumber numberWithInteger:box.value])];
} else if ([value isKindOfClass:[WTPIOSOptions class]]) {
[self writeByte:135];
[self writeValue:[value toList]];
} else if ([value isKindOfClass:[WTPAndroidOptions class]]) {
[self writeByte:135];
[self writeByte:136];
[self writeValue:[value toList]];
} else if ([value isKindOfClass:[WTPOptions class]]) {
[self writeByte:136];
[self writeByte:137];
[self writeValue:[value toList]];
} else if ([value isKindOfClass:[WTPHandle class]]) {
[self writeByte:137];
[self writeByte:138];
[self writeValue:[value toList]];
} else if ([value isKindOfClass:[WTPEndCallReason class]]) {
[self writeByte:138];
[self writeByte:139];
[self writeValue:[value toList]];
} else if ([value isKindOfClass:[WTPIncomingCallError class]]) {
[self writeByte:139];
[self writeByte:140];
[self writeValue:[value toList]];
} else if ([value isKindOfClass:[WTPCallRequestError class]]) {
[self writeByte:140];
[self writeByte:141];
[self writeValue:[value toList]];
} else if ([value isKindOfClass:[WTPCallkeepConnection class]]) {
[self writeByte:142];
[self writeValue:[value toList]];
} else {
[super writeValue:value];
Expand Down Expand Up @@ -800,6 +855,25 @@ void SetUpWTPHostApiWithSuffix(id<FlutterBinaryMessenger> binaryMessenger, NSObj
[channel setMessageHandler:nil];
}
}
{
FlutterBasicMessageChannel *channel =
[[FlutterBasicMessageChannel alloc]
initWithName:[NSString stringWithFormat:@"%@%@", @"dev.flutter.pigeon.webtrit_callkeep_ios.PHostApi.getConnection", messageChannelSuffix]
binaryMessenger:binaryMessenger
codec:WTGetGeneratedCodec()];
if (api) {
NSCAssert([api respondsToSelector:@selector(getConnectionWithUuidString:completion:)], @"WTPHostApi api (%@) doesn't respond to @selector(getConnectionWithUuidString:completion:)", api);
[channel setMessageHandler:^(id _Nullable message, FlutterReply callback) {
NSArray<id> *args = message;
NSString *arg_uuidString = GetNullableObjectAtIndex(args, 0);
[api getConnectionWithUuidString:arg_uuidString completion:^(WTPCallkeepConnection *_Nullable output, FlutterError *_Nullable error) {
callback(wrapResult(output, error));
}];
}];
} else {
[channel setMessageHandler:nil];
}
}
}
@interface WTPDelegateFlutterApi ()
@property(nonatomic, strong) NSObject<FlutterBinaryMessenger> *binaryMessenger;
Expand Down
73 changes: 72 additions & 1 deletion webtrit_callkeep_ios/ios/Classes/WebtritCallkeepPlugin.m
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
#import "NSUUID+v5.h"

static NSString *const OptionsKey = @"WebtritCallkeepPluginOptions";
static NSString *const kActiveCallUUIDsKey = @"WebtritCallkeepActiveCallUUIDs";
static NSString *const kEndedCallUUIDsKey = @"WebtritCallkeepEndedCallUUIDs";

@interface WebtritCallkeepPlugin ()<PKPushRegistryDelegate, CXProviderDelegate, WTPPushRegistryHostApi, WTPHostApi, WTPHostSoundApi>
@end
Expand Down Expand Up @@ -178,6 +180,40 @@ - (void)tearDown:(void (^)(FlutterError *))completion {
completion(nil);
}

// MARK: - Call state persistence (WT-1347)

- (void)_markUUIDActive:(NSString *)uuidString {
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
NSMutableArray *active = [([ud arrayForKey:kActiveCallUUIDsKey] ?: @[]) mutableCopy];
if (![active containsObject:uuidString]) [active addObject:uuidString];
[ud setObject:active forKey:kActiveCallUUIDsKey];
NSMutableDictionary *ended = [([ud dictionaryForKey:kEndedCallUUIDsKey] ?: @{}) mutableCopy];
[ended removeObjectForKey:uuidString];
[ud setObject:ended forKey:kEndedCallUUIDsKey];
}

- (void)_markUUIDEnded:(NSString *)uuidString {
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
NSMutableArray *active = [([ud arrayForKey:kActiveCallUUIDsKey] ?: @[]) mutableCopy];
[active removeObject:uuidString];
[ud setObject:active forKey:kActiveCallUUIDsKey];
NSMutableDictionary *ended = [([ud dictionaryForKey:kEndedCallUUIDsKey] ?: @{}) mutableCopy];
ended[uuidString] = @([[NSDate date] timeIntervalSince1970]);
[ud setObject:ended forKey:kEndedCallUUIDsKey];
}

- (void)_pruneEndedUUIDs {
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
NSMutableDictionary *ended = [([ud dictionaryForKey:kEndedCallUUIDsKey] ?: @{}) mutableCopy];
NSTimeInterval cutoff = [[NSDate date] timeIntervalSince1970] - 86400;
for (NSString *key in ended.allKeys) {
if ([ended[key] doubleValue] < cutoff) [ended removeObjectForKey:key];
}
[ud setObject:ended forKey:kEndedCallUUIDsKey];
}

// MARK: - WTPHostApi

- (void)reportNewIncomingCall:(NSString *)uuidString
handle:(WTPHandle *)handle
displayName:(NSString *)displayName
Expand All @@ -198,6 +234,7 @@ - (void)reportNewIncomingCall:(NSString *)uuidString
update:callUpdate
completion:^(NSError *error) {
if (error == nil) {
[self _markUUIDActive:uuidString];
[self assignIdleTimerDisabled:callUpdate.hasVideo];
completion(nil, nil);
} else if ([error.domain isEqualToString:CXErrorDomainIncomingCall]) {
Expand All @@ -215,6 +252,7 @@ - (void)reportConnectingOutgoingCall:(NSString *)uuidString
#ifdef DEBUG
NSLog(@"[Callkeep][reportConnectingOutgoingCall] uuidString = %@", uuidString);
#endif
[self _markUUIDActive:uuidString];
[_provider reportOutgoingCallWithUUID:[[NSUUID alloc] initWithUUIDString:uuidString]
startedConnectingAtDate:nil];
completion(nil);
Expand Down Expand Up @@ -272,7 +310,7 @@ - (void)reportEndCall:(NSString *)uuidString
#ifdef DEBUG
NSLog(@"[Callkeep][reportEndCall] uuidString = %@", uuidString);
#endif
[self _markUUIDEnded:uuidString];
[_provider reportCallWithUUID:[[NSUUID alloc] initWithUUIDString:uuidString]
endedAtDate:nil
reason:[reason toCallKit]];
Expand Down Expand Up @@ -417,6 +455,35 @@ - (void)sendDTMF:(NSString *)uuidString
[self requestTransaction:transaction completion:completion];
}

- (void)getConnectionWithUuidString:(NSString *)uuidString
completion:(void (^)(WTPCallkeepConnection *_Nullable, FlutterError *_Nullable))completion {
[self _pruneEndedUUIDs];
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
NSDictionary *ended = [ud dictionaryForKey:kEndedCallUUIDsKey] ?: @{};
NSArray *active = [ud arrayForKey:kActiveCallUUIDsKey] ?: @[];

WTPCallkeepConnectionState state;
if (ended[uuidString]) {
state = WTPCallkeepConnectionStateStateDisconnected;
} else if ([active containsObject:uuidString]) {
state = WTPCallkeepConnectionStateStateActive;
} else {
completion(nil, nil);
return;
}

// callId carries the UUID string here — the Dart layer substitutes the real callId
// before exposing the CallkeepConnection to callers (see webtrit_callkeep_ios.dart getConnection).
completion([WTPCallkeepConnection makeWithCallId:uuidString state:state], nil);
}

// NOTE: missed/auto-unanswered calls are covered by both write paths:
// 1. App-initiated: reportEndCall (Dart) → _markUUIDEnded (this file)
// 2. System-initiated (user swipes/CallKit timeout) → provider:performEndCallAction: → _markUUIDEnded
// A gap exists only if CallKit ends the call before _markUUIDActive was written (e.g. rejected
// at the CallKit level during reportNewIncomingCall). In that case getConnection returns nil,
// and HandshakeProcessor falls through to the orphan-outgoing branch for cleanup.

#pragma mark - WTPHostApi - helpers

- (void)requestTransaction:(CXTransaction *)transaction completion:(void (^)(WTPCallRequestError *, FlutterError *))completion {
Expand Down Expand Up @@ -632,6 +699,9 @@ - (void)didReceiveIncomingPushWithPayloadForPushTypeVoIP:(PKPushPayload *)payloa
}
}

if (incomingCallError == nil) {
[self _markUUIDActive:[uuid UUIDString]];
}
[self->_delegateFlutterApi didPushIncomingCallHandle:[callUpdate.remoteHandle toPigeon]
displayName:callUpdate.localizedCallerName
video:callUpdate.hasVideo
Expand Down Expand Up @@ -718,6 +788,7 @@ - (void)provider:(CXProvider *)provider performEndCallAction:(CXEndCallAction *)
#ifdef DEBUG
NSLog(@"[Callkeep][CXProviderDelegate][provider:performEndCallAction:]");
#endif
[self _markUUIDEnded:action.callUUID.UUIDString];
[_delegateFlutterApi performEndCall:action.callUUID.UUIDString
completion:^(NSNumber *fulfill, FlutterError *error) {
if (error != nil || [fulfill boolValue] != YES) {
Expand Down
Loading
Loading