/*
* Copyright (c) 2026 Huawei Device Co., Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#import "adapter/ios/capability/vibrator/iOSAudioHapticPlayer.h"
#import <AudioToolbox/AudioToolbox.h>
#import <CoreHaptics/CoreHaptics.h>
#import <UIKit/UIKit.h>
#include "base/log/log.h"
namespace {
constexpr int32_t slideDurationMs = 10;
constexpr int32_t longPressDurationMs = 80;
constexpr int32_t dragDurationMs = 3;
constexpr NSTimeInterval millisecondsPerSecond = 1000.0;
NSURL* ToFileUrl(NSString* path)
{
if (path == nil || path.length == 0) {
return nil;
}
if ([path hasPrefix:@"file://"]) {
return [NSURL URLWithString:path];
}
return [NSURL fileURLWithPath:path];
}
} // namespace
@interface iOSAudioHapticPlayer ()
{
dispatch_queue_t _hapticsQueue;
}
@property (nonatomic, strong) UIImpactFeedbackGenerator* impactLightGenerator;
@property (nonatomic, strong) UIImpactFeedbackGenerator* impactMediumGenerator;
@property (nonatomic, strong) UISelectionFeedbackGenerator* selectionGenerator;
@property (nonatomic, copy, nullable) NSURL* effectiveUri;
@property (nonatomic, copy, nullable) NSString* effectId;
@property (nonatomic, assign) float intensity;
@property (atomic, strong, nullable) CHHapticEngine* hapticEngine;
@property (atomic, assign) BOOL isSupportsCoreHaptics;
@property (atomic, assign) BOOL isHapticEngineRunning;
@property (nonatomic, assign) SystemSoundID soundId;
@end
@implementation iOSAudioHapticPlayer
- (instancetype)init
{
self = [super init];
if (self) {
_isSupportsCoreHaptics = [CHHapticEngine.capabilitiesForHardware supportsHaptics];
_hapticsQueue = dispatch_queue_create("com.arkuix.audiohaptic.queue", DISPATCH_QUEUE_SERIAL);
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(appDidEnterBackground:)
name:UIApplicationDidEnterBackgroundNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(appWillEnterForeground:)
name:UIApplicationWillEnterForegroundNotification
object:nil];
}
return self;
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
[self releaseResources];
#if !OS_OBJECT_USE_OBJC
if (_hapticsQueue != nil) {
dispatch_release(_hapticsQueue);
_hapticsQueue = nil;
}
#endif
}
- (void)appWillEnterForeground:(NSNotification*)notification
{
[self startHapticEngine];
}
- (void)appDidEnterBackground:(NSNotification*)notification
{
[self stopHapticEngine];
}
- (void)initializeFeedbackGenerators
{
if (!@available(iOS 13.0, *) || !self.isSupportsCoreHaptics) {
_impactLightGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
_impactMediumGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
_selectionGenerator = [[UISelectionFeedbackGenerator alloc] init];
[_impactLightGenerator prepare];
[_impactMediumGenerator prepare];
[_selectionGenerator prepare];
}
}
- (void)initializeHapticEngine
{
if (!@available(iOS 13.0, *)) {
return;
}
if (self.hapticEngine) {
if (!self.isHapticEngineRunning) {
[self startHapticEngine];
}
return;
}
NSError* error = nil;
self.hapticEngine = [[CHHapticEngine alloc] initAndReturnError:&error];
if (error != nil || self.hapticEngine == nil) {
self.hapticEngine = nil;
return;
}
__weak __typeof(self) weakSelf = self;
self.hapticEngine.playsHapticsOnly = YES;
self.hapticEngine.resetHandler = ^{
__strong __typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
[strongSelf startHapticEngine];
};
[self startHapticEngine];
}
- (void)initializeSound
{
if (self.effectiveUri == nil || !self.effectiveUri.isFileURL) {
return;
}
NSString* filePath = self.effectiveUri.path;
if (filePath.length == 0 || ![[NSFileManager defaultManager] fileExistsAtPath:filePath]) {
return;
}
SystemSoundID newSoundId = 0;
OSStatus status = AudioServicesCreateSystemSoundID((__bridge CFURLRef)self.effectiveUri, &newSoundId);
if (status != kAudioServicesNoError || newSoundId == 0) {
self.soundId = 0;
return;
}
self.soundId = newSoundId;
}
- (void)releaseSound
{
if (self.soundId != 0) {
AudioServicesDisposeSystemSoundID(self.soundId);
self.soundId = 0;
}
}
- (void)registerSourceWithEffectId:(NSString*)effectiveUri effectId:(NSString*)effectId
{
self.effectiveUri = ToFileUrl(effectiveUri);
self.effectId = effectId;
}
- (void)setHapticIntensity:(float)intensity
{
_intensity = intensity;
}
- (void)prepare
{
[self initializeHapticEngine];
[self initializeFeedbackGenerators];
[self initializeSound];
}
- (void)startHapticEngine
{
if (!@available(iOS 13.0, *)) {
return;
}
if (self.hapticEngine == nil || self.isHapticEngineRunning) {
return;
}
NSError* error = nil;
[self.hapticEngine startAndReturnError:&error];
if (error) {
LOGE("Error starting haptic engine: %{public}s", error.localizedDescription.UTF8String);
} else {
self.isHapticEngineRunning = YES;
}
}
- (void)stopHapticEngine
{
if (!@available(iOS 13.0, *)) {
return;
}
if (self.hapticEngine == nil || !self.isHapticEngineRunning) {
return;
}
[self.hapticEngine stopWithCompletionHandler:^(NSError* error) {
if (error) {
LOGE("Error stopping haptic engine: %{public}s", error.localizedDescription.UTF8String);
} else {
self.isHapticEngineRunning = NO;
}
}];
}
- (void)startLightGenerator
{
dispatch_async(dispatch_get_main_queue(), ^{
[self.impactLightGenerator impactOccurred];
[self.impactLightGenerator prepare];
});
}
- (void)startMediumGenerator
{
dispatch_async(dispatch_get_main_queue(), ^{
[self.impactMediumGenerator impactOccurred];
[self.impactMediumGenerator prepare];
});
}
- (void)startSelectionGenerator
{
dispatch_async(dispatch_get_main_queue(), ^{
[self.selectionGenerator selectionChanged];
[self.selectionGenerator prepare];
});
}
- (void)startVibratorWithEffectId:(NSString *)effectId
{
if ([effectId isEqualToString:@"haptic.slide"]) {
if (@available(iOS 13.0, *) && self.isSupportsCoreHaptics) {
[self startVibratorWithParams:1.0f durationMs:slideDurationMs];
} else {
[self startLightGenerator];
}
} else if ([effectId isEqualToString:@"haptic.long_press_light"] ||
[effectId isEqualToString:@"haptic.long_press_medium"]) {
if (@available(iOS 13.0, *) && self.isSupportsCoreHaptics) {
[self startVibratorWithParams:1.0f durationMs:longPressDurationMs];
} else {
[self startMediumGenerator];
}
} else if ([effectId isEqualToString:@"haptic.drag"]) {
if (@available(iOS 13.0, *) && self.isSupportsCoreHaptics) {
[self startVibratorWithParams:1.0f durationMs:dragDurationMs];
} else {
[self startSelectionGenerator];
}
}
}
- (void)startVibratorWithParams:(float)intensity durationMs:(int32_t)durationMs
{
if (durationMs <= 0) {
return;
}
if (@available(iOS 13.0, *) && self.isSupportsCoreHaptics) {
const NSTimeInterval durationS = (NSTimeInterval)durationMs / millisecondsPerSecond;
dispatch_queue_t hapticsQueue = _hapticsQueue;
if (hapticsQueue == nil) {
return;
}
__weak __typeof(self) weakSelf = self;
dispatch_async(hapticsQueue, ^{
__strong __typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf || strongSelf.hapticEngine == nil || !strongSelf.isHapticEngineRunning) {
return;
}
NSError* error = nil;
CHHapticEventParameter* intensityParam = [[CHHapticEventParameter alloc]
initWithParameterID:CHHapticEventParameterIDHapticIntensity value:intensity];
CHHapticEvent* event = [[CHHapticEvent alloc] initWithEventType:CHHapticEventTypeHapticContinuous
parameters:@[ intensityParam ]
relativeTime:0
duration:durationS];
CHHapticPattern* pattern = [[CHHapticPattern alloc] initWithEvents:@[ event ] parameters:@[] error:&error];
if (error || !pattern) {
return;
}
id<CHHapticPatternPlayer> player = [strongSelf.hapticEngine createPlayerWithPattern:pattern error:&error];
if (error != nil || player == nil) {
return;
}
[player startAtTime:0 error:&error];
});
}
}
- (void)start
{
if ([self.effectId isEqualToString:@"haptic.slide"]) {
if (@available(iOS 13.0, *) && self.isSupportsCoreHaptics) {
[self startVibratorWithParams:self.intensity durationMs:slideDurationMs];
} else {
[self startLightGenerator];
}
}
dispatch_queue_t hapticsQueue = _hapticsQueue;
if (hapticsQueue == nil) {
return;
}
__weak __typeof(self) weakSelf = self;
dispatch_async(hapticsQueue, ^{
__strong __typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
if (strongSelf.soundId != 0) {
AudioServicesPlaySystemSound(strongSelf.soundId);
}
});
}
- (void)releaseResources
{
[self releaseSound];
[self stopHapticEngine];
self.hapticEngine = nil;
self.effectiveUri = nil;
self.effectId = nil;
self.intensity = 0.0f;
}
@end