// // KeyboardViewController.m // CustomKeyboard // // Created by Mac on 2025/10/27. // #import "KeyboardViewController.h" #import "KBKeyBoardMainView.h" #import "KBAuthManager.h" #import "KBBackspaceUndoManager.h" #import "KBFullAccessManager.h" #import "KBFunctionView.h" #import "KBHostAppLauncher.h" #import "KBInputBufferManager.h" #import "KBKey.h" #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 "KBVM.h" #import "Masonry.h" #import "UIImage+KBColor.h" #import #import // #import "KBLog.h" // 提前声明一个类别,使编译器在 static 回调中识别 kb_consumePendingShopSkin // 方法。 @interface KeyboardViewController (KBSkinShopBridge) - (void)kb_consumePendingShopSkin; @end // 以 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"; static NSString *const kKBDefaultSkinZipNameDark = @"normal_hei_them"; static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo) { KeyboardViewController *strongSelf = (__bridge KeyboardViewController *)observer; if (!strongSelf) { return; } dispatch_async(dispatch_get_main_queue(), ^{ if ([strongSelf respondsToSelector:@selector(kb_consumePendingShopSkin)]) { [strongSelf kb_consumePendingShopSkin]; } }); } @interface KeyboardViewController () @property(nonatomic, strong) UIButton *nextKeyboardButton; // 系统“下一个键盘”按钮(可选) @property(nonatomic, strong) UIView *contentView; @property(nonatomic, strong) KBKeyBoardMainView *keyBoardMainView; // 功能面板视图(点击工具栏第0个时显示) @property(nonatomic, strong) KBFunctionView *functionView; // 功能面板视图(点击工具栏第0个时显示) @property(nonatomic, strong) KBSettingView *settingView; // 设置页 @property(nonatomic, strong) UIImageView *bgImageView; // 背景图(在底层) @property(nonatomic, strong) UIImageView *personaAvatarImageView; // 语音模式下显示的 persona 小头像 @property(nonatomic, strong) UIImageView *personaGrayImageView; // 语音模式下显示的 persona 小头像 @property(nonatomic, strong) KBChatPanelView *chatPanelView; @property(nonatomic, strong) KBKeyboardSubscriptionView *subscriptionView; @property(nonatomic, strong) KBSuggestionEngine *suggestionEngine; @property(nonatomic, copy) NSString *currentWord; @property(nonatomic, assign) BOOL suppressSuggestions; @property(nonatomic, strong) MASConstraint *contentWidthConstraint; @property(nonatomic, strong) MASConstraint *contentHeightConstraint; @property(nonatomic, strong) MASConstraint *keyBoardMainHeightConstraint; @property(nonatomic, strong) MASConstraint *chatPanelHeightConstraint; @property(nonatomic, strong) NSLayoutConstraint *kb_heightConstraint; @property(nonatomic, strong) NSLayoutConstraint *kb_widthConstraint; @property(nonatomic, assign) CGFloat kb_lastPortraitWidth; @property(nonatomic, assign) CGFloat kb_lastKeyboardHeight; @property(nonatomic, strong) NSMutableArray *chatMessages; @property(nonatomic, strong) AVAudioPlayer *chatAudioPlayer; @property(nonatomic, assign) BOOL chatPanelVisible; @end @implementation KeyboardViewController { BOOL _kb_didTriggerLoginDeepLinkOnce; } - (void)viewDidLoad { [super viewDidLoad]; // 撤销删除是“上一段删除操作”的临时状态;键盘被系统回收/重建或跨页面回来时应当清空,避免误显示。 [[KBBackspaceUndoManager shared] registerNonClearAction]; [self setupUI]; self.suggestionEngine = [KBSuggestionEngine shared]; self.currentWord = @""; // 指定 HUD 的承载视图(扩展里无法取到 App 的 KeyWindow) [KBHUD setContainerView:self.view]; // 绑定完全访问管理器,便于统一感知和联动网络开关 [[KBFullAccessManager shared] bindInputController:self]; __unused id token = [[NSNotificationCenter defaultCenter] addObserverForName:KBFullAccessChangedNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(__unused NSNotification *_Nonnull note){ // 如需,可在此刷新与完全访问相关的 UI }]; // 皮肤变化时,立即应用 __unused id token2 = [[NSNotificationCenter defaultCenter] addObserverForName:KBSkinDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(__unused NSNotification *_Nonnull note) { [self kb_applyTheme]; }]; [self kb_applyTheme]; CFNotificationCenterAddObserver( CFNotificationCenterGetDarwinNotifyCenter(), (__bridge const void *)(self), KBSkinInstallNotificationCallback, (__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL, CFNotificationSuspensionBehaviorDeliverImmediately); [self kb_consumePendingShopSkin]; [self kb_applyDefaultSkinIfNeeded]; } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; // 进入/重新进入输入界面时,清理上一次会话残留的撤销状态与缓存,避免显示“撤销删除”但实际上已不可撤销。 [[KBBackspaceUndoManager shared] registerNonClearAction]; [[KBInputBufferManager shared] resetWithText:@""]; [[KBLocalizationManager shared] reloadFromSharedStorageIfNeeded]; // 注意:微信/QQ 等宿主的 documentContext 可能是“截断窗口”,这里只更新 // liveText,不要把它当作全文 manualSnapshot。 [[KBInputBufferManager shared] updateFromExternalContextBefore:self.textDocumentProxy .documentContextBeforeInput after:self.textDocumentProxy .documentContextAfterInput]; } - (void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; [[KBBackspaceUndoManager shared] registerNonClearAction]; // 清理 persona 头像内存 [self kb_hidePersonaAvatar]; } - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { [super traitCollectionDidChange:previousTraitCollection]; if (@available(iOS 13.0, *)) { if (previousTraitCollection.userInterfaceStyle != self.traitCollection.userInterfaceStyle) { [self kb_applyDefaultSkinIfNeeded]; } } } - (void)textDidChange:(id)textInput { [super textDidChange:textInput]; [[KBInputBufferManager shared] updateFromExternalContextBefore:self.textDocumentProxy .documentContextBeforeInput after:self.textDocumentProxy .documentContextAfterInput]; } - (void)setupUI { self.view.translatesAutoresizingMaskIntoConstraints = NO; // 按“短边”宽度等比缩放,横屏保持竖屏布局比例 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); NSLayoutConstraint *h = [self.view.heightAnchor constraintEqualToConstant:keyboardHeight]; NSLayoutConstraint *w = [self.view.widthAnchor constraintEqualToConstant:screenWidth]; self.kb_heightConstraint = h; self.kb_widthConstraint = w; h.priority = UILayoutPriorityRequired; w.priority = UILayoutPriorityRequired; [NSLayoutConstraint activateConstraints:@[ h, w ]]; // 关闭 UIInputView 自适应(某些系统版本会尝试放大为全屏高度导致冲突) if ([self.view isKindOfClass:[UIInputView class]]) { UIInputView *iv = (UIInputView *)self.view; if ([iv respondsToSelector:@selector(setAllowsSelfSizing:)]) { iv.allowsSelfSizing = NO; } } // 内容容器:横屏时保持竖屏宽度,居中显示 [self.view addSubview:self.contentView]; [self.contentView mas_makeConstraints:^(MASConstraintMaker *make) { make.centerX.equalTo(self.view); make.bottom.equalTo(self.view); self.contentWidthConstraint = make.width.mas_equalTo(portraitWidth); self.contentHeightConstraint = make.height.mas_equalTo(keyboardHeight); }]; // 背景图铺底(仅在内容容器内) [self.contentView addSubview:self.bgImageView]; [self.bgImageView mas_makeConstraints:^(MASConstraintMaker *make) { make.edges.equalTo(self.contentView); }]; // 预置功能面板(默认隐藏),与键盘区域共享相同布局 self.functionView.hidden = YES; [self.contentView addSubview:self.functionView]; [self.functionView mas_makeConstraints:^(MASConstraintMaker *make) { make.edges.equalTo(self.contentView); }]; [self.contentView addSubview:self.keyBoardMainView]; [self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) { 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 // MARK: - Suggestions - (void)kb_updateCurrentWordWithInsertedText:(NSString *)text { if (text.length == 0) { return; } if ([self kb_isAlphabeticString:text]) { NSString *current = self.currentWord ?: @""; self.currentWord = [current stringByAppendingString:text]; self.suppressSuggestions = NO; [self kb_updateSuggestionsForCurrentWord]; } else { [self kb_clearCurrentWord]; } } - (void)kb_clearCurrentWord { self.currentWord = @""; [self.keyBoardMainView kb_setSuggestions:@[]]; self.suppressSuggestions = NO; } - (void)kb_scheduleContextRefreshResetSuppression:(BOOL)resetSuppression { dispatch_async(dispatch_get_main_queue(), ^{ [self kb_refreshCurrentWordFromDocumentContextResetSuppression: resetSuppression]; }); } - (void)kb_refreshCurrentWordFromDocumentContextResetSuppression: (BOOL)resetSuppression { NSString *context = self.textDocumentProxy.documentContextBeforeInput ?: @""; NSString *word = [self kb_extractTrailingWordFromContext:context]; self.currentWord = word ?: @""; if (resetSuppression) { self.suppressSuggestions = NO; } [self kb_updateSuggestionsForCurrentWord]; } - (NSString *)kb_extractTrailingWordFromContext:(NSString *)context { if (context.length == 0) { return @""; } static NSCharacterSet *letters = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ letters = [NSCharacterSet characterSetWithCharactersInString: @"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"]; }); NSInteger idx = (NSInteger)context.length - 1; while (idx >= 0) { unichar ch = [context characterAtIndex:(NSUInteger)idx]; if (![letters characterIsMember:ch]) { break; } idx -= 1; } NSUInteger start = (NSUInteger)(idx + 1); if (start >= context.length) { return @""; } return [context substringFromIndex:start]; } - (BOOL)kb_isAlphabeticString:(NSString *)text { if (text.length == 0) { return NO; } static NSCharacterSet *letters = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ letters = [NSCharacterSet characterSetWithCharactersInString: @"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"]; }); for (NSUInteger i = 0; i < text.length; i++) { if (![letters characterIsMember:[text characterAtIndex:i]]) { return NO; } } return YES; } - (void)kb_updateSuggestionsForCurrentWord { NSString *prefix = self.currentWord ?: @""; if (prefix.length == 0) { [self.keyBoardMainView kb_setSuggestions:@[]]; return; } if (self.suppressSuggestions) { [self.keyBoardMainView kb_setSuggestions:@[]]; return; } NSArray *items = [self.suggestionEngine suggestionsForPrefix:prefix limit:5]; NSArray *cased = [self kb_applyCaseToSuggestions:items prefix:prefix]; [self.keyBoardMainView kb_setSuggestions:cased]; } - (NSArray *)kb_applyCaseToSuggestions:(NSArray *)items prefix:(NSString *)prefix { if (items.count == 0 || prefix.length == 0) { return items; } BOOL allUpper = [prefix isEqualToString:prefix.uppercaseString]; BOOL firstUpper = [[prefix substringToIndex:1] isEqualToString:[[prefix substringToIndex:1] uppercaseString]]; if (!allUpper && !firstUpper) { return items; } NSMutableArray *result = [NSMutableArray arrayWithCapacity:items.count]; for (NSString *word in items) { if (allUpper) { [result addObject:word.uppercaseString]; } else { NSString *first = [[word substringToIndex:1] uppercaseString]; NSString *rest = (word.length > 1) ? [word substringFromIndex:1] : @""; [result addObject:[first stringByAppendingString:rest]]; } } return result.copy; } /// 切换显示功能面板/键盘主视图 - (void)showFunctionPanel:(BOOL)show { // 简单显隐切换,复用相同的布局区域 if (show) { [self showChatPanel:NO]; } self.functionView.hidden = !show; self.keyBoardMainView.hidden = show; if (show) { [[KBMaiPointReporter sharedReporter] reportPageExposureWithEventName:@"enter_keyboard_function_panel" pageId:@"keyboard_function_panel" extra:nil completion:nil]; [self hideSubscriptionPanel]; } else { [[KBMaiPointReporter sharedReporter] reportPageExposureWithEventName:@"enter_keyboard_main_panel" pageId:@"keyboard_main_panel" extra:nil completion:nil]; } // 可选:把当前显示的视图置顶,避免层级遮挡 if (show) { [self.contentView bringSubviewToFront:self.functionView]; } else { [self.contentView bringSubviewToFront:self.keyBoardMainView]; } } /// 显示/隐藏设置页(高度与 keyBoardMainView 一致),右侧滑入/滑出 - (void)showSettingView:(BOOL)show { if (show) { [self showChatPanel:NO]; [[KBMaiPointReporter sharedReporter] reportPageExposureWithEventName:@"enter_keyboard_settings" pageId:@"keyboard_settings" extra:nil completion:nil]; // if (!self.settingView) { self.settingView = [[KBSettingView alloc] init]; self.settingView.hidden = YES; [self.contentView addSubview:self.settingView]; [self.settingView mas_makeConstraints:^(MASConstraintMaker *make) { // 与键盘主视图完全等同的区域,保证高度、宽度一致 make.edges.equalTo(self.contentView); }]; [self.settingView.backButton addTarget:self action:@selector(onTapSettingsBack) forControlEvents:UIControlEventTouchUpInside]; // } [self.contentView bringSubviewToFront:self.settingView]; // 以 keyBoardMainView 的实际宽度为准,避免首次添加时 self.view 宽度尚未计算 [self.contentView layoutIfNeeded]; CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds); if (w <= 0) { w = CGRectGetWidth(self.contentView.bounds); } if (w <= 0) { w = [self kb_portraitWidth]; } self.settingView.transform = CGAffineTransformMakeTranslation(w, 0); self.settingView.hidden = NO; [UIView animateWithDuration:0.25 delay:0 options:UIViewAnimationOptionCurveEaseOut animations:^{ self.settingView.transform = CGAffineTransformIdentity; } completion:nil]; } else { if (!self.settingView || self.settingView.hidden) return; CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds); if (w <= 0) { w = CGRectGetWidth(self.contentView.bounds); } if (w <= 0) { w = [self kb_portraitWidth]; } [UIView animateWithDuration:0.22 delay:0 options:UIViewAnimationOptionCurveEaseIn animations:^{ self.settingView.transform = CGAffineTransformMakeTranslation(w, 0); } completion:^(BOOL finished) { self.settingView.hidden = YES; }]; } } /// 显示/隐藏聊天面板(覆盖整个键盘区域) - (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]) { // 未开启完全访问:保持原有引导路径 // [KBHUD showInfo:KBLocalized(@"处理中…")]; [[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]; return; } // 点击充值要先判断是否登录 // 2) 权限没问题,再判断是否登录:未登录 -> 直接拉起主 App,由主 App // 负责完成登录 if (!KBAuthManager.shared.isLoggedIn) { NSString *schemeStr = [NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME]; NSURL *scheme = [NSURL URLWithString:schemeStr]; // 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view]; return; } [[KBMaiPointReporter sharedReporter] reportPageExposureWithEventName:@"enter_keyboard_subscription_panel" pageId:@"keyboard_subscription_panel" extra:nil completion:nil]; [self showFunctionPanel:NO]; KBKeyboardSubscriptionView *panel = self.subscriptionView; if (!panel.superview) { panel.hidden = YES; [self.contentView addSubview:panel]; [panel mas_makeConstraints:^(MASConstraintMaker *make) { make.edges.equalTo(self.contentView); }]; } [self.contentView bringSubviewToFront:panel]; panel.hidden = NO; panel.alpha = 0.0; CGFloat height = CGRectGetHeight(self.contentView.bounds); if (height <= 0) { height = 260; } panel.transform = CGAffineTransformMakeTranslation(0, height); [panel refreshProductsIfNeeded]; [UIView animateWithDuration:0.25 delay:0 options:UIViewAnimationOptionCurveEaseOut animations:^{ panel.alpha = 1.0; panel.transform = CGAffineTransformIdentity; } completion:nil]; } - (void)hideSubscriptionPanel { if (!self.subscriptionView || self.subscriptionView.hidden) { return; } CGFloat height = CGRectGetHeight(self.subscriptionView.bounds); if (height <= 0) { height = CGRectGetHeight(self.contentView.bounds); } KBKeyboardSubscriptionView *panel = self.subscriptionView; [UIView animateWithDuration:0.22 delay:0 options:UIViewAnimationOptionCurveEaseIn animations:^{ panel.alpha = 0.0; panel.transform = CGAffineTransformMakeTranslation(0, height); } completion:^(BOOL finished) { panel.hidden = YES; panel.alpha = 1.0; panel.transform = CGAffineTransformIdentity; }]; } // MARK: - KBKeyBoardMainViewDelegate - (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didTapKey:(KBKey *)key { switch (key.type) { case KBKeyTypeCharacter: { [[KBBackspaceUndoManager shared] registerNonClearAction]; NSString *text = key.output ?: key.title ?: @""; [self.textDocumentProxy insertText:text]; [self kb_updateCurrentWordWithInsertedText:text]; [[KBInputBufferManager shared] appendText:text]; } break; case KBKeyTypeBackspace: [[KBInputBufferManager shared] refreshFromProxyIfPossible:self.textDocumentProxy]; [[KBInputBufferManager shared] prepareSnapshotForDeleteWithContextBefore: self.textDocumentProxy.documentContextBeforeInput after: self.textDocumentProxy .documentContextAfterInput]; [[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:self.textDocumentProxy count:1]; [self kb_scheduleContextRefreshResetSuppression:NO]; [[KBInputBufferManager shared] applyHoldDeleteCount:1]; break; case KBKeyTypeSpace: [[KBBackspaceUndoManager shared] registerNonClearAction]; [self.textDocumentProxy insertText:@" "]; [self kb_clearCurrentWord]; [[KBInputBufferManager shared] appendText:@" "]; break; case KBKeyTypeReturn: if (self.chatPanelVisible) { [self kb_handleChatSendAction]; break; } [[KBBackspaceUndoManager shared] registerNonClearAction]; [self.textDocumentProxy insertText:@"\n"]; [self kb_clearCurrentWord]; [[KBInputBufferManager shared] appendText:@"\n"]; break; case KBKeyTypeGlobe: [self advanceToNextInputMode]; break; case KBKeyTypeCustom: [[KBBackspaceUndoManager shared] registerNonClearAction]; // 点击自定义键切换到功能面板 [self showFunctionPanel:YES]; [self kb_clearCurrentWord]; break; case KBKeyTypeModeChange: case KBKeyTypeShift: // 这些已在 KBKeyBoardMainView/KBKeyboardView 内部处理 break; } } - (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didTapToolActionAtIndex:(NSInteger)index { NSDictionary *extra = @{@"index" : @(index)}; [[KBMaiPointReporter sharedReporter] reportClickWithEventName:@"click_keyboard_toolbar_action" pageId:@"keyboard_main_panel" elementId:@"toolbar_action" 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]; // 显示 persona 头像 [self kb_showPersonaAvatarOnBgImageView]; return; } [self showFunctionPanel:NO]; [self showChatPanel:NO]; } - (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView { [[KBMaiPointReporter sharedReporter] reportClickWithEventName:@"click_keyboard_settings_btn" pageId:@"keyboard_main_panel" elementId:@"settings_btn" extra:nil completion:nil]; [self showSettingView:YES]; } - (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didSelectEmoji:(NSString *)emoji { if (emoji.length == 0) { return; } [[KBBackspaceUndoManager shared] registerNonClearAction]; [self.textDocumentProxy insertText:emoji]; [self kb_clearCurrentWord]; [[KBInputBufferManager shared] appendText:emoji]; } - (void)keyBoardMainViewDidTapUndo:(KBKeyBoardMainView *)keyBoardMainView { [[KBMaiPointReporter sharedReporter] reportClickWithEventName:@"click_keyboard_undo_btn" pageId:@"keyboard_main_panel" elementId:@"undo_btn" extra:nil completion:nil]; [[KBBackspaceUndoManager shared] performUndoFromResponder:self.view]; [self kb_scheduleContextRefreshResetSuppression:YES]; } - (void)keyBoardMainViewDidTapEmojiSearch: (KBKeyBoardMainView *)keyBoardMainView { // [[KBMaiPointReporter sharedReporter] // reportClickWithEventName:@"click_keyboard_emoji_search_btn" // pageId:@"keyboard_main_panel" // elementId:@"emoji_search_btn" // extra:nil // completion:nil]; [KBHUD showInfo:KBLocalized(@"Search coming soon")]; } - (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didSelectSuggestion:(NSString *)suggestion { if (suggestion.length == 0) { return; } NSDictionary *extra = @{@"suggestion_len" : @(suggestion.length)}; // [[KBMaiPointReporter sharedReporter] // reportClickWithEventName:@"click_keyboard_suggestion_item" // pageId:@"keyboard_main_panel" // elementId:@"suggestion_item" // extra:extra // completion:nil]; [[KBBackspaceUndoManager shared] registerNonClearAction]; NSString *current = self.currentWord ?: @""; if (current.length > 0) { for (NSUInteger i = 0; i < current.length; i++) { [self.textDocumentProxy deleteBackward]; } } [self.textDocumentProxy insertText:suggestion]; self.currentWord = suggestion; [self.suggestionEngine recordSelection:suggestion]; self.suppressSuggestions = YES; [self.keyBoardMainView kb_setSuggestions:@[]]; [[KBInputBufferManager shared] replaceTailWithText:suggestion deleteCount:current.length]; } // MARK: - KBFunctionViewDelegate - (void)functionView:(KBFunctionView *)functionView didTapToolActionAtIndex:(NSInteger)index { // 需求:当 index == 0 时,切回键盘主视图 if (index == 0) { [self showFunctionPanel:NO]; } } - (void)functionView:(KBFunctionView *_Nullable)functionView didRightTapToolActionAtIndex:(NSInteger)index { [[KBMaiPointReporter sharedReporter] reportClickWithEventName:@"click_keyboard_function_right_action" pageId:@"keyboard_function_panel" elementId:@"right_action" extra:@{@"action" : @"login_or_recharge"} completion:nil]; if (!KBAuthManager.shared.isLoggedIn) { NSString *schemeStr = [NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME]; NSURL *scheme = [NSURL URLWithString:schemeStr]; // 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view]; return; } NSString *schemeStr = [NSString stringWithFormat:@"%@://recharge?src=keyboard", KB_APP_SCHEME]; NSURL *scheme = [NSURL URLWithString:schemeStr]; // // if (!ul && !scheme) { return; } // // 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view]; if (!ok) { // 失败兜底:给个文案提示 // 比如:请回到桌面手动打开 XXX App 进行设置/充值 [KBHUD showInfo:@"请回到桌面手动打开App进行充值"]; } } - (void)functionViewDidRequestSubscription:(KBFunctionView *)functionView { [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]; } - (void)chatPanelView:(KBChatPanelView *)view didTapVoiceButtonForMessage:(KBChatMessage *)message { if (!message) return; // 如果有 audioData,直接播放 if (message.audioData && message.audioData.length > 0) { [self kb_playChatAudioData:message.audioData]; return; } // 如果有 audioFilePath,播放文件 if (message.audioFilePath.length > 0) { [self kb_playChatAudioAtPath:message.audioFilePath]; return; } NSLog(@"[Keyboard] 没有音频数据可播放"); } - (void)chatPanelViewDidTapClose:(KBChatPanelView *)view { // 清空 chatPanelView 内部的消息 [self.chatPanelView kb_reloadWithMessages:@[]]; if (self.chatAudioPlayer.isPlaying) { [self.chatAudioPlayer stop]; } self.chatAudioPlayer = nil; [self showChatPanel:NO]; // 隐藏 persona 头像 [self kb_hidePersonaAvatar]; } #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; } NSLog(@"[KB] 发送消息: %@", text); KBChatMessage *outgoing = [KBChatMessage userMessageWithText:text]; outgoing.avatarURL = [self kb_sharedUserAvatarURL]; [self.chatPanelView kb_addUserMessage:text]; [self kb_prefetchAvatarForMessage:outgoing]; if (![[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]) { [KBHUD showInfo:KBLocalized(@"请开启完全访问后使用")]; return; } // 添加 loading 消息 [self.chatPanelView kb_addLoadingAssistantMessage]; // 调用新的聊天接口 [self kb_requestChatMessageWithContent: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; } if (![[KBFullAccessManager shared] hasFullAccess]) { return; } __weak typeof(self) weakSelf = self; [[KBVM shared] downloadAvatarFromURL:urlString completion:^(UIImage *image, NSError *error) { __strong typeof(weakSelf) self = weakSelf; if (!self || !image) return; message.avatarImage = image; [self kb_reloadChatRowForMessage:message]; }]; } - (void)kb_reloadChatRowForMessage:(KBChatMessage *)message { // 头像预加载完成后不需要刷新表格 // 因为键盘扩展的聊天面板不显示头像,所以这里直接返回 // 如果将来需要显示头像,可以只刷新特定行而不是整个表格 } - (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(@"未获取到音频文件")]; }); }]; } #pragma mark - New Chat API (with typewriter effect and audio preload) /// 调用新的聊天接口(返回文本和 audioId) - (void)kb_requestChatMessageWithContent:(NSString *)content { if (content.length == 0) { [self.chatPanelView kb_removeLoadingAssistantMessage]; return; } NSInteger companionId = [[KBVM shared] selectedCompanionIdFromAppGroup]; NSLog(@"[KB] 请求聊天: companionId=%ld", (long)companionId); __weak typeof(self) weakSelf = self; [[KBVM shared] sendChatMessageWithContent:content companionId:companionId completion:^(KBChatResponse *response) { __strong typeof(weakSelf) self = weakSelf; if (!self) return; if (!response.success) { NSLog(@"[KB] ❌ 请求失败: %@", response.errorMessage); [self.chatPanelView kb_removeLoadingAssistantMessage]; [KBHUD showInfo:response.errorMessage ?: KBLocalized(@"请求失败")]; return; } NSLog(@"[KB] ✅ 收到回复: %@", response.text); if (response.text.length == 0) { [self.chatPanelView kb_removeLoadingAssistantMessage]; [KBHUD showInfo:KBLocalized(@"未获取到回复内容")]; return; } // 添加 AI 消息(带打字机效果) NSLog(@"[KB] 准备添加 AI 消息"); [self.chatPanelView kb_addAssistantMessage:response.text audioId:response.audioId]; NSLog(@"[KB] AI 消息添加完成"); // 如果有 audioId,开始预加载音频 if (response.audioId.length > 0) { [self kb_preloadAudioWithAudioId:response.audioId]; } }]; } /// 从 AppGroup 获取选中的 persona companionId - (NSInteger)kb_selectedCompanionId { return [[KBVM shared] selectedCompanionIdFromAppGroup]; } #pragma mark - Audio Preload /// 预加载音频(轮询获取 audioURL) - (void)kb_preloadAudioWithAudioId:(NSString *)audioId { if (audioId.length == 0) return; NSLog(@"[Keyboard] 开始预加载音频,audioId: %@", audioId); __weak typeof(self) weakSelf = self; [[KBVM shared] pollAudioURLWithAudioId:audioId maxRetries:10 interval:1.0 completion:^(KBAudioResponse *response) { __strong typeof(weakSelf) self = weakSelf; if (!self) return; if (!response.success || response.audioURL.length == 0) { NSLog(@"[Keyboard] ❌ 预加载音频 URL 获取失败: %@", response.errorMessage); return; } NSLog(@"[Keyboard] ✅ 预加载音频 URL 获取成功"); // 下载音频 [[KBVM shared] downloadAudioFromURL:response.audioURL completion:^(KBAudioResponse *audioResponse) { if (!audioResponse.success) { NSLog(@"[Keyboard] ❌ 预加载音频下载失败: %@", audioResponse.errorMessage); return; } // 更新最后一条 AI 消息的音频数据 [self.chatPanelView kb_updateLastAssistantMessageWithAudioData:audioResponse.audioData duration:audioResponse.duration]; NSLog(@"[Keyboard] ✅ 预加载音频完成,音频时长: %.2f秒", audioResponse.duration); }]; }]; } - (void)kb_downloadChatAudioFromURL:(NSString *)audioURL displayText:(NSString *)displayText { __weak typeof(self) weakSelf = self; [[KBVM shared] downloadAudioFromURL:audioURL completion:^(KBAudioResponse *response) { __strong typeof(weakSelf) self = weakSelf; if (!self) return; if (!response.success) { [KBHUD showInfo:response.errorMessage ?: KBLocalized(@"下载失败")]; return; } if (!response.audioData || response.audioData.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:response.audioData fileExtension:ext displayText:displayText]; }]; } - (void)kb_handleChatAudioData:(NSData *)data fileExtension:(NSString *)extension displayText:(NSString *)displayText { if (data.length == 0) { [KBHUD showInfo:KBLocalized(@"音频数据为空")]; return; } NSString *ext = extension.length > 0 ? extension : @"m4a"; NSString *fileName = [NSString stringWithFormat:@"kb_chat_%@.%@", @((long long)([NSDate date].timeIntervalSince1970 * 1000)), ext]; NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:fileName]; if (![data writeToFile:filePath atomically:YES]) { [KBHUD showInfo:KBLocalized(@"音频保存失败")]; return; } NSString *text = displayText.length > 0 ? displayText : KBLocalized(@"语音消息"); KBChatMessage *incoming = [KBChatMessage messageWithText:text outgoing:NO audioFilePath:filePath]; incoming.displayName = KBLocalized(@"AI助手"); [self kb_appendChatMessage:incoming]; } - (void)kb_appendChatMessage:(KBChatMessage *)message { if (!message) { return; } [self.chatMessages addObject:message]; if (self.chatMessages.count > kKBChatMessageLimit) { NSUInteger overflow = self.chatMessages.count - kKBChatMessageLimit; NSArray *removed = [self.chatMessages subarrayWithRange:NSMakeRange(0, overflow)]; [self.chatMessages removeObjectsInRange:NSMakeRange(0, overflow)]; for (KBChatMessage *msg in removed) { if (msg.audioFilePath.length > 0) { NSString *tmpRoot = NSTemporaryDirectory(); if (tmpRoot.length > 0 && [msg.audioFilePath hasPrefix:tmpRoot]) { [[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath error:nil]; } } } } [self.chatPanelView kb_reloadWithMessages:self.chatMessages]; } - (NSString *)kb_mockChatAudioPath { NSString *path = [[NSBundle mainBundle] pathForResource:@"ai_test" ofType:@"m4a"]; return path ?: @""; } - (NSString *)kb_chatTextFromJSON:(NSDictionary *)json { NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json]; NSString *text = [self kb_stringValueInDict:data keys:@[ @"text", @"message", @"content" ]]; if (text.length == 0) { text = [self kb_stringValueInDict:json keys:@[ @"text", @"message", @"content" ]]; } return text ?: @""; } - (NSString *)kb_chatAudioURLFromJSON:(NSDictionary *)json { NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json]; NSArray *keys = @[ @"audioUrl", @"audioURL", @"audio_url", @"url", @"fileUrl", @"file_url", @"audioFileUrl", @"audio_file_url" ]; NSString *url = [self kb_stringValueInDict:data keys:keys]; if (url.length == 0) { url = [self kb_stringValueInDict:json keys:keys]; } return url ?: @""; } - (NSString *)kb_chatAudioBase64FromJSON:(NSDictionary *)json { NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json]; NSArray *keys = @[ @"audioBase64", @"audio_base64", @"audioData", @"audio_data", @"base64" ]; NSString *b64 = [self kb_stringValueInDict:data keys:keys]; if (b64.length == 0) { b64 = [self kb_stringValueInDict:json keys:keys]; } return b64 ?: @""; } - (NSDictionary *)kb_chatDataDictionaryFromJSON:(NSDictionary *)json { if (![json isKindOfClass:[NSDictionary class]]) { return @{}; } id dataObj = json[@"data"] ?: json[@"result"] ?: json[@"response"]; if ([dataObj isKindOfClass:[NSDictionary class]]) { return (NSDictionary *)dataObj; } return @{}; } - (NSString *)kb_stringValueInDict:(NSDictionary *)dict keys:(NSArray *)keys { if (![dict isKindOfClass:[NSDictionary class]]) { return @""; } for (NSString *key in keys) { id value = dict[key]; if ([value isKindOfClass:[NSString class]] && ((NSString *)value).length > 0) { return (NSString *)value; } } return @""; } - (void)kb_playChatAudioAtPath:(NSString *)path { if (path.length == 0) { return; } NSURL *url = [NSURL fileURLWithPath:path]; if (![NSFileManager.defaultManager fileExistsAtPath:path]) { [KBHUD showInfo:KBLocalized(@"音频文件不存在")]; return; } if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) { NSURL *currentURL = self.chatAudioPlayer.url; if ([currentURL isEqual:url]) { [self.chatAudioPlayer stop]; self.chatAudioPlayer = nil; return; } [self.chatAudioPlayer stop]; self.chatAudioPlayer = nil; } NSError *sessionError = nil; AVAudioSession *session = [AVAudioSession sharedInstance]; if ([session respondsToSelector:@selector(setCategory:options:error:)]) { [session setCategory:AVAudioSessionCategoryPlayback withOptions:AVAudioSessionCategoryOptionDuckOthers error:&sessionError]; } else { [session setCategory:AVAudioSessionCategoryPlayback error:&sessionError]; } [session setActive:YES error:nil]; NSError *playerError = nil; AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:&playerError]; if (playerError || !player) { [KBHUD showInfo:KBLocalized(@"音频播放失败")]; return; } self.chatAudioPlayer = player; [player prepareToPlay]; [player play]; } /// 播放音频数据 - (void)kb_playChatAudioData:(NSData *)audioData { if (!audioData || audioData.length == 0) { NSLog(@"[Keyboard] 音频数据为空"); return; } // 如果正在播放,先停止 if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) { [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] initWithData:audioData error:&playerError]; if (playerError || !player) { NSLog(@"[Keyboard] 音频播放器初始化失败: %@", playerError.localizedDescription); [KBHUD showInfo:KBLocalized(@"音频播放失败")]; return; } self.chatAudioPlayer = player; player.volume = 1.0; [player prepareToPlay]; [player play]; NSLog(@"[Keyboard] 开始播放音频,时长: %.2f秒", player.duration); } #pragma mark - KBKeyboardSubscriptionViewDelegate - (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view { [[KBMaiPointReporter sharedReporter] reportClickWithEventName:@"click_keyboard_subscription_close_btn" pageId:@"keyboard_subscription_panel" elementId:@"close_btn" extra:nil completion:nil]; [self hideSubscriptionPanel]; } - (void)subscriptionView:(KBKeyboardSubscriptionView *)view didTapPurchaseForProduct:(KBKeyboardSubscriptionProduct *)product { NSMutableDictionary *extra = [NSMutableDictionary dictionary]; if ([product.productId isKindOfClass:NSString.class] && product.productId.length > 0) { extra[@"product_id"] = product.productId; } [[KBMaiPointReporter sharedReporter] reportClickWithEventName:@"click_keyboard_subscription_product_btn" pageId:@"keyboard_subscription_panel" elementId:@"product_btn" extra:extra.copy completion:nil]; [self hideSubscriptionPanel]; [self kb_openRechargeForProduct:product]; } #pragma mark - lazy - (KBKeyBoardMainView *)keyBoardMainView { if (!_keyBoardMainView) { _keyBoardMainView = [[KBKeyBoardMainView alloc] init]; _keyBoardMainView.delegate = self; } return _keyBoardMainView; } - (KBFunctionView *)functionView { if (!_functionView) { _functionView = [[KBFunctionView alloc] init]; _functionView.delegate = self; // 监听功能面板顶部Bar点击 } return _functionView; } - (KBSettingView *)settingView { if (!_settingView) { _settingView = [[KBSettingView alloc] init]; } return _settingView; } - (KBChatPanelView *)chatPanelView { if (!_chatPanelView) { NSLog(@"[Keyboard] ⚠️ chatPanelView 被创建!"); _chatPanelView = [[KBChatPanelView alloc] init]; _chatPanelView.delegate = self; } return _chatPanelView; } - (NSMutableArray *)chatMessages { if (!_chatMessages) { _chatMessages = [NSMutableArray array]; } return _chatMessages; } - (KBKeyboardSubscriptionView *)subscriptionView { if (!_subscriptionView) { _subscriptionView = [[KBKeyboardSubscriptionView alloc] init]; _subscriptionView.delegate = self; _subscriptionView.hidden = YES; _subscriptionView.alpha = 0.0; } return _subscriptionView; } - (UIImageView *)personaAvatarImageView { if (!_personaAvatarImageView) { _personaAvatarImageView = [[UIImageView alloc] init]; _personaAvatarImageView.contentMode = UIViewContentModeScaleAspectFill; _personaAvatarImageView.clipsToBounds = YES; _personaAvatarImageView.hidden = YES; } return _personaAvatarImageView; } - (UIImageView *)personaGrayImageView{ if (!_personaGrayImageView) { _personaGrayImageView = [[UIImageView alloc] init]; _personaAvatarImageView.contentMode = UIViewContentModeScaleAspectFill; } return _personaGrayImageView; } #pragma mark - Persona Avatar /// 从 AppGroup 读取选中的 persona 信息 - (NSDictionary *)kb_selectedPersonaFromAppGroup { NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup]; NSDictionary *personaDict = [ud objectForKey:@"AppGroup_SelectedPersona"]; if ([personaDict isKindOfClass:[NSDictionary class]]) { return personaDict; } return nil; } /// 在 bgImageView 上显示 persona 头像 - (void)kb_showPersonaAvatarOnBgImageView { // 检查是否有完全访问权限 if (![[KBFullAccessManager shared] hasFullAccess]) { NSLog(@"[Keyboard] 未开启完全访问,无法显示 persona 头像"); return; } // 从 AppGroup 共享目录读取预处理好的小图片 NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup]; if (!containerURL) { NSLog(@"[Keyboard] 无法获取 AppGroup 容器目录"); return; } NSString *imagePath = [[containerURL path] stringByAppendingPathComponent:@"persona_cover.jpg"]; if (![[NSFileManager defaultManager] fileExistsAtPath:imagePath]) { NSLog(@"[Keyboard] persona 封面图文件不存在: %@", imagePath); return; } NSLog(@"[Keyboard] 准备从本地加载 persona 封面图: %@", imagePath); // 添加视图到 contentView,与 bgImageView 尺寸一致 if (!self.personaAvatarImageView.superview) { [self.contentView insertSubview:self.personaAvatarImageView aboveSubview:self.bgImageView]; [self.personaAvatarImageView addSubview:self.personaGrayImageView]; [self.personaAvatarImageView mas_makeConstraints:^(MASConstraintMaker *make) { make.edges.equalTo(self.bgImageView); }]; [self.personaGrayImageView mas_makeConstraints:^(MASConstraintMaker *make) { make.left.right.bottom.equalTo(self.personaAvatarImageView); make.height.mas_equalTo(self.keyBoardMainView); }]; } // 先清理旧图片 self.personaAvatarImageView.image = nil; // 从本地文件加载图片(已经是缩小后的小图片,内存占用很小) UIImage *image = [UIImage imageWithContentsOfFile:imagePath]; if (image) { self.personaAvatarImageView.image = image; self.personaAvatarImageView.hidden = NO; NSLog(@"[Keyboard] persona 封面图加载成功"); } else { NSLog(@"[Keyboard] persona 封面图加载失败"); } } /// 隐藏 persona 头像 - (void)kb_hidePersonaAvatar { self.personaAvatarImageView.hidden = YES; self.personaAvatarImageView.image = nil; } #pragma mark - Actions - (void)kb_openRechargeForProduct:(KBKeyboardSubscriptionProduct *)product { if (![product isKindOfClass:KBKeyboardSubscriptionProduct.class] || product.productId.length == 0) { [KBHUD showInfo:KBLocalized(@"Product unavailable")]; return; } NSString *encodedId = [self.class kb_urlEncodedString:product.productId]; NSString *title = [product displayTitle]; NSString *encodedTitle = [self.class kb_urlEncodedString:title]; NSMutableArray *params = [NSMutableArray arrayWithObjects:@"autoPay=1", @"prefill=1", nil]; if (encodedId.length) { [params addObject:[NSString stringWithFormat:@"productId=%@", encodedId]]; } if (encodedTitle.length) { [params addObject:[NSString stringWithFormat:@"productTitle=%@", encodedTitle]]; } NSString *query = [params componentsJoinedByString:@"&"]; NSString *urlString = [NSString stringWithFormat:@"%@://recharge?src=keyboard&%@", KB_APP_SCHEME, query]; NSURL *scheme = [NSURL URLWithString:urlString]; BOOL success = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view]; if (!success) { [KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")]; } } + (NSString *)kb_urlEncodedString:(NSString *)value { if (value.length == 0) { return @""; } NSString *reserved = @"!*'();:@&=+$,/?%#[]"; NSMutableCharacterSet *allowed = [[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy]; [allowed removeCharactersInString:reserved]; return [value stringByAddingPercentEncodingWithAllowedCharacters:allowed] ?: @""; } - (void)onTapSettingsBack { [[KBMaiPointReporter sharedReporter] reportClickWithEventName:@"click_keyboard_settings_back_btn" pageId:@"keyboard_settings" elementId:@"back_btn" extra:nil completion:nil]; [self showSettingView:NO]; } - (void)dealloc { CFNotificationCenterRemoveObserver( CFNotificationCenterGetDarwinNotifyCenter(), (__bridge const void *)(self), (__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL); } // 当键盘第一次显示时,尝试唤起主 App 以提示登录(由主 App // 决定是否真的弹登录)。 - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; // if (!_kb_didTriggerLoginDeepLinkOnce) { // _kb_didTriggerLoginDeepLinkOnce = YES; // // 仅在未登录时尝试拉起主App登录 // if (!KBAuthManager.shared.isLoggedIn) { // [self kb_tryOpenContainerForLoginIfNeeded]; // } // } } - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; [self kb_updateKeyboardLayoutIfNeeded]; } - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator: (id)coordinator { [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; __weak typeof(self) weakSelf = self; [coordinator animateAlongsideTransition:^( id _Nonnull context) { [weakSelf kb_updateKeyboardLayoutIfNeeded]; } completion:^( __unused id< UIViewControllerTransitionCoordinatorContext> _Nonnull context) { [weakSelf kb_updateKeyboardLayoutIfNeeded]; }]; } //- (void)kb_tryOpenContainerForLoginIfNeeded { // // 使用与主 App 一致的自定义 Scheme // NSURL *url = [NSURL URLWithString:[NSString // stringWithFormat:@"%@@//login?src=keyboard", KB_APP_SCHEME]]; if (!url) // return; KBWeakSelf [self.extensionContext openURL:url // completionHandler:^(__unused BOOL success) { // // 即使失败也不重复尝试;避免打扰。 // __unused typeof(weakSelf) selfStrong = weakSelf; // }]; //} #pragma mark - Theme - (void)kb_applyTheme { KBSkinTheme *t = [KBSkinManager shared].current; UIImage *img = [[KBSkinManager shared] currentBackgroundImage]; BOOL isDefaultTheme = [self kb_isDefaultKeyboardTheme:t]; BOOL isDarkMode = [self kb_isDarkModeActive]; CGSize size = self.bgImageView.bounds.size; if (isDefaultTheme) { if (isDarkMode) { // 暗黑模式:直接使用背景色,不使用图片渲染 // 这样可以避免图片渲染时的色彩空间转换导致颜色不一致 img = nil; self.bgImageView.image = nil; // 使用与系统键盘底部完全相同的颜色 if (@available(iOS 13.0, *)) { // iOS 系统键盘使用的实际颜色 (RGB: 44, 44, 46 in sRGB, 或 #2C2C2E) // 但为了完美匹配,我们使用动态颜色并直接设置为背景 UIColor *kbBgColor = [UIColor colorWithDynamicProvider:^UIColor *_Nonnull( UITraitCollection *_Nonnull traitCollection) { if (traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) { // 暗黑模式下系统键盘实际背景色 return [UIColor colorWithRed:43.0 / 255.0 green:43.0 / 255.0 blue:43.0 / 255.0 alpha:1.0]; } else { return [UIColor colorWithRed:209.0 / 255.0 green:211.0 / 255.0 blue:219.0 / 255.0 alpha:1.0]; } }]; self.contentView.backgroundColor = kbBgColor; self.bgImageView.backgroundColor = kbBgColor; } else { UIColor *darkColor = [UIColor colorWithRed:43.0 / 255.0 green:43.0 / 255.0 blue:43.0 / 255.0 alpha:1.0]; self.contentView.backgroundColor = darkColor; self.bgImageView.backgroundColor = darkColor; } } else { // 浅色模式:使用渐变图片 if (size.width <= 0 || size.height <= 0) { [self.view layoutIfNeeded]; size = self.bgImageView.bounds.size; } if (size.width <= 0 || size.height <= 0) { size = self.view.bounds.size; } if (size.width <= 0 || size.height <= 0) { size = [UIScreen mainScreen].bounds.size; } UIColor *topColor = [UIColor colorWithHex:0xDEDFE4]; UIColor *bottomColor = [UIColor colorWithHex:0xD1D3DB]; img = [self kb_defaultGradientImageWithSize:size topColor:topColor bottomColor:bottomColor]; self.contentView.backgroundColor = [UIColor clearColor]; self.bgImageView.backgroundColor = [UIColor clearColor]; } NSLog(@"==="); } else { // 自定义皮肤:清除背景色,使用皮肤图片 self.contentView.backgroundColor = [UIColor clearColor]; self.bgImageView.backgroundColor = [UIColor clearColor]; } NSLog(@"⌨️[Keyboard] apply theme id=%@ hasBg=%d", t.skinId, (img != nil)); [self kb_logSkinDiagnosticsWithTheme:t backgroundImage:img]; self.bgImageView.image = img; self.personaGrayImageView.image = img; // [self.chatPanelView kb_setBackgroundImage:img]; BOOL hasImg = (img != nil); // 触发键区按主题重绘 if ([self.keyBoardMainView respondsToSelector:@selector(kb_applyTheme)]) { // method declared in KBKeyBoardMainView.h #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" [self.keyBoardMainView performSelector:@selector(kb_applyTheme)]; #pragma clang diagnostic pop } if ([self.functionView respondsToSelector:@selector(kb_applyTheme)]) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" [self.functionView performSelector:@selector(kb_applyTheme)]; #pragma clang diagnostic pop } } - (BOOL)kb_isDefaultKeyboardTheme:(KBSkinTheme *)theme { NSString *skinId = theme.skinId ?: @""; if (skinId.length == 0 || [skinId isEqualToString:@"default"]) { return YES; } if ([skinId isEqualToString:kKBDefaultSkinIdLight]) { return YES; } return [skinId isEqualToString:kKBDefaultSkinIdDark]; } - (BOOL)kb_isDarkModeActive { if (@available(iOS 13.0, *)) { return self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark; } return NO; } - (NSString *)kb_defaultSkinIdForCurrentStyle { return [self kb_isDarkModeActive] ? kKBDefaultSkinIdDark : kKBDefaultSkinIdLight; } - (NSString *)kb_defaultSkinZipNameForCurrentStyle { return [self kb_isDarkModeActive] ? kKBDefaultSkinZipNameDark : kKBDefaultSkinZipNameLight; } - (UIImage *)kb_defaultGradientImageWithSize:(CGSize)size topColor:(UIColor *)topColor bottomColor:(UIColor *)bottomColor { if (size.width <= 0 || size.height <= 0) { return nil; } // 将动态颜色解析为当前 trait collection 下的具体颜色值 // 否则在 UIGraphicsBeginImageContextWithOptions 中渲染时会使用默认的浅色模式 UIColor *resolvedTopColor = topColor; UIColor *resolvedBottomColor = bottomColor; if (@available(iOS 13.0, *)) { resolvedTopColor = [topColor resolvedColorWithTraitCollection:self.traitCollection]; resolvedBottomColor = [bottomColor resolvedColorWithTraitCollection:self.traitCollection]; } CAGradientLayer *layer = [CAGradientLayer layer]; layer.frame = CGRectMake(0, 0, size.width, size.height); layer.startPoint = CGPointMake(0.5, 0.0); layer.endPoint = CGPointMake(0.5, 1.0); layer.colors = @[ (id)resolvedTopColor.CGColor, (id)resolvedBottomColor.CGColor ]; UIGraphicsBeginImageContextWithOptions(size, YES, 0); [layer renderInContext:UIGraphicsGetCurrentContext()]; UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return image; } - (void)kb_logSkinDiagnosticsWithTheme:(KBSkinTheme *)theme backgroundImage:(UIImage *)image { #if DEBUG NSString *skinId = theme.skinId ?: @""; NSString *name = theme.name ?: @""; NSMutableArray *roots = [NSMutableArray array]; NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup]; if (containerURL.path.length > 0) { [roots addObject:containerURL.path]; } NSString *cacheRoot = NSSearchPathForDirectoriesInDomains( NSCachesDirectory, NSUserDomainMask, YES) .firstObject; if (cacheRoot.length > 0) { [roots addObject:cacheRoot]; } NSFileManager *fm = [NSFileManager defaultManager]; NSMutableArray *lines = [NSMutableArray array]; for (NSString *root in roots) { NSString *iconsDir = [[root stringByAppendingPathComponent:@"Skins"] stringByAppendingPathComponent:skinId]; iconsDir = [iconsDir stringByAppendingPathComponent:@"icons"]; BOOL isDir = NO; BOOL exists = [fm fileExistsAtPath:iconsDir isDirectory:&isDir] && isDir; NSArray *contents = exists ? [fm contentsOfDirectoryAtPath:iconsDir error:nil] : nil; NSUInteger count = contents.count; BOOL hasQ = exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent:@"key_q.png"]]; BOOL hasQUp = exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent: @"key_q_up.png"]]; BOOL hasDel = exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent: @"key_del.png"]]; BOOL hasShift = exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent:@"key_up.png"]]; BOOL hasShiftUpper = exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent: @"key_up_upper.png"]]; NSString *line = [NSString stringWithFormat:@"root=%@ icons=%@ exist=%d count=%tu key_q=%d " @"key_q_up=%d key_del=%d key_up=%d key_up_upper=%d", root, iconsDir, exists, count, hasQ, hasQUp, hasDel, hasShift, hasShiftUpper]; [lines addObject:line]; } NSLog(@"[Keyboard] theme id=%@ name=%@ hasBg=%d\n%@", skinId, name, (image != nil), [lines componentsJoinedByString:@"\n"]); #endif } - (void)kb_consumePendingShopSkin { KBWeakSelf [KBSkinInstallBridge consumePendingRequestFromBundle:NSBundle.mainBundle completion:^(BOOL success, NSError *_Nullable error) { if (!success) { if (error) { NSLog(@"[Keyboard] skin request failed: %@", error); [KBHUD showInfo: KBLocalized( @"皮肤资源准备失败,请稍后再试")]; } return; } [weakSelf kb_applyTheme]; [KBHUD showInfo:KBLocalized( @"皮肤已更新,立即体验吧")]; }]; } #pragma mark - Default Skin - (void)kb_applyDefaultSkinIfNeeded { NSDictionary *pending = [KBSkinInstallBridge pendingRequestPayload]; if (pending.count > 0) { return; } NSString *currentId = [KBSkinManager shared].current.skinId ?: @""; BOOL isDefault = (currentId.length == 0 || [currentId isEqualToString:@"default"]); BOOL isLightDefault = [currentId isEqualToString:kKBDefaultSkinIdLight]; BOOL isDarkDefault = [currentId isEqualToString:kKBDefaultSkinIdDark]; if (!isDefault && !isLightDefault && !isDarkDefault) { // 用户已应用自定义皮肤:不随深色模式切换默认皮肤 return; } NSString *targetId = [self kb_defaultSkinIdForCurrentStyle]; NSString *targetZip = [self kb_defaultSkinZipNameForCurrentStyle]; if (currentId.length > 0 && [currentId isEqualToString:targetId]) { return; } NSError *applyError = nil; if ([KBSkinInstallBridge applyInstalledSkinWithId:targetId error:&applyError]) { return; } [KBSkinInstallBridge publishBundleSkinRequestWithId:targetId name:targetId zipName:targetZip iconShortNames:nil]; [KBSkinInstallBridge consumePendingRequestFromBundle:NSBundle.mainBundle completion:^(__unused BOOL success, __unused NSError *_Nullable error){ // 已通过通知触发主题刷新,这里无需额外处理 }]; } #pragma mark - Layout Helpers - (CGFloat)kb_portraitWidth { CGSize s = [UIScreen mainScreen].bounds.size; return MIN(s.width, s.height); } - (CGFloat)kb_keyboardHeightForWidth:(CGFloat)width { if (width <= 0) { 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); } if (containerWidth <= 0) { containerWidth = CGRectGetWidth([UIScreen mainScreen].bounds); } BOOL widthChanged = (fabs(self.kb_lastPortraitWidth - portraitWidth) >= 0.5); BOOL heightChanged = (fabs(self.kb_lastKeyboardHeight - keyboardHeight) >= 0.5); if (!widthChanged && !heightChanged && containerWidth > 0 && self.kb_widthConstraint.constant == containerWidth) { return; } self.kb_lastPortraitWidth = portraitWidth; self.kb_lastKeyboardHeight = keyboardHeight; if (self.kb_heightConstraint) { self.kb_heightConstraint.constant = keyboardHeight; } if (containerWidth > 0 && self.kb_widthConstraint) { self.kb_widthConstraint.constant = containerWidth; } if (self.contentWidthConstraint) { [self.contentWidthConstraint setOffset:portraitWidth]; } if (self.contentHeightConstraint) { [self.contentHeightConstraint setOffset:keyboardHeight]; } if (self.keyBoardMainHeightConstraint) { [self.keyBoardMainHeightConstraint setOffset:keyboardBaseHeight]; } if (self.chatPanelHeightConstraint) { [self.chatPanelHeightConstraint setOffset:chatPanelHeight]; } [self.view layoutIfNeeded]; } #pragma mark - Lazy - (UIView *)contentView { if (!_contentView) { _contentView = [[UIView alloc] init]; _contentView.backgroundColor = [UIColor clearColor]; } return _contentView; } - (UIImageView *)bgImageView { if (!_bgImageView) { _bgImageView = [[UIImageView alloc] init]; _bgImageView.contentMode = UIViewContentModeScaleAspectFill; _bgImageView.clipsToBounds = YES; } return _bgImageView; } @end