1
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
<array>
|
||||
<string>kbkeyboardAppExtension</string>
|
||||
</array>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>需要使用麦克风进行语音输入</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@@ -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 <AVFoundation/AVFoundation.h>
|
||||
|
||||
// #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 () <KBKeyBoardMainViewDelegate,
|
||||
KBFunctionViewDelegate,
|
||||
KBKeyboardSubscriptionViewDelegate>
|
||||
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<KBChatMessage *> *chatMessages;
|
||||
@property(nonatomic, strong) AVAudioPlayer *chatAudioPlayer;
|
||||
@property(nonatomic, strong) NSCache<NSString *, UIImage *> *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<KBChatMessage *> *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<NSString *> *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<NSString *> *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<NSString *> *)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<KBChatMessage *> *)chatMessages {
|
||||
if (!_chatMessages) {
|
||||
_chatMessages = [NSMutableArray array];
|
||||
}
|
||||
return _chatMessages;
|
||||
}
|
||||
|
||||
- (NSCache<NSString *, UIImage *> *)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];
|
||||
}
|
||||
|
||||
|
||||
26
CustomKeyboard/Model/KBChatMessage.h
Normal file
26
CustomKeyboard/Model/KBChatMessage.h
Normal file
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// KBChatMessage.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
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
|
||||
20
CustomKeyboard/Model/KBChatMessage.m
Normal file
20
CustomKeyboard/Model/KBChatMessage.m
Normal file
@@ -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
|
||||
@@ -64,6 +64,15 @@ typedef void(^KBNetworkDataCompletion)(NSData *_Nullable data,
|
||||
headers:(nullable NSDictionary<NSString *, NSString *> *)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<NSString *, NSString *> *)headers
|
||||
completion:(KBNetworkCompletion)completion;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -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<NSString *, NSString *> *)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<AFMultipartFormData> 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<NSString *,NSString *> *)headers
|
||||
|
||||
BIN
CustomKeyboard/Resource/ai_test.m4a
Normal file
BIN
CustomKeyboard/Resource/ai_test.m4a
Normal file
Binary file not shown.
17
CustomKeyboard/View/KBChatMessageCell.h
Normal file
17
CustomKeyboard/View/KBChatMessageCell.h
Normal file
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// KBChatMessageCell.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
@class KBChatMessage;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBChatMessageCell : UITableViewCell
|
||||
|
||||
- (void)kb_configureWithMessage:(KBChatMessage *)message;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
194
CustomKeyboard/View/KBChatMessageCell.m
Normal file
194
CustomKeyboard/View/KBChatMessageCell.m
Normal file
@@ -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
|
||||
27
CustomKeyboard/View/KBChatPanelView.h
Normal file
27
CustomKeyboard/View/KBChatPanelView.h
Normal file
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// KBChatPanelView.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
@class KBChatPanelView, KBChatMessage;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@protocol KBChatPanelViewDelegate <NSObject>
|
||||
@optional
|
||||
- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text;
|
||||
- (void)chatPanelView:(KBChatPanelView *)view didTapMessage:(KBChatMessage *)message;
|
||||
@end
|
||||
|
||||
@interface KBChatPanelView : UIView
|
||||
|
||||
@property (nonatomic, weak) id<KBChatPanelViewDelegate> delegate;
|
||||
|
||||
@property (nonatomic, strong, readonly) UITableView *tableView;
|
||||
|
||||
- (void)kb_reloadWithMessages:(NSArray<KBChatMessage *> *)messages;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
97
CustomKeyboard/View/KBChatPanelView.m
Normal file
97
CustomKeyboard/View/KBChatPanelView.m
Normal file
@@ -0,0 +1,97 @@
|
||||
//
|
||||
// KBChatPanelView.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBChatPanelView.h"
|
||||
#import "KBChatMessage.h"
|
||||
#import "KBChatMessageCell.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
@interface KBChatPanelView () <UITableViewDataSource, UITableViewDelegate>
|
||||
@property (nonatomic, strong) UITableView *tableViewInternal;
|
||||
@property (nonatomic, copy) NSArray<KBChatMessage *> *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<KBChatMessage *> *)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
|
||||
@@ -26,7 +26,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@property (nonatomic, weak, nullable) id<KBToolBarDelegate> delegate;
|
||||
|
||||
/// 左侧按钮的标题(数量由数组决定)。默认值:@[@"AI"]。
|
||||
/// 左侧按钮的标题(数量由数组决定)。默认值:@[@"AI", @"语音"]。
|
||||
@property (nonatomic, copy) NSArray<NSString *> *leftButtonTitles;
|
||||
|
||||
/// 暴露按钮以便外部定制(只读;首次访问时懒加载创建)
|
||||
|
||||
@@ -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)
|
||||
|
||||
// 根据宿主是否已提供系统切换键,决定是否显示地球按钮;并绑定系统事件。
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
|
||||
/// AI
|
||||
#define API_AI_TALK @"/chat/talk"
|
||||
#define API_AI_VOICE_TALK @"/chat/voice" // 语音对话(替换为后端真实路径)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
26
Shared/KBVoiceBridgeNotification.h
Normal file
26
Shared/KBVoiceBridgeNotification.h
Normal file
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// KBVoiceBridgeNotification.h
|
||||
// 通用的语音录制桥接常量(App 与键盘扩展共享)
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
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
|
||||
14
Shared/KBVoiceBridgeNotification.m
Normal file
14
Shared/KBVoiceBridgeNotification.m
Normal file
@@ -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";
|
||||
21
Shared/KBVoiceRecordManager.h
Normal file
21
Shared/KBVoiceRecordManager.h
Normal file
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// KBVoiceRecordManager.h
|
||||
// 主 App 录音管理(用于键盘桥接)
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
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
|
||||
312
Shared/KBVoiceRecordManager.m
Normal file
312
Shared/KBVoiceRecordManager.m
Normal file
@@ -0,0 +1,312 @@
|
||||
//
|
||||
// KBVoiceRecordManager.m
|
||||
//
|
||||
|
||||
#import "KBVoiceRecordManager.h"
|
||||
#import "KBConfig.h"
|
||||
#import "KBVoiceBridgeNotification.h"
|
||||
#import "KBHUD.h"
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
|
||||
static void KBVoiceBridgeDarwinCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo);
|
||||
|
||||
@interface KBVoiceRecordManager () <AVAudioRecorderDelegate>
|
||||
@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
|
||||
@@ -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 = "<group>"; };
|
||||
0459D1B52EBA287900F2D189 /* KBSkinManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBSkinManager.h; sourceTree = "<group>"; };
|
||||
0459D1B62EBA287900F2D189 /* KBSkinManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBSkinManager.m; sourceTree = "<group>"; };
|
||||
0460866A2F18D75500757C95 /* ai_test.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = ai_test.m4a; sourceTree = "<group>"; };
|
||||
046131122ECF454500A6FADF /* KBKeyPreviewView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBKeyPreviewView.h; sourceTree = "<group>"; };
|
||||
046131132ECF454500A6FADF /* KBKeyPreviewView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBKeyPreviewView.m; sourceTree = "<group>"; };
|
||||
0477BDEE2EBB76E30055D639 /* HomeSheetVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HomeSheetVC.h; sourceTree = "<group>"; };
|
||||
@@ -557,8 +564,6 @@
|
||||
04FC95C82EB1E4C9007BD342 /* BaseNavigationController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BaseNavigationController.m; sourceTree = "<group>"; };
|
||||
04FC95CA2EB1E780007BD342 /* BaseTabBarController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BaseTabBarController.h; sourceTree = "<group>"; };
|
||||
04FC95CB2EB1E780007BD342 /* BaseTabBarController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BaseTabBarController.m; sourceTree = "<group>"; };
|
||||
04FC95CD2EB1E7A1007BD342 /* HomeVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HomeVC.h; sourceTree = "<group>"; };
|
||||
04FC95CE2EB1E7A1007BD342 /* HomeVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HomeVC.m; sourceTree = "<group>"; };
|
||||
04FC95D02EB1E7AE007BD342 /* MyVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MyVC.h; sourceTree = "<group>"; };
|
||||
04FC95D12EB1E7AE007BD342 /* MyVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MyVC.m; sourceTree = "<group>"; };
|
||||
04FC95D32EB1EA16007BD342 /* BaseCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BaseCell.h; sourceTree = "<group>"; };
|
||||
@@ -633,12 +638,22 @@
|
||||
A1B2C4002EB4A0A100000002 /* KBAuthManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAuthManager.m; sourceTree = "<group>"; };
|
||||
A1B2C4222EB4B7A100000001 /* KBKeyboardPermissionManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBKeyboardPermissionManager.m; sourceTree = "<group>"; };
|
||||
A1B2C4232EB4B7A100000001 /* KBKeyboardPermissionManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBKeyboardPermissionManager.h; sourceTree = "<group>"; };
|
||||
A1B2C5012F31001000000001 /* KBVoiceBridgeNotification.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceBridgeNotification.h; sourceTree = "<group>"; };
|
||||
A1B2C5022F31001000000001 /* KBVoiceBridgeNotification.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceBridgeNotification.m; sourceTree = "<group>"; };
|
||||
A1B2C5032F31001000000001 /* KBVoiceRecordManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceRecordManager.h; sourceTree = "<group>"; };
|
||||
A1B2C5042F31001000000001 /* KBVoiceRecordManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceRecordManager.m; sourceTree = "<group>"; };
|
||||
A1B2C9012FBD000100000001 /* KBBackspaceLongPressHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBBackspaceLongPressHandler.h; sourceTree = "<group>"; };
|
||||
A1B2C9022FBD000100000001 /* KBBackspaceLongPressHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBBackspaceLongPressHandler.m; sourceTree = "<group>"; };
|
||||
A1B2C9032FBD000200000001 /* KBBackspaceUndoManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBBackspaceUndoManager.h; sourceTree = "<group>"; };
|
||||
A1B2C9042FBD000200000001 /* KBBackspaceUndoManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBBackspaceUndoManager.m; sourceTree = "<group>"; };
|
||||
A1B2C9072FBD000200000003 /* KBInputBufferManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBInputBufferManager.h; sourceTree = "<group>"; };
|
||||
A1B2C9082FBD000200000004 /* KBInputBufferManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBInputBufferManager.m; sourceTree = "<group>"; };
|
||||
A1B2C9202FC9000100000001 /* KBChatMessage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatMessage.h; sourceTree = "<group>"; };
|
||||
A1B2C9212FC9000100000001 /* KBChatMessage.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatMessage.m; sourceTree = "<group>"; };
|
||||
A1B2C9222FC9000100000001 /* KBChatMessageCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatMessageCell.h; sourceTree = "<group>"; };
|
||||
A1B2C9232FC9000100000001 /* KBChatMessageCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatMessageCell.m; sourceTree = "<group>"; };
|
||||
A1B2C9242FC9000100000001 /* KBChatPanelView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatPanelView.h; sourceTree = "<group>"; };
|
||||
A1B2C9252FC9000100000001 /* KBChatPanelView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatPanelView.m; sourceTree = "<group>"; };
|
||||
A1B2D7002EB8C00100000001 /* KBLangTestVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBLangTestVC.h; sourceTree = "<group>"; };
|
||||
A1B2D7012EB8C00100000001 /* KBLangTestVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBLangTestVC.m; sourceTree = "<group>"; };
|
||||
A1B2E0012EBC7AAA00000001 /* KBTopThreeView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBTopThreeView.h; sourceTree = "<group>"; };
|
||||
@@ -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;
|
||||
|
||||
@@ -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<NSString *, NSString *> *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<NSString *, NSString *> *params = [self kb_queryParametersFromURL:url];
|
||||
NSString *productId = params[@"productId"];
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
//
|
||||
|
||||
#import "BaseTabBarController.h"
|
||||
#import "HomeVC.h"
|
||||
#import "HomeMainVC.h"
|
||||
#import "MyVC.h"
|
||||
#import "KBShopVC.h"
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
//
|
||||
// HomeVC.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2025/10/29.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface HomeVC : UIViewController
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -100,6 +100,14 @@ typedef void(^KBNetworkDataCompletion)(NSData *_Nullable data,
|
||||
headers:(nullable NSDictionary<NSString *, NSString *> *)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<NSString *, NSString *> *)headers
|
||||
completion:(KBNetworkCompletion)completion;
|
||||
|
||||
@end
|
||||
|
||||
|
||||
@@ -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<NSString *, NSString *> *)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
|
||||
|
||||
|
||||
@@ -15,16 +15,18 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<!-- 相册权限:更换头像需要访问相册 -->
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>更换头像需要访问您的相册</string>
|
||||
<!-- 若未来需要保存图片到相册,可保留此项(当前仅选择不需要) -->
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>保存图片需要写入您的相册</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Reference in New Issue
Block a user