diff --git a/CustomKeyboard/Info.plist b/CustomKeyboard/Info.plist index 6b0b101..baa47a8 100644 --- a/CustomKeyboard/Info.plist +++ b/CustomKeyboard/Info.plist @@ -6,6 +6,8 @@ kbkeyboardAppExtension + NSMicrophoneUsageDescription + 需要使用麦克风进行语音输入 NSExtension NSExtensionAttributes diff --git a/CustomKeyboard/KeyboardViewController.m b/CustomKeyboard/KeyboardViewController.m index 564c13c..ad4d5d7 100644 --- a/CustomKeyboard/KeyboardViewController.m +++ b/CustomKeyboard/KeyboardViewController.m @@ -18,11 +18,15 @@ #import "KBKeyboardSubscriptionProduct.h" #import "KBKeyboardSubscriptionView.h" #import "KBSettingView.h" +#import "KBChatMessage.h" +#import "KBChatPanelView.h" #import "KBSkinInstallBridge.h" #import "KBSkinManager.h" #import "KBSuggestionEngine.h" +#import "KBNetworkManager.h" #import "Masonry.h" #import "UIImage+KBColor.h" +#import // #import "KBLog.h" @@ -34,6 +38,8 @@ // 以 375 宽设计稿为基准的键盘总高度 static const CGFloat kKBKeyboardBaseHeight = 250.0f; +static const CGFloat kKBChatPanelHeight = 180; +static const NSUInteger kKBChatMessageLimit = 6; static NSString *const kKBDefaultSkinIdLight = @"normal_them"; static NSString *const kKBDefaultSkinZipNameLight = @"normal_them"; static NSString *const kKBDefaultSkinIdDark = @"normal_hei_them"; @@ -57,7 +63,8 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, @interface KeyboardViewController () + KBKeyboardSubscriptionViewDelegate, + KBChatPanelViewDelegate> @property(nonatomic, strong) UIButton *nextKeyboardButton; // 系统“下一个键盘”按钮(可选) @property(nonatomic, strong) UIView *contentView; @@ -67,16 +74,23 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, KBFunctionView *functionView; // 功能面板视图(点击工具栏第0个时显示) @property(nonatomic, strong) KBSettingView *settingView; // 设置页 @property(nonatomic, strong) UIImageView *bgImageView; // 背景图(在底层) +@property(nonatomic, strong) KBChatPanelView *chatPanelView; @property(nonatomic, strong) KBKeyboardSubscriptionView *subscriptionView; @property(nonatomic, strong) KBSuggestionEngine *suggestionEngine; @property(nonatomic, copy) NSString *currentWord; @property(nonatomic, assign) BOOL suppressSuggestions; @property(nonatomic, strong) MASConstraint *contentWidthConstraint; @property(nonatomic, strong) MASConstraint *contentHeightConstraint; +@property(nonatomic, strong) MASConstraint *keyBoardMainHeightConstraint; +@property(nonatomic, strong) MASConstraint *chatPanelHeightConstraint; @property(nonatomic, strong) NSLayoutConstraint *kb_heightConstraint; @property(nonatomic, strong) NSLayoutConstraint *kb_widthConstraint; @property(nonatomic, assign) CGFloat kb_lastPortraitWidth; @property(nonatomic, assign) CGFloat kb_lastKeyboardHeight; +@property(nonatomic, strong) NSMutableArray *chatMessages; +@property(nonatomic, strong) AVAudioPlayer *chatAudioPlayer; +@property(nonatomic, strong) NSCache *chatAvatarCache; +@property(nonatomic, assign) BOOL chatPanelVisible; @end @implementation KeyboardViewController @@ -167,6 +181,8 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, // 按“短边”宽度等比缩放,横屏保持竖屏布局比例 CGFloat portraitWidth = [self kb_portraitWidth]; CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth]; + CGFloat keyboardBaseHeight = [self kb_keyboardBaseHeightForWidth:portraitWidth]; + CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth]; CGFloat screenWidth = CGRectGetWidth([UIScreen mainScreen].bounds); CGFloat outerVerticalInset = KBFit(4.0f); @@ -210,8 +226,20 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, [self.contentView addSubview:self.keyBoardMainView]; [self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) { - make.edges.equalTo(self.contentView); + make.left.right.equalTo(self.contentView); + make.bottom.equalTo(self.contentView); + self.keyBoardMainHeightConstraint = + make.height.mas_equalTo(keyboardBaseHeight); }]; + + [self.contentView addSubview:self.chatPanelView]; + [self.chatPanelView mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.right.equalTo(self.contentView); + make.bottom.equalTo(self.keyBoardMainView.mas_top); + self.chatPanelHeightConstraint = + make.height.mas_equalTo(chatPanelHeight); + }]; + self.chatPanelView.hidden = YES; } #pragma mark - Private @@ -349,6 +377,9 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, /// 切换显示功能面板/键盘主视图 - (void)showFunctionPanel:(BOOL)show { // 简单显隐切换,复用相同的布局区域 + if (show) { + [self showChatPanel:NO]; + } self.functionView.hidden = !show; self.keyBoardMainView.hidden = show; @@ -378,6 +409,7 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, /// 显示/隐藏设置页(高度与 keyBoardMainView 一致),右侧滑入/滑出 - (void)showSettingView:(BOOL)show { if (show) { + [self showChatPanel:NO]; [[KBMaiPointReporter sharedReporter] reportPageExposureWithEventName:@"enter_keyboard_settings" pageId:@"keyboard_settings" @@ -436,6 +468,40 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, } } +/// 显示/隐藏聊天面板(覆盖整个键盘区域) +- (void)showChatPanel:(BOOL)show { + if (show == self.chatPanelVisible) { + return; + } + self.chatPanelVisible = show; + if (show) { + self.chatPanelView.hidden = NO; + self.chatPanelView.alpha = 0.0; + [self.contentView bringSubviewToFront:self.chatPanelView]; + self.functionView.hidden = YES; + [self hideSubscriptionPanel]; + [self showSettingView:NO]; + [UIView animateWithDuration:0.2 + delay:0 + options:UIViewAnimationOptionCurveEaseOut + animations:^{ + self.chatPanelView.alpha = 1.0; + } + completion:nil]; + } else { + [UIView animateWithDuration:0.18 + delay:0 + options:UIViewAnimationOptionCurveEaseIn + animations:^{ + self.chatPanelView.alpha = 0.0; + } + completion:^(BOOL finished) { + self.chatPanelView.hidden = YES; + }]; + } + [self kb_updateKeyboardLayoutIfNeeded]; +} + - (void)showSubscriptionPanel { // 1) 先判断权限:未开启“完全访问”则走引导逻辑 if (![[KBFullAccessManager shared] hasFullAccess]) { @@ -544,6 +610,10 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, [[KBInputBufferManager shared] appendText:@" "]; break; case KBKeyTypeReturn: + if (self.chatPanelVisible) { + [self kb_handleChatSendAction]; + break; + } [[KBBackspaceUndoManager shared] registerNonClearAction]; [self.textDocumentProxy insertText:@"\n"]; [self kb_clearCurrentWord]; @@ -575,11 +645,18 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, extra:extra completion:nil]; if (index == 0) { + [self showChatPanel:NO]; [self showFunctionPanel:YES]; [self kb_clearCurrentWord]; return; } + if (index == 1) { + [self showFunctionPanel:NO]; + [self showChatPanel:YES]; + return; + } [self showFunctionPanel:NO]; + [self showChatPanel:NO]; } - (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView { @@ -697,6 +774,399 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, [self showSubscriptionPanel]; } +#pragma mark - KBChatPanelViewDelegate + +- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text { + NSString *trim = + [text stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (trim.length == 0) { + return; + } + [self kb_sendChatText:trim]; +} + +- (void)chatPanelView:(KBChatPanelView *)view + didTapMessage:(KBChatMessage *)message { + if (message.audioFilePath.length == 0) { + return; + } + [self kb_playChatAudioAtPath:message.audioFilePath]; +} + +#pragma mark - Chat Helpers + +- (void)kb_handleChatSendAction { + if (!self.chatPanelVisible) { + return; + } + [[KBInputBufferManager shared] refreshFromProxyIfPossible:self.textDocumentProxy]; + NSString *rawText = [KBInputBufferManager shared].liveText ?: @""; + NSString *trim = + [rawText stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (trim.length == 0) { + [KBHUD showInfo:KBLocalized(@"请输入内容")]; + return; + } + [self kb_sendChatText:trim]; + [self kb_clearHostInputForText:rawText]; +} + +- (void)kb_sendChatText:(NSString *)text { + if (text.length == 0) { + return; + } + KBChatMessage *outgoing = + [KBChatMessage messageWithText:text outgoing:YES audioFilePath:nil]; + outgoing.avatarURL = [self kb_sharedUserAvatarURL]; + [self kb_appendChatMessage:outgoing]; + [self kb_prefetchAvatarForMessage:outgoing]; + + if (![[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]) { + [KBHUD showInfo:KBLocalized(@"请开启完全访问后使用")]; + return; + } + [self kb_requestChatAudioForText:text]; +} + +- (void)kb_clearHostInputForText:(NSString *)text { + if (text.length == 0) { + return; + } + NSUInteger count = [self kb_composedCharacterCountForString:text]; + for (NSUInteger i = 0; i < count; i++) { + [self.textDocumentProxy deleteBackward]; + } + [[KBInputBufferManager shared] clearAllLiveText]; + [self kb_clearCurrentWord]; +} + +- (NSUInteger)kb_composedCharacterCountForString:(NSString *)text { + if (text.length == 0) { + return 0; + } + __block NSUInteger count = 0; + [text enumerateSubstringsInRange:NSMakeRange(0, text.length) + options:NSStringEnumerationByComposedCharacterSequences + usingBlock:^(__unused NSString *substring, + __unused NSRange substringRange, + __unused NSRange enclosingRange, + __unused BOOL *stop) { + count += 1; + }]; + return count; +} + +- (NSString *)kb_sharedUserAvatarURL { + NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup]; + NSString *url = [ud stringForKey:AppGroup_UserAvatarURL]; + return url ?: @""; +} + +- (void)kb_prefetchAvatarForMessage:(KBChatMessage *)message { + if (!message || message.avatarImage) { + return; + } + NSString *urlString = message.avatarURL ?: @""; + if (urlString.length == 0) { + return; + } + UIImage *cached = [self.chatAvatarCache objectForKey:urlString]; + if (cached) { + message.avatarImage = cached; + [self.chatPanelView kb_reloadWithMessages:self.chatMessages]; + return; + } + if (![[KBFullAccessManager shared] hasFullAccess]) { + return; + } + __weak typeof(self) weakSelf = self; + [[KBNetworkManager shared] + GETData:urlString + parameters:nil + headers:nil + completion:^(NSData *data, NSURLResponse *response, NSError *error) { + if (error || data.length == 0) { + return; + } + UIImage *image = [UIImage imageWithData:data]; + if (!image) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + __strong typeof(weakSelf) self = weakSelf; + if (!self) { + return; + } + [self.chatAvatarCache setObject:image forKey:urlString]; + message.avatarImage = image; + [self.chatPanelView kb_reloadWithMessages:self.chatMessages]; + }); + }]; +} + +- (void)kb_requestChatAudioForText:(NSString *)text { + NSString *mockPath = [self kb_mockChatAudioPath]; + if (mockPath.length > 0) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.35 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + NSString *displayText = KBLocalized(@"语音回复"); + KBChatMessage *incoming = + [KBChatMessage messageWithText:displayText + outgoing:NO + audioFilePath:mockPath]; + incoming.displayName = KBLocalized(@"AI助手"); + [self kb_appendChatMessage:incoming]; + [self kb_playChatAudioAtPath:mockPath]; + }); + return; + } + NSDictionary *payload = @{@"message" : text ?: @""}; + __weak typeof(self) weakSelf = self; + [[KBNetworkManager shared] POST:API_AI_TALK + jsonBody:payload + headers:nil + completion:^(NSDictionary *json, NSURLResponse *response, + NSError *error) { + dispatch_async(dispatch_get_main_queue(), ^{ + __strong typeof(weakSelf) self = weakSelf; + if (!self) { + return; + } + if (error) { + NSString *tip = error.localizedDescription + ?: KBLocalized(@"请求失败"); + [KBHUD showInfo:tip]; + return; + } + NSString *displayText = + [self kb_chatTextFromJSON:json]; + NSString *audioURL = + [self kb_chatAudioURLFromJSON:json]; + NSString *audioBase64 = + [self kb_chatAudioBase64FromJSON:json]; + if (audioURL.length > 0) { + [self kb_downloadChatAudioFromURL:audioURL + displayText:displayText]; + return; + } + if (audioBase64.length > 0) { + NSData *data = [[NSData alloc] + initWithBase64EncodedString:audioBase64 + options:0]; + if (data.length == 0) { + [KBHUD showInfo:KBLocalized(@"音频数据解析失败")]; + return; + } + [self kb_handleChatAudioData:data + fileExtension:@"m4a" + displayText:displayText]; + return; + } + [KBHUD showInfo:KBLocalized(@"未获取到音频文件")]; + }); + }]; +} + +- (void)kb_downloadChatAudioFromURL:(NSString *)audioURL + displayText:(NSString *)displayText { + __weak typeof(self) weakSelf = self; + [[KBNetworkManager shared] + GETData:audioURL + parameters:nil + headers:nil + completion:^(NSData *data, NSURLResponse *response, NSError *error) { + dispatch_async(dispatch_get_main_queue(), ^{ + __strong typeof(weakSelf) self = weakSelf; + if (!self) { + return; + } + if (error) { + NSString *tip = error.localizedDescription ?: KBLocalized(@"下载失败"); + [KBHUD showInfo:tip]; + return; + } + if (data.length == 0) { + [KBHUD showInfo:KBLocalized(@"未获取到音频数据")]; + return; + } + NSString *ext = @"m4a"; + NSURL *url = [NSURL URLWithString:audioURL]; + if (url.pathExtension.length > 0) { + ext = url.pathExtension; + } + [self kb_handleChatAudioData:data + fileExtension:ext + displayText:displayText]; + }); + }]; +} + +- (void)kb_handleChatAudioData:(NSData *)data + fileExtension:(NSString *)extension + displayText:(NSString *)displayText { + if (data.length == 0) { + [KBHUD showInfo:KBLocalized(@"音频数据为空")]; + return; + } + NSString *ext = extension.length > 0 ? extension : @"m4a"; + NSString *fileName = [NSString + stringWithFormat:@"kb_chat_%@.%@", + @((long long)([NSDate date].timeIntervalSince1970 * + 1000)), + ext]; + NSString *filePath = + [NSTemporaryDirectory() stringByAppendingPathComponent:fileName]; + if (![data writeToFile:filePath atomically:YES]) { + [KBHUD showInfo:KBLocalized(@"音频保存失败")]; + return; + } + NSString *text = displayText.length > 0 ? displayText : KBLocalized(@"语音消息"); + KBChatMessage *incoming = + [KBChatMessage messageWithText:text + outgoing:NO + audioFilePath:filePath]; + incoming.displayName = KBLocalized(@"AI助手"); + [self kb_appendChatMessage:incoming]; +} + +- (void)kb_appendChatMessage:(KBChatMessage *)message { + if (!message) { + return; + } + [self.chatMessages addObject:message]; + if (self.chatMessages.count > kKBChatMessageLimit) { + NSUInteger overflow = self.chatMessages.count - kKBChatMessageLimit; + NSArray *removed = + [self.chatMessages subarrayWithRange:NSMakeRange(0, overflow)]; + [self.chatMessages removeObjectsInRange:NSMakeRange(0, overflow)]; + for (KBChatMessage *msg in removed) { + if (msg.audioFilePath.length > 0) { + NSString *tmpRoot = NSTemporaryDirectory(); + if (tmpRoot.length > 0 && + [msg.audioFilePath hasPrefix:tmpRoot]) { + [[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath + error:nil]; + } + } + } + } + [self.chatPanelView kb_reloadWithMessages:self.chatMessages]; +} + +- (NSString *)kb_mockChatAudioPath { + NSString *path = [[NSBundle mainBundle] pathForResource:@"ai_test" + ofType:@"m4a"]; + return path ?: @""; +} + +- (NSString *)kb_chatTextFromJSON:(NSDictionary *)json { + NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json]; + NSString *text = + [self kb_stringValueInDict:data + keys:@[ @"text", @"message", @"content" ]]; + if (text.length == 0) { + text = [self kb_stringValueInDict:json + keys:@[ @"text", @"message", @"content" ]]; + } + return text ?: @""; +} + +- (NSString *)kb_chatAudioURLFromJSON:(NSDictionary *)json { + NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json]; + NSArray *keys = + @[ @"audioUrl", @"audioURL", @"audio_url", @"url", @"fileUrl", + @"file_url", @"audioFileUrl", @"audio_file_url" ]; + NSString *url = [self kb_stringValueInDict:data keys:keys]; + if (url.length == 0) { + url = [self kb_stringValueInDict:json keys:keys]; + } + return url ?: @""; +} + +- (NSString *)kb_chatAudioBase64FromJSON:(NSDictionary *)json { + NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json]; + NSArray *keys = + @[ @"audioBase64", @"audio_base64", @"audioData", @"audio_data", + @"base64" ]; + NSString *b64 = [self kb_stringValueInDict:data keys:keys]; + if (b64.length == 0) { + b64 = [self kb_stringValueInDict:json keys:keys]; + } + return b64 ?: @""; +} + +- (NSDictionary *)kb_chatDataDictionaryFromJSON:(NSDictionary *)json { + if (![json isKindOfClass:[NSDictionary class]]) { + return @{}; + } + id dataObj = json[@"data"] ?: json[@"result"] ?: json[@"response"]; + if ([dataObj isKindOfClass:[NSDictionary class]]) { + return (NSDictionary *)dataObj; + } + return @{}; +} + +- (NSString *)kb_stringValueInDict:(NSDictionary *)dict + keys:(NSArray *)keys { + if (![dict isKindOfClass:[NSDictionary class]]) { + return @""; + } + for (NSString *key in keys) { + id value = dict[key]; + if ([value isKindOfClass:[NSString class]] && + ((NSString *)value).length > 0) { + return (NSString *)value; + } + } + return @""; +} + +- (void)kb_playChatAudioAtPath:(NSString *)path { + if (path.length == 0) { + return; + } + NSURL *url = [NSURL fileURLWithPath:path]; + if (![NSFileManager.defaultManager fileExistsAtPath:path]) { + [KBHUD showInfo:KBLocalized(@"音频文件不存在")]; + return; + } + + if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) { + NSURL *currentURL = self.chatAudioPlayer.url; + if ([currentURL isEqual:url]) { + [self.chatAudioPlayer stop]; + self.chatAudioPlayer = nil; + return; + } + [self.chatAudioPlayer stop]; + self.chatAudioPlayer = nil; + } + + NSError *sessionError = nil; + AVAudioSession *session = [AVAudioSession sharedInstance]; + if ([session respondsToSelector:@selector(setCategory:options:error:)]) { + [session setCategory:AVAudioSessionCategoryPlayback + withOptions:AVAudioSessionCategoryOptionDuckOthers + error:&sessionError]; + } else { + [session setCategory:AVAudioSessionCategoryPlayback error:&sessionError]; + } + [session setActive:YES error:nil]; + + NSError *playerError = nil; + AVAudioPlayer *player = + [[AVAudioPlayer alloc] initWithContentsOfURL:url error:&playerError]; + if (playerError || !player) { + [KBHUD showInfo:KBLocalized(@"音频播放失败")]; + return; + } + self.chatAudioPlayer = player; + [player prepareToPlay]; + [player play]; +} + #pragma mark - KBKeyboardSubscriptionViewDelegate - (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view { @@ -750,6 +1220,28 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, return _settingView; } +- (KBChatPanelView *)chatPanelView { + if (!_chatPanelView) { + _chatPanelView = [[KBChatPanelView alloc] init]; + _chatPanelView.delegate = self; + } + return _chatPanelView; +} + +- (NSMutableArray *)chatMessages { + if (!_chatMessages) { + _chatMessages = [NSMutableArray array]; + } + return _chatMessages; +} + +- (NSCache *)chatAvatarCache { + if (!_chatAvatarCache) { + _chatAvatarCache = [[NSCache alloc] init]; + } + return _chatAvatarCache; +} + - (KBKeyboardSubscriptionView *)subscriptionView { if (!_subscriptionView) { _subscriptionView = [[KBKeyboardSubscriptionView alloc] init]; @@ -1150,12 +1642,36 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, if (width <= 0) { width = KB_DESIGN_WIDTH; } - return kKBKeyboardBaseHeight * (width / KB_DESIGN_WIDTH); + CGFloat scale = width / KB_DESIGN_WIDTH; + CGFloat baseHeight = kKBKeyboardBaseHeight * scale; + CGFloat chatHeight = kKBChatPanelHeight * scale; + if (self.chatPanelVisible) { + return baseHeight + chatHeight; + } + return baseHeight; +} + +- (CGFloat)kb_keyboardBaseHeightForWidth:(CGFloat)width { + if (width <= 0) { + width = KB_DESIGN_WIDTH; + } + CGFloat scale = width / KB_DESIGN_WIDTH; + return kKBKeyboardBaseHeight * scale; +} + +- (CGFloat)kb_chatPanelHeightForWidth:(CGFloat)width { + if (width <= 0) { + width = KB_DESIGN_WIDTH; + } + CGFloat scale = width / KB_DESIGN_WIDTH; + return kKBChatPanelHeight * scale; } - (void)kb_updateKeyboardLayoutIfNeeded { CGFloat portraitWidth = [self kb_portraitWidth]; CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth]; + CGFloat keyboardBaseHeight = [self kb_keyboardBaseHeightForWidth:portraitWidth]; + CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth]; CGFloat containerWidth = CGRectGetWidth(self.view.superview.bounds); if (containerWidth <= 0) { containerWidth = CGRectGetWidth(self.view.window.bounds); @@ -1186,6 +1702,12 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, if (self.contentHeightConstraint) { [self.contentHeightConstraint setOffset:keyboardHeight]; } + if (self.keyBoardMainHeightConstraint) { + [self.keyBoardMainHeightConstraint setOffset:keyboardBaseHeight]; + } + if (self.chatPanelHeightConstraint) { + [self.chatPanelHeightConstraint setOffset:chatPanelHeight]; + } [self.view layoutIfNeeded]; } diff --git a/CustomKeyboard/Model/KBChatMessage.h b/CustomKeyboard/Model/KBChatMessage.h new file mode 100644 index 0000000..acf69f4 --- /dev/null +++ b/CustomKeyboard/Model/KBChatMessage.h @@ -0,0 +1,26 @@ +// +// KBChatMessage.h +// CustomKeyboard +// + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface KBChatMessage : NSObject + +@property (nonatomic, copy) NSString *text; +@property (nonatomic, assign) BOOL outgoing; +@property (nonatomic, copy, nullable) NSString *audioFilePath; +@property (nonatomic, copy, nullable) NSString *avatarURL; +@property (nonatomic, copy, nullable) NSString *displayName; +@property (nonatomic, strong, nullable) UIImage *avatarImage; + ++ (instancetype)messageWithText:(NSString *)text + outgoing:(BOOL)outgoing + audioFilePath:(nullable NSString *)audioFilePath; + +@end + +NS_ASSUME_NONNULL_END diff --git a/CustomKeyboard/Model/KBChatMessage.m b/CustomKeyboard/Model/KBChatMessage.m new file mode 100644 index 0000000..ce85acf --- /dev/null +++ b/CustomKeyboard/Model/KBChatMessage.m @@ -0,0 +1,20 @@ +// +// KBChatMessage.m +// CustomKeyboard +// + +#import "KBChatMessage.h" + +@implementation KBChatMessage + ++ (instancetype)messageWithText:(NSString *)text + outgoing:(BOOL)outgoing + audioFilePath:(NSString *)audioFilePath { + KBChatMessage *msg = [[KBChatMessage alloc] init]; + msg.text = text ?: @""; + msg.outgoing = outgoing; + msg.audioFilePath = audioFilePath; + return msg; +} + +@end diff --git a/CustomKeyboard/Network/KBNetworkManager.h b/CustomKeyboard/Network/KBNetworkManager.h index b319dac..da0a96f 100644 --- a/CustomKeyboard/Network/KBNetworkManager.h +++ b/CustomKeyboard/Network/KBNetworkManager.h @@ -64,6 +64,15 @@ typedef void(^KBNetworkDataCompletion)(NSData *_Nullable data, headers:(nullable NSDictionary *)headers completion:(KBNetworkCompletion)completion; +/// POST multipart 上传文件(常用于语音/图片等文件) +- (nullable NSURLSessionDataTask *)uploadFile:(NSString *)path + fileURL:(NSURL *)fileURL + name:(NSString *)name + mimeType:(NSString *)mimeType + parameters:(nullable NSDictionary *)parameters + headers:(nullable NSDictionary *)headers + completion:(KBNetworkCompletion)completion; + @end NS_ASSUME_NONNULL_END diff --git a/CustomKeyboard/Network/KBNetworkManager.m b/CustomKeyboard/Network/KBNetworkManager.m index 0d9103e..5e1385a 100644 --- a/CustomKeyboard/Network/KBNetworkManager.m +++ b/CustomKeyboard/Network/KBNetworkManager.m @@ -124,6 +124,84 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network"; return [self startAFJSONTaskWithRequest:req completion:completion]; } +- (NSURLSessionDataTask *)uploadFile:(NSString *)path + fileURL:(NSURL *)fileURL + name:(NSString *)name + mimeType:(NSString *)mimeType + parameters:(NSDictionary *)parameters + headers:(NSDictionary *)headers + completion:(KBNetworkCompletion)completion { + [self getSignWithParare:parameters]; + if (![self ensureEnabled:completion]) return nil; + NSString *urlString = [self buildURLStringWithPath:path]; + if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; } + if (!fileURL) { + if (completion) completion(nil, nil, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid file")}]); + return nil; + } + AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer]; + serializer.timeoutInterval = self.timeout; + NSError *error = nil; + NSMutableURLRequest *req = [serializer multipartFormRequestWithMethod:@"POST" + URLString:urlString + parameters:parameters + constructingBodyWithBlock:^(id formData) { + NSString *safeName = (name.length > 0) ? name : @"file"; + NSString *fileName = fileURL.lastPathComponent ?: @"upload.bin"; + NSString *type = (mimeType.length > 0) ? mimeType : @"application/octet-stream"; + [formData appendPartWithFileURL:fileURL name:safeName fileName:fileName mimeType:type error:nil]; + } error:&error]; + if (error || !req) { + if (completion) completion(nil, nil, error ?: [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidURL userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}]); + return nil; + } + [self applyHeaders:headers toMutableRequest:req contentType:nil]; + self.manager.responseSerializer = [AFHTTPResponseSerializer serializer]; + NSURLSessionUploadTask *task = [self.manager uploadTaskWithStreamedRequest:req progress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) { + if (error) { + if (completion) completion(nil, response, error); + return; + } + NSData *data = (NSData *)responseObject; + if (![data isKindOfClass:[NSData class]]) { + if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"No data")}]); + return; + } + NSString *ct = nil; + if ([response isKindOfClass:[NSHTTPURLResponse class]]) { + ct = ((NSHTTPURLResponse *)response).allHeaderFields[@"Content-Type"]; + } + BOOL looksJSON = (ct && [[ct lowercaseString] containsString:@"json"]); + if (!looksJSON) { + const unsigned char *bytes = data.bytes; + NSUInteger len = data.length; + for (NSUInteger i = 0; !looksJSON && i < len; i++) { + unsigned char c = bytes[i]; + if (c == ' ' || c == '\n' || c == '\r' || c == '\t') continue; + looksJSON = (c == '{' || c == '['); + break; + } + } + if (looksJSON) { + NSError *jsonErr = nil; + id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonErr]; + if (jsonErr) { + if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDecodeFailed userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Failed to parse JSON")}]); + return; + } + if (![json isKindOfClass:[NSDictionary class]]) { + if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]); + return; + } + if (completion) completion((NSDictionary *)json, response, nil); + } else { + if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]); + } + }]; + [task resume]; + return task; +} + - (NSURLSessionDataTask *)GETData:(NSString *)path parameters:(NSDictionary *)parameters headers:(NSDictionary *)headers diff --git a/CustomKeyboard/Resource/ai_test.m4a b/CustomKeyboard/Resource/ai_test.m4a new file mode 100644 index 0000000..9039275 Binary files /dev/null and b/CustomKeyboard/Resource/ai_test.m4a differ diff --git a/CustomKeyboard/View/KBChatMessageCell.h b/CustomKeyboard/View/KBChatMessageCell.h new file mode 100644 index 0000000..1414a7c --- /dev/null +++ b/CustomKeyboard/View/KBChatMessageCell.h @@ -0,0 +1,17 @@ +// +// KBChatMessageCell.h +// CustomKeyboard +// + +#import +@class KBChatMessage; + +NS_ASSUME_NONNULL_BEGIN + +@interface KBChatMessageCell : UITableViewCell + +- (void)kb_configureWithMessage:(KBChatMessage *)message; + +@end + +NS_ASSUME_NONNULL_END diff --git a/CustomKeyboard/View/KBChatMessageCell.m b/CustomKeyboard/View/KBChatMessageCell.m new file mode 100644 index 0000000..fc4ef37 --- /dev/null +++ b/CustomKeyboard/View/KBChatMessageCell.m @@ -0,0 +1,194 @@ +// +// KBChatMessageCell.m +// CustomKeyboard +// + +#import "KBChatMessageCell.h" +#import "KBChatMessage.h" +#import "Masonry.h" + +@interface KBChatMessageCell () +@property (nonatomic, strong) UIImageView *avatarView; +@property (nonatomic, strong) UILabel *nameLabel; +@property (nonatomic, strong) UIView *bubbleView; +@property (nonatomic, strong) UILabel *messageLabel; +@property (nonatomic, strong) UIImageView *audioIconView; +@property (nonatomic, strong) UILabel *audioLabel; +@end + +@implementation KBChatMessageCell + +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier { + if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) { + self.backgroundColor = [UIColor clearColor]; + self.selectionStyle = UITableViewCellSelectionStyleNone; + + [self.contentView addSubview:self.avatarView]; + [self.contentView addSubview:self.nameLabel]; + [self.contentView addSubview:self.bubbleView]; + [self.bubbleView addSubview:self.messageLabel]; + [self.bubbleView addSubview:self.audioIconView]; + [self.bubbleView addSubview:self.audioLabel]; + } + return self; +} + +- (void)kb_configureWithMessage:(KBChatMessage *)message { + BOOL outgoing = message.outgoing; + BOOL audioMessage = (!outgoing && message.audioFilePath.length > 0); + UIColor *bubbleColor = outgoing ? [UIColor colorWithHex:0x02BEAC] : [UIColor colorWithWhite:1 alpha:0.95]; + UIColor *textColor = outgoing ? [UIColor whiteColor] : [UIColor colorWithHex:0x1B1F1A]; + + self.bubbleView.backgroundColor = bubbleColor; + self.messageLabel.textColor = textColor; + self.audioLabel.textColor = textColor; + self.audioIconView.tintColor = textColor; + self.messageLabel.text = message.text ?: @""; + self.audioLabel.text = + (message.text.length > 0) ? message.text : KBLocalized(@"语音回复"); + self.messageLabel.hidden = audioMessage; + self.audioIconView.hidden = !audioMessage; + self.audioLabel.hidden = !audioMessage; + + UIImage *avatarImage = message.avatarImage; + if (!avatarImage) { + avatarImage = [self kb_defaultAvatarImage]; + } + self.avatarView.image = avatarImage; + self.avatarView.backgroundColor = + avatarImage ? [UIColor clearColor] : [UIColor colorWithWhite:0.9 alpha:1.0]; + self.nameLabel.hidden = outgoing; + self.nameLabel.text = + (message.displayName.length > 0) ? message.displayName : KBLocalized(@"AI助手"); + + CGFloat avatarSize = 28.0; + [self.avatarView mas_remakeConstraints:^(MASConstraintMaker *make) { + make.width.height.mas_equalTo(avatarSize); + make.top.equalTo(self.contentView.mas_top).offset(6); + if (outgoing) { + make.right.equalTo(self.contentView.mas_right).offset(-8); + } else { + make.left.equalTo(self.contentView.mas_left).offset(8); + } + }]; + + if (outgoing) { + [self.nameLabel mas_remakeConstraints:^(MASConstraintMaker *make) { + make.top.equalTo(self.contentView.mas_top).offset(0); + make.left.equalTo(self.contentView.mas_left); + }]; + } else { + [self.nameLabel mas_remakeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(self.avatarView.mas_right).offset(6); + make.top.equalTo(self.contentView.mas_top).offset(2); + make.right.lessThanOrEqualTo(self.contentView.mas_right).offset(-12); + }]; + } + + [self.bubbleView mas_remakeConstraints:^(MASConstraintMaker *make) { + make.width.lessThanOrEqualTo(self.contentView.mas_width).multipliedBy(0.65); + if (outgoing) { + make.top.equalTo(self.contentView.mas_top).offset(6); + make.bottom.equalTo(self.contentView.mas_bottom).offset(-6); + make.right.equalTo(self.avatarView.mas_left).offset(-6); + } else { + make.top.equalTo(self.nameLabel.mas_bottom).offset(2); + make.bottom.equalTo(self.contentView.mas_bottom).offset(-6); + make.left.equalTo(self.avatarView.mas_right).offset(6); + make.right.lessThanOrEqualTo(self.contentView.mas_right).offset(-12); + } + }]; + + if (audioMessage) { + [self.audioIconView mas_remakeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(self.bubbleView.mas_left).offset(10); + make.centerY.equalTo(self.bubbleView); + make.width.height.mas_equalTo(16); + }]; + [self.audioLabel mas_remakeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(self.audioIconView.mas_right).offset(6); + make.centerY.equalTo(self.bubbleView); + make.right.equalTo(self.bubbleView.mas_right).offset(-10); + make.top.greaterThanOrEqualTo(self.bubbleView.mas_top).offset(8); + make.bottom.lessThanOrEqualTo(self.bubbleView.mas_bottom).offset(-8); + }]; + } else { + [self.messageLabel mas_remakeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.bubbleView).insets(UIEdgeInsetsMake(8, 10, 8, 10)); + }]; + } +} + +#pragma mark - Lazy + +- (UIImageView *)avatarView { + if (!_avatarView) { + _avatarView = [[UIImageView alloc] init]; + _avatarView.contentMode = UIViewContentModeScaleAspectFill; + _avatarView.layer.cornerRadius = 14; + _avatarView.layer.masksToBounds = YES; + _avatarView.backgroundColor = [UIColor colorWithWhite:0.9 alpha:1.0]; + _avatarView.tintColor = [UIColor colorWithHex:0xB9BDC8]; + } + return _avatarView; +} + +- (UILabel *)nameLabel { + if (!_nameLabel) { + _nameLabel = [[UILabel alloc] init]; + _nameLabel.font = [UIFont systemFontOfSize:11]; + _nameLabel.textColor = [UIColor colorWithHex:0x6B6F7A]; + _nameLabel.numberOfLines = 1; + } + return _nameLabel; +} + +- (UIView *)bubbleView { + if (!_bubbleView) { + _bubbleView = [[UIView alloc] init]; + _bubbleView.layer.cornerRadius = 12; + _bubbleView.layer.masksToBounds = YES; + } + return _bubbleView; +} + +- (UILabel *)messageLabel { + if (!_messageLabel) { + _messageLabel = [[UILabel alloc] init]; + _messageLabel.font = [UIFont systemFontOfSize:14]; + _messageLabel.numberOfLines = 0; + } + return _messageLabel; +} + +- (UIImageView *)audioIconView { + if (!_audioIconView) { + _audioIconView = [[UIImageView alloc] init]; + _audioIconView.contentMode = UIViewContentModeScaleAspectFit; + _audioIconView.tintColor = [UIColor colorWithHex:0x1B1F1A]; + UIImage *icon = nil; + if (@available(iOS 13.0, *)) { + icon = [UIImage systemImageNamed:@"waveform"]; + } + _audioIconView.image = icon; + } + return _audioIconView; +} + +- (UILabel *)audioLabel { + if (!_audioLabel) { + _audioLabel = [[UILabel alloc] init]; + _audioLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium]; + _audioLabel.numberOfLines = 1; + } + return _audioLabel; +} + +- (UIImage *)kb_defaultAvatarImage { + if (@available(iOS 13.0, *)) { + return [UIImage systemImageNamed:@"person.circle.fill"]; + } + return nil; +} + +@end diff --git a/CustomKeyboard/View/KBChatPanelView.h b/CustomKeyboard/View/KBChatPanelView.h new file mode 100644 index 0000000..255ffec --- /dev/null +++ b/CustomKeyboard/View/KBChatPanelView.h @@ -0,0 +1,27 @@ +// +// KBChatPanelView.h +// CustomKeyboard +// + +#import +@class KBChatPanelView, KBChatMessage; + +NS_ASSUME_NONNULL_BEGIN + +@protocol KBChatPanelViewDelegate +@optional +- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text; +- (void)chatPanelView:(KBChatPanelView *)view didTapMessage:(KBChatMessage *)message; +@end + +@interface KBChatPanelView : UIView + +@property (nonatomic, weak) id delegate; + +@property (nonatomic, strong, readonly) UITableView *tableView; + +- (void)kb_reloadWithMessages:(NSArray *)messages; + +@end + +NS_ASSUME_NONNULL_END diff --git a/CustomKeyboard/View/KBChatPanelView.m b/CustomKeyboard/View/KBChatPanelView.m new file mode 100644 index 0000000..bfb25b2 --- /dev/null +++ b/CustomKeyboard/View/KBChatPanelView.m @@ -0,0 +1,97 @@ +// +// KBChatPanelView.m +// CustomKeyboard +// + +#import "KBChatPanelView.h" +#import "KBChatMessage.h" +#import "KBChatMessageCell.h" +#import "Masonry.h" + +@interface KBChatPanelView () +@property (nonatomic, strong) UITableView *tableViewInternal; +@property (nonatomic, copy) NSArray *messages; +@end + +@implementation KBChatPanelView + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + self.backgroundColor = [UIColor colorWithHex:0xD1D3DB]; + + [self addSubview:self.tableViewInternal]; + + [self.tableViewInternal mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.right.equalTo(self); + make.top.equalTo(self.mas_top).offset(8); + make.bottom.equalTo(self.mas_bottom).offset(-8); + }]; + } + return self; +} + +#pragma mark - Public + +- (void)kb_reloadWithMessages:(NSArray *)messages { + self.messages = messages ?: @[]; + [self.tableViewInternal reloadData]; + if (self.messages.count > 0) { + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:self.messages.count - 1 inSection:0]; + [self.tableViewInternal scrollToRowAtIndexPath:indexPath + atScrollPosition:UITableViewScrollPositionBottom + animated:YES]; + } +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + return self.messages.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + KBChatMessageCell *cell = [tableView dequeueReusableCellWithIdentifier:NSStringFromClass(KBChatMessageCell.class)]; + KBChatMessage *msg = self.messages[indexPath.row]; + [cell kb_configureWithMessage:msg]; + return cell; +} + +#pragma mark - UITableViewDelegate + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { + return UITableViewAutomaticDimension; +} + +- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath { + return 44.0; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + if (indexPath.row >= self.messages.count) { return; } + KBChatMessage *msg = self.messages[indexPath.row]; + if ([self.delegate respondsToSelector:@selector(chatPanelView:didTapMessage:)]) { + [self.delegate chatPanelView:self didTapMessage:msg]; + } +} + +#pragma mark - Lazy + +- (UITableView *)tableViewInternal { + if (!_tableViewInternal) { + _tableViewInternal = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain]; + _tableViewInternal.backgroundColor = [UIColor clearColor]; + _tableViewInternal.separatorStyle = UITableViewCellSeparatorStyleNone; + _tableViewInternal.dataSource = self; + _tableViewInternal.delegate = self; + _tableViewInternal.estimatedRowHeight = 44.0; + _tableViewInternal.rowHeight = UITableViewAutomaticDimension; + [_tableViewInternal registerClass:KBChatMessageCell.class forCellReuseIdentifier:NSStringFromClass(KBChatMessageCell.class)]; + } + return _tableViewInternal; +} + +#pragma mark - Expose + +- (UITableView *)tableView { return self.tableViewInternal; } + +@end diff --git a/CustomKeyboard/View/KBToolBar.h b/CustomKeyboard/View/KBToolBar.h index 5bb4553..1b8eeab 100644 --- a/CustomKeyboard/View/KBToolBar.h +++ b/CustomKeyboard/View/KBToolBar.h @@ -26,7 +26,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, weak, nullable) id delegate; -/// 左侧按钮的标题(数量由数组决定)。默认值:@[@"AI"]。 +/// 左侧按钮的标题(数量由数组决定)。默认值:@[@"AI", @"语音"]。 @property (nonatomic, copy) NSArray *leftButtonTitles; /// 暴露按钮以便外部定制(只读;首次访问时懒加载创建) diff --git a/CustomKeyboard/View/KBToolBar.m b/CustomKeyboard/View/KBToolBar.m index 3eeec40..b15106b 100644 --- a/CustomKeyboard/View/KBToolBar.m +++ b/CustomKeyboard/View/KBToolBar.m @@ -26,11 +26,12 @@ static NSString * const kKBAIKeyIdentifier = @"ai"; static NSString * const kKBUndoKeyIdentifier = @"key_revoke"; static const CGFloat kKBAIButtonWidth = 40; static const CGFloat kKBAIButtonHeight = 40; +static const NSInteger kKBVoiceButtonIndex = 1; - (instancetype)initWithFrame:(CGRect)frame{ if (self = [super initWithFrame:frame]) { self.backgroundColor = [UIColor clearColor]; - _leftButtonTitles = @[@"AI"]; // 默认标题 + _leftButtonTitles = @[@"AI", KBLocalized(@"语音")]; // 默认标题 [self setupUI]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(kb_undoStateChanged) @@ -68,6 +69,7 @@ static const CGFloat kKBAIButtonHeight = 40; } }]; [self kb_updateAIButtonAppearance]; + [self kb_updateVoiceButtonAppearance]; } #pragma mark - 视图搭建 @@ -171,6 +173,7 @@ static const CGFloat kKBAIButtonHeight = 40; - (void)kb_applyTheme { [self kb_updateAIButtonAppearance]; + [self kb_updateVoiceButtonAppearance]; [self kb_updateUndoButtonAppearance]; } @@ -208,6 +211,16 @@ static const CGFloat kKBAIButtonHeight = 40; } } +- (void)kb_updateVoiceButtonAppearance { + UIButton *voiceButton = [self kb_voiceButton]; + if (!voiceButton) { return; } + + voiceButton.backgroundColor = [UIColor colorWithHex:0xE53935]; + [voiceButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; + voiceButton.layer.cornerRadius = 16; + voiceButton.layer.masksToBounds = YES; +} + - (void)kb_updateUndoButtonAppearance { if (!self.undoButtonInternal) { return; } @@ -306,6 +319,11 @@ static const CGFloat kKBAIButtonHeight = 40; return self.leftButtonsInternal[0]; } +- (UIButton *)kb_voiceButton { + if (self.leftButtonsInternal.count <= kKBVoiceButtonIndex) { return nil; } + return self.leftButtonsInternal[kKBVoiceButtonIndex]; +} + #pragma mark - Globe (Input Mode Switch) // 根据宿主是否已提供系统切换键,决定是否显示地球按钮;并绑定系统事件。 diff --git a/Shared/KBAPI.h b/Shared/KBAPI.h index 9e90d34..4cdea9c 100644 --- a/Shared/KBAPI.h +++ b/Shared/KBAPI.h @@ -69,6 +69,7 @@ /// AI #define API_AI_TALK @"/chat/talk" +#define API_AI_VOICE_TALK @"/chat/voice" // 语音对话(替换为后端真实路径) diff --git a/Shared/KBConfig.h b/Shared/KBConfig.h index 171cfaa..ed10b90 100644 --- a/Shared/KBConfig.h +++ b/Shared/KBConfig.h @@ -27,6 +27,9 @@ /// 键盘 -> 主 App 订阅页预填充数据(用于免二次请求) #define AppGroup_SubscriptionPrefillPayload @"AppGroup_SubscriptionPrefillPayload" +/// 用户头像 URL(主 App 写入,键盘扩展读取) +#define AppGroup_UserAvatarURL @"AppGroup_UserAvatarURL" + /// 皮肤图标加载模式: /// 0 = 使用本地 Assets 图片名(key_icons 的 value 写成图片名,例如 "kb_q_melon") /// 1 = 使用远程 Zip 皮肤包(skinJSON 中提供 zip_url;key_icons 的 value 写成 Zip 内图标文件名,例如 "key_a") diff --git a/Shared/KBVoiceBridgeNotification.h b/Shared/KBVoiceBridgeNotification.h new file mode 100644 index 0000000..f2ce7f7 --- /dev/null +++ b/Shared/KBVoiceBridgeNotification.h @@ -0,0 +1,26 @@ +// +// KBVoiceBridgeNotification.h +// 通用的语音录制桥接常量(App 与键盘扩展共享) +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// 扩展 -> 主 App:请求开始录音 +extern NSString * const KBDarwinVoiceStartRequest; // "com.loveKey.nyx.voice.start" +/// 扩展 -> 主 App:请求停止录音 +extern NSString * const KBDarwinVoiceStopRequest; // "com.loveKey.nyx.voice.stop" +/// 主 App -> 扩展:录音文件已就绪 +extern NSString * const KBDarwinVoiceReady; // "com.loveKey.nyx.voice.ready" +/// 主 App -> 扩展:录音失败 +extern NSString * const KBDarwinVoiceFailed; // "com.loveKey.nyx.voice.failed" + +/// App Group: 录音文件路径(NSString) +extern NSString * const KBVoiceBridgeFilePathKey; +/// App Group: 录音错误信息(NSString) +extern NSString * const KBVoiceBridgeErrorKey; +/// App Group: 录音时间戳(NSNumber) +extern NSString * const KBVoiceBridgeTimestampKey; + +NS_ASSUME_NONNULL_END diff --git a/Shared/KBVoiceBridgeNotification.m b/Shared/KBVoiceBridgeNotification.m new file mode 100644 index 0000000..89fa884 --- /dev/null +++ b/Shared/KBVoiceBridgeNotification.m @@ -0,0 +1,14 @@ +// +// KBVoiceBridgeNotification.m +// + +#import "KBVoiceBridgeNotification.h" + +NSString * const KBDarwinVoiceStartRequest = @"com.loveKey.nyx.voice.start"; +NSString * const KBDarwinVoiceStopRequest = @"com.loveKey.nyx.voice.stop"; +NSString * const KBDarwinVoiceReady = @"com.loveKey.nyx.voice.ready"; +NSString * const KBDarwinVoiceFailed = @"com.loveKey.nyx.voice.failed"; + +NSString * const KBVoiceBridgeFilePathKey = @"kb_voice_file_path"; +NSString * const KBVoiceBridgeErrorKey = @"kb_voice_error"; +NSString * const KBVoiceBridgeTimestampKey = @"kb_voice_ts"; diff --git a/Shared/KBVoiceRecordManager.h b/Shared/KBVoiceRecordManager.h new file mode 100644 index 0000000..c8099af --- /dev/null +++ b/Shared/KBVoiceRecordManager.h @@ -0,0 +1,21 @@ +// +// KBVoiceRecordManager.h +// 主 App 录音管理(用于键盘桥接) +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface KBVoiceRecordManager : NSObject + ++ (instancetype)shared; + +- (void)startRecording; +- (void)stopRecording; + +@property (nonatomic, assign, readonly, getter=isRecording) BOOL recording; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Shared/KBVoiceRecordManager.m b/Shared/KBVoiceRecordManager.m new file mode 100644 index 0000000..af0abc4 --- /dev/null +++ b/Shared/KBVoiceRecordManager.m @@ -0,0 +1,312 @@ +// +// KBVoiceRecordManager.m +// + +#import "KBVoiceRecordManager.h" +#import "KBConfig.h" +#import "KBVoiceBridgeNotification.h" +#import "KBHUD.h" +#import + +static void KBVoiceBridgeDarwinCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo); + +@interface KBVoiceRecordManager () +@property (nonatomic, strong) AVAudioRecorder *recorder; +@property (nonatomic, strong) NSURL *recordURL; +@property (nonatomic, assign, readwrite, getter=isRecording) BOOL recording; +@property (nonatomic, assign) BOOL pendingStart; +@end + +@implementation KBVoiceRecordManager + ++ (instancetype)shared { + static KBVoiceRecordManager *m; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ m = [KBVoiceRecordManager new]; }); + return m; +} + +- (instancetype)init { + if (self = [super init]) { + CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(), + (__bridge const void *)(self), + KBVoiceBridgeDarwinCallback, + (__bridge CFStringRef)KBDarwinVoiceStartRequest, + NULL, + CFNotificationSuspensionBehaviorDeliverImmediately); + CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(), + (__bridge const void *)(self), + KBVoiceBridgeDarwinCallback, + (__bridge CFStringRef)KBDarwinVoiceStopRequest, + NULL, + CFNotificationSuspensionBehaviorDeliverImmediately); + } + return self; +} + +- (void)dealloc { + CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(), + (__bridge const void *)(self), + (__bridge CFStringRef)KBDarwinVoiceStartRequest, + NULL); + CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(), + (__bridge const void *)(self), + (__bridge CFStringRef)KBDarwinVoiceStopRequest, + NULL); +} + +static void KBVoiceBridgeDarwinCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo) { + KBVoiceRecordManager *self = (__bridge KBVoiceRecordManager *)observer; + if (!self) { return; } + NSString *n = (__bridge NSString *)name; + NSLog(@"[KBVoiceBridge][App] Darwin received: %@", n); + dispatch_async(dispatch_get_main_queue(), ^{ + if ([n isEqualToString:KBDarwinVoiceStartRequest]) { + [self startRecording]; + } else if ([n isEqualToString:KBDarwinVoiceStopRequest]) { + [self stopRecording]; + } + }); +} + +#pragma mark - Public + +- (void)startRecording { + if (self.isRecording) { + NSLog(@"[KBVoiceBridge][App] startRecording already recording, stop then restart"); + [self stopRecording]; +// return; + } + NSLog(@"[KBVoiceBridge][App] startRecording begin"); + [self kb_clearSharedState]; + + AVAudioSession *session = [AVAudioSession sharedInstance]; + AVAudioSessionRecordPermission permission = session.recordPermission; + if (permission == AVAudioSessionRecordPermissionDenied) { + NSLog(@"[KBVoiceBridge][App] recordPermission denied"); + [self kb_postFailed:KBLocalized(@"麦克风权限未开启")]; + return; + } + if (permission == AVAudioSessionRecordPermissionUndetermined) { + NSLog(@"[KBVoiceBridge][App] recordPermission undetermined, requesting"); + self.pendingStart = YES; + __weak typeof(self) weakSelf = self; + [session requestRecordPermission:^(BOOL granted) { + dispatch_async(dispatch_get_main_queue(), ^{ + __strong typeof(weakSelf) self = weakSelf; + if (!self) { return; } + if (!self.pendingStart) { return; } + self.pendingStart = NO; + if (!granted) { + NSLog(@"[KBVoiceBridge][App] recordPermission request denied"); + [self kb_postFailed:KBLocalized(@"麦克风权限未开启")]; + return; + } + NSLog(@"[KBVoiceBridge][App] recordPermission request granted"); + [self startRecording]; + }); + }]; + return; + } + + NSError *error = nil; + if (@available(iOS 10.0, *)) { + [session setCategory:AVAudioSessionCategoryPlayAndRecord + mode:AVAudioSessionModeDefault + options:AVAudioSessionCategoryOptionDefaultToSpeaker | AVAudioSessionCategoryOptionAllowBluetooth + error:&error]; + } else { + [session setCategory:AVAudioSessionCategoryPlayAndRecord error:&error]; + } + if (error) { + NSLog(@"[KBVoiceBridge][App] setCategory error: %@", error.localizedDescription); + [self kb_postFailed:KBLocalized(@"麦克风初始化失败")]; + return; + } + [session setActive:YES error:&error]; + if (error) { + NSLog(@"[KBVoiceBridge][App] setActive error: %@", error.localizedDescription); + [self kb_postFailed:error.localizedDescription ?: KBLocalized(@"麦克风启动失败")]; + return; + } + if (!session.isInputAvailable) { + NSLog(@"[KBVoiceBridge][App] input not available"); + [self kb_postFailed:KBLocalized(@"麦克风不可用")]; + return; + } + + NSURL *aacURL = [self kb_voiceFileURLWithExtension:@"m4a"]; + if (!aacURL) { + NSLog(@"[KBVoiceBridge][App] app group not configured"); + [self kb_postFailed:KBLocalized(@"App Group 未配置,无法共享录音")]; + return; + } + NSDictionary *aacSettings = [self kb_voiceRecordSettingsAAC]; + if ([self kb_tryStartRecorderWithSettings:aacSettings fileURL:aacURL error:&error]) { + NSLog(@"[KBVoiceBridge][App] recorder started (aac) url=%@", aacURL); + self.recordURL = aacURL; + self.recording = YES; + return; + } + + NSURL *pcmURL = [self kb_voiceFileURLWithExtension:@"caf"]; + if (!pcmURL) { + NSLog(@"[KBVoiceBridge][App] app group not configured"); + [self kb_postFailed:KBLocalized(@"App Group 未配置,无法共享录音")]; + return; + } + NSDictionary *pcmSettings = [self kb_voiceRecordSettingsPCM]; + error = nil; + if ([self kb_tryStartRecorderWithSettings:pcmSettings fileURL:pcmURL error:&error]) { + NSLog(@"[KBVoiceBridge][App] recorder started (pcm) url=%@", pcmURL); + self.recordURL = pcmURL; + self.recording = YES; + return; + } + + NSLog(@"[KBVoiceBridge][App] recorder start failed: %@", error.localizedDescription); + NSString *tip = error.localizedDescription ?: KBLocalized(@"录音启动失败,可能是系统限制或宿主 App 不允许录音"); + [self kb_postFailed:tip]; +} + +- (void)stopRecording { + self.pendingStart = NO; + NSLog(@"[KBVoiceBridge][App] stopRecording"); + if (self.recorder.isRecording) { + [self.recorder stop]; + } +} + +#pragma mark - AVAudioRecorderDelegate + +- (void)audioRecorderDidFinishRecording:(AVAudioRecorder *)recorder successfully:(BOOL)flag { + [[AVAudioSession sharedInstance] setActive:NO error:nil]; + NSLog(@"[KBVoiceBridge][App] finishRecording flag=%d url=%@", flag, recorder.url); + NSURL *fileURL = recorder.url ?: self.recordURL; + self.recorder = nil; + self.recordURL = nil; + self.recording = NO; + + if (!flag || !fileURL) { + [self kb_postFailed:KBLocalized(@"录音失败")]; + return; + } + + [self kb_saveSharedFileURL:fileURL]; + [self kb_postDarwin:KBDarwinVoiceReady]; +} + +- (void)audioRecorderEncodeErrorDidOccur:(AVAudioRecorder *)recorder error:(NSError *)error { + [[AVAudioSession sharedInstance] setActive:NO error:nil]; + NSLog(@"[KBVoiceBridge][App] encodeError: %@", error.localizedDescription); + self.recorder = nil; + self.recordURL = nil; + self.recording = NO; + [self kb_postFailed:error.localizedDescription ?: KBLocalized(@"录音失败")]; +} + +#pragma mark - Helpers + +- (NSDictionary *)kb_voiceRecordSettingsAAC { + return @{AVFormatIDKey: @(kAudioFormatMPEG4AAC), + AVSampleRateKey: @(16000), + AVNumberOfChannelsKey: @(1), + AVEncoderAudioQualityKey: @(AVAudioQualityMedium)}; +} + +- (NSDictionary *)kb_voiceRecordSettingsPCM { + return @{AVFormatIDKey: @(kAudioFormatLinearPCM), + AVSampleRateKey: @(16000), + AVNumberOfChannelsKey: @(1), + AVLinearPCMBitDepthKey: @(16), + AVLinearPCMIsFloatKey: @(NO), + AVLinearPCMIsBigEndianKey: @(NO)}; +} + +- (NSURL *)kb_voiceFileURLWithExtension:(NSString *)ext { + NSURL *dirURL = [self kb_voiceDirectoryURL]; + if (!dirURL) { + return nil; + } + NSTimeInterval ts = [[NSDate date] timeIntervalSince1970] * 1000; + NSString *safeExt = (ext.length > 0) ? ext : @"m4a"; + NSString *fileName = [NSString stringWithFormat:@"kb_voice_%lld.%@", (long long)ts, safeExt]; + return [dirURL URLByAppendingPathComponent:fileName]; +} + +- (NSURL *)kb_voiceDirectoryURL { + NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup]; + if (!containerURL) { + return nil; + } + NSURL *dirURL = [containerURL URLByAppendingPathComponent:@"voice" isDirectory:YES]; + if (dirURL && ![[NSFileManager defaultManager] fileExistsAtPath:dirURL.path]) { + [[NSFileManager defaultManager] createDirectoryAtURL:dirURL withIntermediateDirectories:YES attributes:nil error:nil]; + } + return dirURL; +} + +- (BOOL)kb_tryStartRecorderWithSettings:(NSDictionary *)settings fileURL:(NSURL *)fileURL error:(NSError **)error { + AVAudioRecorder *recorder = [[AVAudioRecorder alloc] initWithURL:fileURL settings:settings error:error]; + if (*error || !recorder) { + NSLog(@"[KBVoiceBridge][App] create recorder failed: %@", (*error).localizedDescription); + return NO; + } + recorder.delegate = self; + recorder.meteringEnabled = YES; + if (![recorder prepareToRecord]) { + NSLog(@"[KBVoiceBridge][App] prepareToRecord failed"); + return NO; + } + if (![recorder record]) { + NSLog(@"[KBVoiceBridge][App] record returned NO"); + return NO; + } + self.recorder = recorder; + return YES; +} + +- (NSUserDefaults *)kb_voiceUserDefaults { + return [[NSUserDefaults alloc] initWithSuiteName:AppGroup]; +} + +- (void)kb_clearSharedState { + NSUserDefaults *ud = [self kb_voiceUserDefaults]; + [ud removeObjectForKey:KBVoiceBridgeFilePathKey]; + [ud removeObjectForKey:KBVoiceBridgeErrorKey]; + [ud removeObjectForKey:KBVoiceBridgeTimestampKey]; + [ud synchronize]; +} + +- (void)kb_saveSharedFileURL:(NSURL *)fileURL { + if (!fileURL) { return; } + NSUserDefaults *ud = [self kb_voiceUserDefaults]; + [ud setObject:fileURL.path ?: @"" forKey:KBVoiceBridgeFilePathKey]; + [ud setObject:@([[NSDate date] timeIntervalSince1970]) forKey:KBVoiceBridgeTimestampKey]; + [ud removeObjectForKey:KBVoiceBridgeErrorKey]; + [ud synchronize]; +} + +- (void)kb_saveSharedError:(NSString *)message { + NSUserDefaults *ud = [self kb_voiceUserDefaults]; + [ud setObject:message ?: @"" forKey:KBVoiceBridgeErrorKey]; + [ud setObject:@([[NSDate date] timeIntervalSince1970]) forKey:KBVoiceBridgeTimestampKey]; + [ud removeObjectForKey:KBVoiceBridgeFilePathKey]; + [ud synchronize]; +} + +- (void)kb_postFailed:(NSString *)message { + [self kb_saveSharedError:message]; + [self kb_postDarwin:KBDarwinVoiceFailed]; + + if ([UIApplication sharedApplication].applicationState == UIApplicationStateActive) { + NSString *tip = message.length > 0 ? message : KBLocalized(@"录音失败"); + [KBHUD showInfo:tip]; + } +} + +- (void)kb_postDarwin:(NSString *)name { + CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), + (__bridge CFStringRef)name, + NULL, NULL, true); +} + +@end diff --git a/keyBoard.xcodeproj/project.pbxproj b/keyBoard.xcodeproj/project.pbxproj index 123237f..22a9f75 100644 --- a/keyBoard.xcodeproj/project.pbxproj +++ b/keyBoard.xcodeproj/project.pbxproj @@ -52,6 +52,7 @@ 0459D1B42EBA284C00F2D189 /* KBSkinCenterVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0459D1B32EBA284C00F2D189 /* KBSkinCenterVC.m */; }; 0459D1B72EBA287900F2D189 /* KBSkinManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 0459D1B62EBA287900F2D189 /* KBSkinManager.m */; }; 0459D1B82EBA287900F2D189 /* KBSkinManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 0459D1B62EBA287900F2D189 /* KBSkinManager.m */; }; + 0460866B2F18D75500757C95 /* ai_test.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 0460866A2F18D75500757C95 /* ai_test.m4a */; }; 046131142ECF454500A6FADF /* KBKeyPreviewView.m in Sources */ = {isa = PBXBuildFile; fileRef = 046131132ECF454500A6FADF /* KBKeyPreviewView.m */; }; 0477BDF02EBB76E30055D639 /* HomeSheetVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0477BDEF2EBB76E30055D639 /* HomeSheetVC.m */; }; 0477BDF32EBB7B850055D639 /* KBDirectionIndicatorView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0477BDF22EBB7B850055D639 /* KBDirectionIndicatorView.m */; }; @@ -182,7 +183,6 @@ 04FC95B22EB0B2CC007BD342 /* KBSettingView.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95B12EB0B2CC007BD342 /* KBSettingView.m */; }; 04FC95C92EB1E4C9007BD342 /* BaseNavigationController.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95C82EB1E4C9007BD342 /* BaseNavigationController.m */; }; 04FC95CC2EB1E780007BD342 /* BaseTabBarController.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95CB2EB1E780007BD342 /* BaseTabBarController.m */; }; - 04FC95CF2EB1E7A1007BD342 /* HomeVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95CE2EB1E7A1007BD342 /* HomeVC.m */; }; 04FC95D22EB1E7AE007BD342 /* MyVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95D12EB1E7AE007BD342 /* MyVC.m */; }; 04FC95D72EB1EA16007BD342 /* BaseTableView.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95D62EB1EA16007BD342 /* BaseTableView.m */; }; 04FC95D82EB1EA16007BD342 /* BaseCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95D42EB1EA16007BD342 /* BaseCell.m */; }; @@ -221,9 +221,15 @@ A1B2C4002EB4A0A100000004 /* KBAuthManager.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C4002EB4A0A100000002 /* KBAuthManager.m */; }; A1B2C4202EB4B7A100000001 /* KBKeyboardPermissionManager.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C4222EB4B7A100000001 /* KBKeyboardPermissionManager.m */; }; A1B2C4212EB4B7A100000001 /* KBKeyboardPermissionManager.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C4222EB4B7A100000001 /* KBKeyboardPermissionManager.m */; }; + A1B2C5052F31001000000001 /* KBVoiceBridgeNotification.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C5022F31001000000001 /* KBVoiceBridgeNotification.m */; }; + A1B2C5062F31001000000001 /* KBVoiceBridgeNotification.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C5022F31001000000001 /* KBVoiceBridgeNotification.m */; }; + A1B2C5072F31001000000001 /* KBVoiceRecordManager.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C5042F31001000000001 /* KBVoiceRecordManager.m */; }; A1B2C9032FBD000100000001 /* KBBackspaceLongPressHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C9022FBD000100000001 /* KBBackspaceLongPressHandler.m */; }; A1B2C9052FBD000200000001 /* KBBackspaceUndoManager.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C9042FBD000200000001 /* KBBackspaceUndoManager.m */; }; A1B2C9092FBD000200000005 /* KBInputBufferManager.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C9082FBD000200000004 /* KBInputBufferManager.m */; }; + A1B2C9262FC9000100000001 /* KBChatMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C9212FC9000100000001 /* KBChatMessage.m */; }; + A1B2C9272FC9000100000001 /* KBChatMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C9232FC9000100000001 /* KBChatMessageCell.m */; }; + A1B2C9282FC9000100000001 /* KBChatPanelView.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C9252FC9000100000001 /* KBChatPanelView.m */; }; A1B2D7022EB8C00100000001 /* KBLangTestVC.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2D7012EB8C00100000001 /* KBLangTestVC.m */; }; A1B2E1012EBC7AAA00000001 /* KBTopThreeView.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2E0022EBC7AAA00000001 /* KBTopThreeView.m */; }; A1B2E1022EBC7AAA00000001 /* HomeHotCell.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2E0042EBC7AAA00000001 /* HomeHotCell.m */; }; @@ -323,6 +329,7 @@ 0459D1B32EBA284C00F2D189 /* KBSkinCenterVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBSkinCenterVC.m; sourceTree = ""; }; 0459D1B52EBA287900F2D189 /* KBSkinManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBSkinManager.h; sourceTree = ""; }; 0459D1B62EBA287900F2D189 /* KBSkinManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBSkinManager.m; sourceTree = ""; }; + 0460866A2F18D75500757C95 /* ai_test.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = ai_test.m4a; sourceTree = ""; }; 046131122ECF454500A6FADF /* KBKeyPreviewView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBKeyPreviewView.h; sourceTree = ""; }; 046131132ECF454500A6FADF /* KBKeyPreviewView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBKeyPreviewView.m; sourceTree = ""; }; 0477BDEE2EBB76E30055D639 /* HomeSheetVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HomeSheetVC.h; sourceTree = ""; }; @@ -557,8 +564,6 @@ 04FC95C82EB1E4C9007BD342 /* BaseNavigationController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BaseNavigationController.m; sourceTree = ""; }; 04FC95CA2EB1E780007BD342 /* BaseTabBarController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BaseTabBarController.h; sourceTree = ""; }; 04FC95CB2EB1E780007BD342 /* BaseTabBarController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BaseTabBarController.m; sourceTree = ""; }; - 04FC95CD2EB1E7A1007BD342 /* HomeVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HomeVC.h; sourceTree = ""; }; - 04FC95CE2EB1E7A1007BD342 /* HomeVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HomeVC.m; sourceTree = ""; }; 04FC95D02EB1E7AE007BD342 /* MyVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MyVC.h; sourceTree = ""; }; 04FC95D12EB1E7AE007BD342 /* MyVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MyVC.m; sourceTree = ""; }; 04FC95D32EB1EA16007BD342 /* BaseCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BaseCell.h; sourceTree = ""; }; @@ -633,12 +638,22 @@ A1B2C4002EB4A0A100000002 /* KBAuthManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAuthManager.m; sourceTree = ""; }; A1B2C4222EB4B7A100000001 /* KBKeyboardPermissionManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBKeyboardPermissionManager.m; sourceTree = ""; }; A1B2C4232EB4B7A100000001 /* KBKeyboardPermissionManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBKeyboardPermissionManager.h; sourceTree = ""; }; + A1B2C5012F31001000000001 /* KBVoiceBridgeNotification.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceBridgeNotification.h; sourceTree = ""; }; + A1B2C5022F31001000000001 /* KBVoiceBridgeNotification.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceBridgeNotification.m; sourceTree = ""; }; + A1B2C5032F31001000000001 /* KBVoiceRecordManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceRecordManager.h; sourceTree = ""; }; + A1B2C5042F31001000000001 /* KBVoiceRecordManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceRecordManager.m; sourceTree = ""; }; A1B2C9012FBD000100000001 /* KBBackspaceLongPressHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBBackspaceLongPressHandler.h; sourceTree = ""; }; A1B2C9022FBD000100000001 /* KBBackspaceLongPressHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBBackspaceLongPressHandler.m; sourceTree = ""; }; A1B2C9032FBD000200000001 /* KBBackspaceUndoManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBBackspaceUndoManager.h; sourceTree = ""; }; A1B2C9042FBD000200000001 /* KBBackspaceUndoManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBBackspaceUndoManager.m; sourceTree = ""; }; A1B2C9072FBD000200000003 /* KBInputBufferManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBInputBufferManager.h; sourceTree = ""; }; A1B2C9082FBD000200000004 /* KBInputBufferManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBInputBufferManager.m; sourceTree = ""; }; + A1B2C9202FC9000100000001 /* KBChatMessage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatMessage.h; sourceTree = ""; }; + A1B2C9212FC9000100000001 /* KBChatMessage.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatMessage.m; sourceTree = ""; }; + A1B2C9222FC9000100000001 /* KBChatMessageCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatMessageCell.h; sourceTree = ""; }; + A1B2C9232FC9000100000001 /* KBChatMessageCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatMessageCell.m; sourceTree = ""; }; + A1B2C9242FC9000100000001 /* KBChatPanelView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatPanelView.h; sourceTree = ""; }; + A1B2C9252FC9000100000001 /* KBChatPanelView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatPanelView.m; sourceTree = ""; }; A1B2D7002EB8C00100000001 /* KBLangTestVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBLangTestVC.h; sourceTree = ""; }; A1B2D7012EB8C00100000001 /* KBLangTestVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBLangTestVC.m; sourceTree = ""; }; A1B2E0012EBC7AAA00000001 /* KBTopThreeView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBTopThreeView.h; sourceTree = ""; }; @@ -686,6 +701,7 @@ 041007D02ECE010100D203BB /* Resource */ = { isa = PBXGroup; children = ( + 0460866A2F18D75500757C95 /* ai_test.m4a */, 04E161812F10E6470022C23B /* normal_hei_them.zip */, 04E161822F10E6470022C23B /* normal_them.zip */, A1B2C3EC2F20000000000001 /* kb_words.txt */, @@ -1203,6 +1219,10 @@ A1B2C3F22EB35A9900000001 /* KBFullAccessGuideView.m */, 04FC95B02EB0B2CC007BD342 /* KBSettingView.h */, 04FC95B12EB0B2CC007BD342 /* KBSettingView.m */, + A1B2C9242FC9000100000001 /* KBChatPanelView.h */, + A1B2C9252FC9000100000001 /* KBChatPanelView.m */, + A1B2C9222FC9000100000001 /* KBChatMessageCell.h */, + A1B2C9232FC9000100000001 /* KBChatMessageCell.m */, 049FB22D2EC34EB900FAB05D /* KBStreamTextView.h */, 049FB22E2EC34EB900FAB05D /* KBStreamTextView.m */, 049FB23A2EC4766700FAB05D /* Function */, @@ -1248,6 +1268,8 @@ children = ( 04FC95642EB0546C007BD342 /* KBKey.h */, 04FC95652EB0546C007BD342 /* KBKey.m */, + A1B2C9202FC9000100000001 /* KBChatMessage.h */, + A1B2C9212FC9000100000001 /* KBChatMessage.m */, 04FEDC202F00020000999999 /* KBKeyboardSubscriptionProduct.h */, 04FEDC212F00020000999999 /* KBKeyboardSubscriptionProduct.m */, 04FEDC232F10000100000001 /* KBKeyboardLayoutConfig.h */, @@ -1293,8 +1315,6 @@ 04FC95B52EB1E3B1007BD342 /* VC */ = { isa = PBXGroup; children = ( - 04FC95CD2EB1E7A1007BD342 /* HomeVC.h */, - 04FC95CE2EB1E7A1007BD342 /* HomeVC.m */, 0477BE022EBC83130055D639 /* HomeMainVC.h */, 0477BE032EBC83130055D639 /* HomeMainVC.m */, 0477BDEE2EBB76E30055D639 /* HomeSheetVC.h */, @@ -1651,6 +1671,10 @@ 0459D1B62EBA287900F2D189 /* KBSkinManager.m */, 049FB23D2EC4B6EF00FAB05D /* KBULBridgeNotification.h */, 049FB23E2EC4B6EF00FAB05D /* KBULBridgeNotification.m */, + A1B2C5012F31001000000001 /* KBVoiceBridgeNotification.h */, + A1B2C5022F31001000000001 /* KBVoiceBridgeNotification.m */, + A1B2C5032F31001000000001 /* KBVoiceRecordManager.h */, + A1B2C5042F31001000000001 /* KBVoiceRecordManager.m */, 04D1F6B02EDFF10A00B12345 /* KBSkinInstallBridge.h */, 04D1F6B12EDFF10A00B12345 /* KBSkinInstallBridge.m */, 04791F8C2ED469C0004E8522 /* KBHostAppLauncher.h */, @@ -1818,6 +1842,7 @@ 04E161832F10E6470022C23B /* normal_hei_them.zip in Resources */, 04E161842F10E6470022C23B /* normal_them.zip in Resources */, 04A9FE202EB893F10020DB6D /* Localizable.strings in Resources */, + 0460866B2F18D75500757C95 /* ai_test.m4a in Resources */, 041007D42ECE012500D203BB /* 002.zip in Resources */, 041007D22ECE012000D203BB /* KBSkinIconMap.strings in Resources */, A1B2C3ED2F20000000000001 /* kb_words.txt in Resources */, @@ -1924,6 +1949,9 @@ 0450AC4A2EF2C3ED00B6AF06 /* KBKeyboardSubscriptionOptionCell.m in Sources */, 04A9FE0F2EB481100020DB6D /* KBHUD.m in Sources */, 04C6EADD2EAF8CEB0089C901 /* KBToolBar.m in Sources */, + A1B2C9262FC9000100000001 /* KBChatMessage.m in Sources */, + A1B2C9272FC9000100000001 /* KBChatMessageCell.m in Sources */, + A1B2C9282FC9000100000001 /* KBChatPanelView.m in Sources */, A1B2C3EB2F20000000000001 /* KBSuggestionBarView.m in Sources */, 04FC95792EB09BC8007BD342 /* KBKeyBoardMainView.m in Sources */, 04FEDAB32EEDB05000123456 /* KBEmojiPanelView.m in Sources */, @@ -1955,6 +1983,7 @@ A1B2C9052FBD000200000001 /* KBBackspaceUndoManager.m in Sources */, A1B2C9092FBD000200000005 /* KBInputBufferManager.m in Sources */, 049FB23F2EC4B6EF00FAB05D /* KBULBridgeNotification.m in Sources */, + A1B2C5052F31001000000001 /* KBVoiceBridgeNotification.m in Sources */, 04791F992ED49CE7004E8522 /* KBFont.m in Sources */, 04FC956D2EB054B7007BD342 /* KBKeyboardView.m in Sources */, 04FC95672EB0546C007BD342 /* KBKey.m in Sources */, @@ -2046,6 +2075,8 @@ 0477BDF72EBC63A80055D639 /* KBTestVC.m in Sources */, 04122F7E2EC5FC5500EF7AB3 /* KBJfPayCell.m in Sources */, 049FB2402EC4B6EF00FAB05D /* KBULBridgeNotification.m in Sources */, + A1B2C5062F31001000000001 /* KBVoiceBridgeNotification.m in Sources */, + A1B2C5072F31001000000001 /* KBVoiceRecordManager.m in Sources */, 04FC95C92EB1E4C9007BD342 /* BaseNavigationController.m in Sources */, 048908DD2EBF67EB00FABA60 /* KBSearchResultVC.m in Sources */, 05A1B2D12F5B1A2B3C4D5E60 /* KBSearchVM.m in Sources */, @@ -2081,7 +2112,6 @@ 0498BD7E2EE04F9C006CC1D5 /* KBTag.m in Sources */, 04791F922ED48010004E8522 /* KBNoticeVC.m in Sources */, 04FC970F2EB334F8007BD342 /* KBWebImageManager.m in Sources */, - 04FC95CF2EB1E7A1007BD342 /* HomeVC.m in Sources */, 0498BDDE2EE81508006CC1D5 /* KBShopVM.m in Sources */, 049FB2112EC1F72F00FAB05D /* KBMyListCell.m in Sources */, A1B2D7022EB8C00100000001 /* KBLangTestVC.m in Sources */, @@ -2263,6 +2293,9 @@ INFOPLIST_FILE = keyBoard/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Key of Love"; INFOPLIST_KEY_CFBundleURLTypes = "{\n CFBundleURLName = \"com.loveKey.nyx.keyboard\";\n CFBundleURLSchemes = (\n kbkeyboardAppExtension\n );\n}"; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "需要使用麦克风进行语音输入"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "保存图片需要写入您的相册"; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "更换头像需要访问您的相册"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIMainStoryboardFile = Main; @@ -2311,6 +2344,9 @@ INFOPLIST_FILE = keyBoard/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Key of Love"; INFOPLIST_KEY_CFBundleURLTypes = "{\n CFBundleURLName = \"com.loveKey.nyx.keyboard\";\n CFBundleURLSchemes = (\n kbkeyboardAppExtension\n );\n}"; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "需要使用麦克风进行语音输入"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "保存图片需要写入您的相册"; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "更换头像需要访问您的相册"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIMainStoryboardFile = Main; diff --git a/keyBoard/AppDelegate.m b/keyBoard/AppDelegate.m index 0af4a55..1d40b03 100644 --- a/keyBoard/AppDelegate.m +++ b/keyBoard/AppDelegate.m @@ -25,6 +25,7 @@ #import "KBUserSessionManager.h" #import "KBLoginVC.h" #import "KBConfig.h" +#import "KBVoiceRecordManager.h" static NSTimeInterval const kKBSubscriptionPrefillTTL = 10 * 60.0; @@ -61,6 +62,8 @@ static NSTimeInterval const kKBSubscriptionPrefillTTL = 10 * 60.0; // 主工程默认开启网络总开关(键盘扩展仍需用户允许完全访问后再行开启) [KBNetworkManager shared].enabled = YES; + // 预热语音录制管理器(注册 Darwin 通知,响应键盘录音请求) + [KBVoiceRecordManager shared]; /// 获取网络权限 [self getNetJudge]; /// 触发一次简单网络请求,用于拉起系统的蜂窝数据权限弹窗 @@ -190,6 +193,19 @@ static NSTimeInterval const kKBSubscriptionPrefillTTL = 10 * 60.0; } else if ([host isEqualToString:@"settings"]) { // kbkeyboard://settings [self kb_openAppSettings]; return YES; + } else if ([host isEqualToString:@"voice"]) { // kbkeyboard://voice?action=start|stop + NSDictionary *params = [self kb_queryParametersFromURL:url]; + NSString *action = params[@"action"].lowercaseString; + NSLog(@"[KBVoiceBridge][App] openURL voice action=%@", action); + if ([action isEqualToString:@"start"]) { + [[KBVoiceRecordManager shared] startRecording]; + return YES; + } + if ([action isEqualToString:@"stop"]) { + [[KBVoiceRecordManager shared] stopRecording]; + return YES; + } + return YES; }else if ([host isEqualToString:@"recharge"]) { // kbkeyboard://recharge NSDictionary *params = [self kb_queryParametersFromURL:url]; NSString *productId = params[@"productId"]; diff --git a/keyBoard/Class/Base/VC/BaseTabBarController.m b/keyBoard/Class/Base/VC/BaseTabBarController.m index f7a70d7..16ad77b 100644 --- a/keyBoard/Class/Base/VC/BaseTabBarController.m +++ b/keyBoard/Class/Base/VC/BaseTabBarController.m @@ -6,7 +6,6 @@ // #import "BaseTabBarController.h" -#import "HomeVC.h" #import "HomeMainVC.h" #import "MyVC.h" #import "KBShopVC.h" diff --git a/keyBoard/Class/Home/VC/HomeMainVC.m b/keyBoard/Class/Home/VC/HomeMainVC.m index 387de89..5b0bfb7 100644 --- a/keyBoard/Class/Home/VC/HomeMainVC.m +++ b/keyBoard/Class/Home/VC/HomeMainVC.m @@ -10,10 +10,13 @@ #import "KBPanModalView.h" #import "KBGuideVC.h" // 首次安装指引页 #import "WMDragView.h" +#import "KBMyVM.h" +#import "KBUserSessionManager.h" @interface HomeMainVC () @property (nonatomic, strong) HomeHeadView *headView; @property (nonatomic, strong) KBPanModalView *simplePanModalView; +@property (nonatomic, strong) KBMyVM *viewModel; /// 权限按钮 @property (nonatomic, strong) WMDragView *keyPermissButton; @@ -65,6 +68,17 @@ // NSLog(@"[MainApp] 写入完成"); } +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + if (![KBUserSessionManager shared].isLoggedIn) { + return; + } + if (!self.viewModel) { + self.viewModel = [[KBMyVM alloc] init]; + } + [self.viewModel fetchUserDetailWithCompletion:nil]; +} + // 在界面可见后做“首次安装”检查,避免在 viewDidLoad 期间 push 引起的转场警告 - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; diff --git a/keyBoard/Class/Home/VC/HomeVC.h b/keyBoard/Class/Home/VC/HomeVC.h deleted file mode 100644 index 23d76f4..0000000 --- a/keyBoard/Class/Home/VC/HomeVC.h +++ /dev/null @@ -1,16 +0,0 @@ -// -// HomeVC.h -// keyBoard -// -// Created by Mac on 2025/10/29. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface HomeVC : UIViewController - -@end - -NS_ASSUME_NONNULL_END diff --git a/keyBoard/Class/Home/VC/HomeVC.m b/keyBoard/Class/Home/VC/HomeVC.m deleted file mode 100644 index d6badcb..0000000 --- a/keyBoard/Class/Home/VC/HomeVC.m +++ /dev/null @@ -1,61 +0,0 @@ -// -// HomeVC.m -// keyBoard -// -// Created by Mac on 2025/10/29. -// - -#import "HomeVC.h" -#import "HomeHeadView.h" -#import "HomeSheetVC.h" - -@interface HomeVC () -@property (nonatomic, strong) HomeHeadView *headView; - -@end - -@implementation HomeVC - -- (void)viewDidLoad { - [super viewDidLoad]; - self.view.backgroundColor = [UIColor yellowColor]; - CGFloat topV = (500); - - [self.view addSubview:self.headView]; - [self setupMas:topV]; - // 创建sheetVC - HomeSheetVC *vc = [[HomeSheetVC alloc] init]; - // 使用宏,避免误写成函数指针判断导致恒为 true -// if (KB_DEVICE_HAS_NOTCH) { -// vc.minHeight = KB_SCREEN_HEIGHT - topV - 34; -// }else{ - vc.minHeight = KB_SCREEN_HEIGHT - topV; -// -// } - vc.topInset = 100; - [self presentPanModal:vc]; -} - -- (void)setupMas:(CGFloat)headViewTopV{ - [self.headView mas_makeConstraints:^(MASConstraintMaker *make) { - make.left.right.equalTo(self.view); - make.top.equalTo(self.view); - make.height.mas_equalTo(headViewTopV); - }]; -} - -- (void)viewWillAppear:(BOOL)animated{ - [super viewWillAppear:animated]; - -} - - -#pragma mark - lazy -- (HomeHeadView *)headView{ - if (!_headView) { - _headView = [[HomeHeadView alloc] init]; - } - return _headView; -} - -@end diff --git a/keyBoard/Class/Me/VM/KBMyVM.m b/keyBoard/Class/Me/VM/KBMyVM.m index 2cc7c15..5a92ac6 100644 --- a/keyBoard/Class/Me/VM/KBMyVM.m +++ b/keyBoard/Class/Me/VM/KBMyVM.m @@ -47,6 +47,13 @@ NSString * const KBUserCharacterDeletedNotification = @"KBUserCharacterDeletedNo } KBUser *user = [KBUser mj_objectWithKeyValues:(NSDictionary *)dataObj]; + NSUserDefaults *sharedDefaults = [[NSUserDefaults alloc] initWithSuiteName:AppGroup]; + if (user.avatarUrl.length > 0) { + [sharedDefaults setObject:user.avatarUrl forKey:AppGroup_UserAvatarURL]; + } else { + [sharedDefaults removeObjectForKey:AppGroup_UserAvatarURL]; + } + [sharedDefaults synchronize]; if (completion) completion(user, nil); }]; } diff --git a/keyBoard/Class/Network/KBNetworkManager.h b/keyBoard/Class/Network/KBNetworkManager.h index e1b93de..71285fd 100644 --- a/keyBoard/Class/Network/KBNetworkManager.h +++ b/keyBoard/Class/Network/KBNetworkManager.h @@ -100,6 +100,14 @@ typedef void(^KBNetworkDataCompletion)(NSData *_Nullable data, headers:(nullable NSDictionary *)headers completion:(KBNetworkCompletion)completion; +/// 上传本地文件(multipart/form-data) +- (nullable NSURLSessionDataTask *)uploadFile:(NSString *)path + fileURL:(NSURL *)fileURL + name:(NSString *)name + mimeType:(NSString *)mimeType + parameters:(nullable NSDictionary *)parameters + headers:(nullable NSDictionary *)headers + completion:(KBNetworkCompletion)completion; @end diff --git a/keyBoard/Class/Network/KBNetworkManager.m b/keyBoard/Class/Network/KBNetworkManager.m index 71f41fc..c8e5a48 100644 --- a/keyBoard/Class/Network/KBNetworkManager.m +++ b/keyBoard/Class/Network/KBNetworkManager.m @@ -323,6 +323,42 @@ autoShowBusinessError:YES completion:completion]; } +- (NSURLSessionDataTask *)uploadFile:(NSString *)path + fileURL:(NSURL *)fileURL + name:(NSString *)name + mimeType:(NSString *)mimeType + parameters:(NSDictionary *)parameters + headers:(NSDictionary *)headers + completion:(KBNetworkCompletion)completion { + if (!fileURL || !fileURL.isFileURL) { + if (completion) { + NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain + code:KBNetworkErrorInvalidResponse + userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid file")}]; + completion(nil, nil, e); + } + return nil; + } + NSError *readError = nil; + NSData *data = [NSData dataWithContentsOfURL:fileURL options:0 error:&readError]; + if (readError || data.length == 0) { + if (completion) { + NSError *e = readError ?: [NSError errorWithDomain:KBNetworkErrorDomain + code:KBNetworkErrorInvalidResponse + userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Empty file data")}]; + completion(nil, nil, e); + } + return nil; + } + NSString *fileName = fileURL.lastPathComponent ?: @"upload.bin"; + return [self uploadFile:path + fileData:data + fileName:fileName + mimeType:mimeType + headers:headers + completion:completion]; +} + #pragma mark - Core diff --git a/keyBoard/Info.plist b/keyBoard/Info.plist index 94ec165..f4ed86f 100644 --- a/keyBoard/Info.plist +++ b/keyBoard/Info.plist @@ -15,16 +15,18 @@ + UIBackgroundModes + + audio + NSAppTransportSecurity NSAllowsArbitraryLoads - - NSPhotoLibraryUsageDescription - 更换头像需要访问您的相册 - - NSPhotoLibraryAddUsageDescription - 保存图片需要写入您的相册 + UIBackgroundModes + + audio +