This commit is contained in:
2026-01-15 18:16:56 +08:00
parent da62d4f411
commit 32c4138ae0
29 changed files with 1523 additions and 95 deletions

View File

@@ -6,6 +6,8 @@
<array>
<string>kbkeyboardAppExtension</string>
</array>
<key>NSMicrophoneUsageDescription</key>
<string>需要使用麦克风进行语音输入</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>

View File

@@ -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];
}

View 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

View 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

View File

@@ -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

View File

@@ -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

Binary file not shown.

View 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

View 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

View 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

View 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

View File

@@ -26,7 +26,7 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, weak, nullable) id<KBToolBarDelegate> delegate;
/// 左侧按钮的标题(数量由数组决定)。默认值:@[@"AI"]。
/// 左侧按钮的标题(数量由数组决定)。默认值:@[@"AI", @"语音"]。
@property (nonatomic, copy) NSArray<NSString *> *leftButtonTitles;
/// 暴露按钮以便外部定制(只读;首次访问时懒加载创建)

View File

@@ -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)
// 宿

View File

@@ -69,6 +69,7 @@
/// AI
#define API_AI_TALK @"/chat/talk"
#define API_AI_VOICE_TALK @"/chat/voice" // 语音对话(替换为后端真实路径)

View File

@@ -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_urlkey_icons 的 value 写成 Zip 内图标文件名,例如 "key_a"

View 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

View 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";

View 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

View 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

View File

@@ -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;

View File

@@ -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"];

View File

@@ -6,7 +6,6 @@
//
#import "BaseTabBarController.h"
#import "HomeVC.h"
#import "HomeMainVC.h"
#import "MyVC.h"
#import "KBShopVC.h"

View File

@@ -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];

View File

@@ -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

View File

@@ -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

View File

@@ -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);
}];
}

View File

@@ -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

View File

@@ -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

View File

@@ -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>