diff --git a/build-qdomyos-zwift-Qt_5_15_2_for_iOS-Debug/qdomyoszwift.xcodeproj/project.pbxproj b/build-qdomyos-zwift-Qt_5_15_2_for_iOS-Debug/qdomyoszwift.xcodeproj/project.pbxproj index 77fc1096d1..668cee654c 100644 --- a/build-qdomyos-zwift-Qt_5_15_2_for_iOS-Debug/qdomyoszwift.xcodeproj/project.pbxproj +++ b/build-qdomyos-zwift-Qt_5_15_2_for_iOS-Debug/qdomyoszwift.xcodeproj/project.pbxproj @@ -131,6 +131,8 @@ 870A5DB32CEF8FB100839641 /* moc_technogymbike.cpp in Compile Sources */ = {isa = PBXBuildFile; fileRef = 870A5DB22CEF8FB100839641 /* moc_technogymbike.cpp */; }; 870A5DB52CEF8FD200839641 /* technogymbike.cpp in Compile Sources */ = {isa = PBXBuildFile; fileRef = 870A5DB42CEF8FD200839641 /* technogymbike.cpp */; }; 870C72652E91565E00DC8A84 /* ios_liveactivity.mm in Compile Sources */ = {isa = PBXBuildFile; fileRef = 870C72632E91565E00DC8A84 /* ios_liveactivity.mm */; }; + 87BA10032F0000010000BA03 /* ios_background_keepalive.mm in Compile Sources */ = {isa = PBXBuildFile; fileRef = 87BA10022F0000010000BA02 /* ios_background_keepalive.mm */; }; + 87BA10052F0000010000BA05 /* ios_bluetooth_background.mm in Compile Sources */ = {isa = PBXBuildFile; fileRef = 87BA10042F0000010000BA04 /* ios_bluetooth_background.mm */; }; 870F72D62FDEC0FD00181280 /* webtranslation.cpp in Compile Sources */ = {isa = PBXBuildFile; fileRef = 870F72D52FDEC0FD00181280 /* webtranslation.cpp */; }; 8710706C29C48AEA0094D0F3 /* handleurl.cpp in Compile Sources */ = {isa = PBXBuildFile; fileRef = 8710706B29C48AEA0094D0F3 /* handleurl.cpp */; }; 8710706E29C48AF30094D0F3 /* moc_handleurl.cpp in Compile Sources */ = {isa = PBXBuildFile; fileRef = 8710706D29C48AF30094D0F3 /* moc_handleurl.cpp */; }; @@ -1024,6 +1026,9 @@ 870A5DB42CEF8FD200839641 /* technogymbike.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; name = technogymbike.cpp; path = ../src/devices/technogymbike/technogymbike.cpp; sourceTree = SOURCE_ROOT; }; 870C72622E91565E00DC8A84 /* ios_liveactivity.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = ios_liveactivity.h; path = ../src/ios/ios_liveactivity.h; sourceTree = SOURCE_ROOT; }; 870C72632E91565E00DC8A84 /* ios_liveactivity.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = ios_liveactivity.mm; path = ../src/ios/ios_liveactivity.mm; sourceTree = SOURCE_ROOT; }; + 87BA10012F0000010000BA01 /* ios_background_keepalive.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = ios_background_keepalive.h; path = ../src/ios/ios_background_keepalive.h; sourceTree = SOURCE_ROOT; }; + 87BA10022F0000010000BA02 /* ios_background_keepalive.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = ios_background_keepalive.mm; path = ../src/ios/ios_background_keepalive.mm; sourceTree = SOURCE_ROOT; }; + 87BA10042F0000010000BA04 /* ios_bluetooth_background.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = ios_bluetooth_background.mm; path = ../src/ios/ios_bluetooth_background.mm; sourceTree = SOURCE_ROOT; }; 870F72D52FDEC0FD00181280 /* webtranslation.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; name = webtranslation.cpp; path = ../src/webtranslation.cpp; sourceTree = SOURCE_ROOT; }; 8710706A29C48AE90094D0F3 /* handleurl.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = handleurl.h; path = ../src/handleurl.h; sourceTree = ""; }; 8710706B29C48AEA0094D0F3 /* handleurl.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; name = handleurl.cpp; path = ../src/handleurl.cpp; sourceTree = ""; }; @@ -2425,6 +2430,9 @@ 87EFC57C2E918DAA005BB573 /* LiveActivityBridge.swift */, 870C72622E91565E00DC8A84 /* ios_liveactivity.h */, 870C72632E91565E00DC8A84 /* ios_liveactivity.mm */, + 87BA10012F0000010000BA01 /* ios_background_keepalive.h */, + 87BA10022F0000010000BA02 /* ios_background_keepalive.mm */, + 87BA10042F0000010000BA04 /* ios_bluetooth_background.mm */, 876C646E2E74139F00F1BEC0 /* fitbackupwriter.h */, 876C646F2E74139F00F1BEC0 /* fitbackupwriter.cpp */, 876C64702E74139F00F1BEC0 /* moc_fitbackupwriter.cpp */, @@ -3790,6 +3798,8 @@ buildActionMask = 2147483647; files = ( 870C72652E91565E00DC8A84 /* ios_liveactivity.mm in Compile Sources */, + 87BA10032F0000010000BA03 /* ios_background_keepalive.mm in Compile Sources */, + 87BA10052F0000010000BA05 /* ios_bluetooth_background.mm in Compile Sources */, 8738249627E646E3004F1B46 /* characteristicnotifier2acd.cpp in Compile Sources */, 8738249127E646E3004F1B46 /* dirconpacket.cpp in Compile Sources */, 870A5DB52CEF8FD200839641 /* technogymbike.cpp in Compile Sources */, diff --git a/src/homeform.cpp b/src/homeform.cpp index 4e5aad9e1b..363664ae2b 100644 --- a/src/homeform.cpp +++ b/src/homeform.cpp @@ -4,6 +4,7 @@ #ifdef Q_OS_IOS #include "ios/lockscreen.h" #include "ios/ios_liveactivity.h" +#include "ios/ios_background_keepalive.h" #endif #include "localipaddress.h" #ifdef Q_OS_ANDROID @@ -5760,6 +5761,11 @@ void homeform::Start_inner(bool send_event_to_device) { } else { #ifdef Q_OS_IOS #ifndef IO_UNDER_QT + // keep the process alive (and so every QTimer and every BLE connection) + // when the user locks the screen during the session + if (settings.value(QZSettings::ios_background_keepalive, QZSettings::default_ios_background_keepalive) + .toBool()) + iosBackgroundKeepAlive::start(); if(h && !h->appleWatchAppInstalled() && bluetoothManager->device()) h->startWorkout(bluetoothManager->device()->deviceType()); #endif @@ -5879,6 +5885,7 @@ void homeform::Stop() { h->stopWorkout(); // End iOS Live Activity when workout stops ios_liveactivity::endLiveActivity(); + iosBackgroundKeepAlive::stop(); #endif #endif @@ -6077,12 +6084,10 @@ void homeform::update() { // Timer jitter detection (same logic as trainprogram::scheduler) QDateTime now = QDateTime::currentDateTime(); qint64 msecsElapsed = lastUpdateCall.msecsTo(now); - - // Reset jitter if it's getting too large - if (qAbs(currentUpdateJitter) > 5000) { - currentUpdateJitter = 0; + if (msecsElapsed < 0) { + msecsElapsed = 0; } - + currentUpdateJitter += msecsElapsed - 1000; lastUpdateCall = now; @@ -8548,15 +8553,16 @@ void homeform::update() { currentUpdateJitter = 0; } - // Check for timer jitter gaps and fill missing SessionLine records (same logic as trainprogram) - if (!Session.empty() && qAbs(currentUpdateJitter) > 1000) { - if (currentUpdateJitter > 1000) { - // We are late... fill the missing seconds with SessionLine records - int missedSeconds = currentUpdateJitter / 1000; + if (!Session.empty() && currentUpdateJitter > 1000) { + const uint32_t lastRecordedTime = Session.last().elapsedTime; + const uint32_t elapsedGap = currentElapsedSeconds > lastRecordedTime + ? currentElapsedSeconds - lastRecordedTime - 1 + : 0; + const int missedSeconds = qMin(currentUpdateJitter / 1000, elapsedGap); + + if (missedSeconds > 0) { qDebug() << "Timer jitter detected: filling" << missedSeconds << "missing SessionLine records"; - - // Create SessionLine records for each missed second using current device values - uint32_t lastRecordedTime = Session.last().elapsedTime; + for (int i = 1; i <= missedSeconds; i++) { SessionLine gapFill( bluetoothManager->device()->currentSpeed().value(), inclination, distance1s, @@ -8564,34 +8570,21 @@ void homeform::update() { pace, cadence, bluetoothManager->device()->calories().value(), bluetoothManager->device()->elevationGain().value(), bluetoothManager->device()->negativeElevationGain().value(), - lastRecordedTime + i, // Fill each missing second + lastRecordedTime + i, lapTrigger, totalStrokes, avgStrokesRate, maxStrokesRate, avgStrokesLength, bluetoothManager->device()->currentCordinate(), strideLength, groundContact, verticalOscillation, stepCount, target_cadence->value().toDouble(), target_power->value().toDouble(), target_resistance->value().toDouble(), target_incline->value().toDouble(), target_speed->value().toDouble(), bluetoothManager->device()->CoreBodyTemperature.value(), bluetoothManager->device()->SkinTemperature.value(), bluetoothManager->device()->HeatStrainIndex.value(), 0.0, QList()); - + Session.append(gapFill); - qDebug() << "Added gap-filling SessionLine for elapsed time:" << (lastRecordedTime + i); - } - - // Adjust jitter counter (same as trainprogram) - currentUpdateJitter -= (missedSeconds * 1000); - } else if (currentUpdateJitter < -1000) { - // We are early (negative jitter)... remove excess SessionLine records - int excessSeconds = (-currentUpdateJitter) / 1000; - qDebug() << "Negative timer jitter detected: removing" << excessSeconds << "excess SessionLine records"; - - // Remove excess SessionLine records from the end - for (int i = 0; i < excessSeconds && !Session.empty(); i++) { - Session.removeLast(); - qDebug() << "Removed excess SessionLine record"; } - - // Adjust jitter counter (same as trainprogram) - currentUpdateJitter += (excessSeconds * 1000); } + + currentUpdateJitter %= 1000; + } else if (currentUpdateJitter < -1000) { + currentUpdateJitter %= 1000; } SessionLine s( diff --git a/src/ios/Info.plist b/src/ios/Info.plist index 0b4baa9927..6f3af1c653 100644 --- a/src/ios/Info.plist +++ b/src/ios/Info.plist @@ -83,6 +83,7 @@ access to step cadence in order to show it on the iphone UIBackgroundModes + audio bluetooth-central bluetooth-peripheral diff --git a/src/ios/LiveActivityBridge.swift b/src/ios/LiveActivityBridge.swift index 00d758106e..43b54aea4a 100644 --- a/src/ios/LiveActivityBridge.swift +++ b/src/ios/LiveActivityBridge.swift @@ -15,8 +15,11 @@ import UIKit private var currentActivity: Activity? private var inactivityTimer: Timer? - // Timeout in seconds - if no updates received, auto-close the Live Activity - private let inactivityTimeout: TimeInterval = 10.0 + // Timeout in seconds - if no updates received, auto-close the Live Activity. + // While QZ is in background the updates are throttled to one every 5 seconds + // (see ios_liveactivity.mm), so this has to leave a comfortable margin or a + // single late BLE notification would close the Live Activity. + private let inactivityTimeout: TimeInterval = 30.0 // MARK: - Private Methods diff --git a/src/ios/ios_app_delegate.mm b/src/ios/ios_app_delegate.mm index 14ee290b73..81ec982d5f 100644 --- a/src/ios/ios_app_delegate.mm +++ b/src/ios/ios_app_delegate.mm @@ -70,6 +70,14 @@ @implementation QIOSApplicationDelegate (QZApplicationDelegate) - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { qDebug() << "QZ iOS launch"; + + // when CoreBluetooth state restoration is in place iOS can relaunch us in + // background to deliver a BLE event: useful to know it while reading a log + NSArray *restoredCentrals = [launchOptions objectForKey:UIApplicationLaunchOptionsBluetoothCentralsKey]; + if (restoredCentrals.count > 0) { + qDebug() << "QZ iOS launch: relaunched by CoreBluetooth for" << (int)restoredCentrals.count << "central(s)"; + } + UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter]; [center requestAuthorizationWithOptions:UNAuthorizationOptionBadge completionHandler:^(BOOL granted, NSError *error){ @@ -94,6 +102,19 @@ - (void)applicationDidBecomeActive:(UIApplication *)application { qDebug() << "QZ iOS lifecycle: applicationDidBecomeActive"; } +- (void)applicationWillResignActive:(UIApplication *)application { + Q_UNUSED(application) + qDebug() << "QZ iOS lifecycle: applicationWillResignActive"; +} + +- (void)applicationDidEnterBackground:(UIApplication *)application { + // With the background keep alive running the app stays in the "background" + // state instead of being suspended, so every QTimer (homeform::update, the + // per device refresh timers) and the BLE connections keep working. + qDebug() << "QZ iOS lifecycle: applicationDidEnterBackground, remaining background time" + << application.backgroundTimeRemaining; +} + - (void)pressesBegan:(NSSet *)presses withEvent:(UIPressesEvent *)event { bool didHandleShortcut = false; diff --git a/src/ios/ios_background_keepalive.h b/src/ios/ios_background_keepalive.h new file mode 100644 index 0000000000..9e3339b9ba --- /dev/null +++ b/src/ios/ios_background_keepalive.h @@ -0,0 +1,38 @@ +#ifndef IOS_BACKGROUND_KEEPALIVE_H +#define IOS_BACKGROUND_KEEPALIVE_H + +/** + * @brief Keeps the app running while the screen is off / the app is in background. + * + * The `bluetooth-central` background mode alone is not enough: iOS suspends the + * process a few seconds after the screen goes off and only resumes it for short + * windows when a BLE notification arrives. While suspended every QTimer stops, + * so homeform::update() and the per-device refresh timers do not run and the + * session is effectively frozen. + * + * Playing an (inaudible) looping buffer on an active AVAudioSession keeps the + * app in the "running in background" state, exactly like the background + * execution plugins used on Flutter. The session is configured with + * MixWithOthers so the user's music/podcast is never interrupted. + */ +class iosBackgroundKeepAlive { + public: + /** + * @brief Activates the audio session and starts the silent loop. + * Must be called while the app is in foreground (iOS refuses to activate a + * new audio session from background). Calling it twice is a no-op. + */ + static void start(); + + /** + * @brief Stops the silent loop and deactivates the audio session. + */ + static void stop(); + + /** + * @brief True when the keep alive loop is currently playing. + */ + static bool isRunning(); +}; + +#endif // IOS_BACKGROUND_KEEPALIVE_H diff --git a/src/ios/ios_background_keepalive.mm b/src/ios/ios_background_keepalive.mm new file mode 100644 index 0000000000..7de30bf29c --- /dev/null +++ b/src/ios/ios_background_keepalive.mm @@ -0,0 +1,222 @@ +#ifndef IO_UNDER_QT +#import +#import +#import + +#include + +#include "ios/ios_background_keepalive.h" + +namespace { + +// 1 second of 8 kHz mono 16 bit silence, wrapped in a minimal WAV container. +NSData *QZSilentWavData() { + const uint32_t sampleRate = 8000; + const uint16_t channels = 1; + const uint16_t bitsPerSample = 16; + const uint32_t byteRate = sampleRate * channels * (bitsPerSample / 8); + const uint16_t blockAlign = channels * (bitsPerSample / 8); + const uint32_t dataSize = byteRate; // one second + const uint32_t riffSize = 36 + dataSize; + const uint32_t fmtSize = 16; + const uint16_t audioFormat = 1; // PCM + + NSMutableData *wav = [NSMutableData dataWithCapacity:44 + dataSize]; + [wav appendBytes:"RIFF" length:4]; + [wav appendBytes:&riffSize length:4]; + [wav appendBytes:"WAVE" length:4]; + [wav appendBytes:"fmt " length:4]; + [wav appendBytes:&fmtSize length:4]; + [wav appendBytes:&audioFormat length:2]; + [wav appendBytes:&channels length:2]; + [wav appendBytes:&sampleRate length:4]; + [wav appendBytes:&byteRate length:4]; + [wav appendBytes:&blockAlign length:2]; + [wav appendBytes:&bitsPerSample length:2]; + [wav appendBytes:"data" length:4]; + [wav appendBytes:&dataSize length:4]; + [wav increaseLengthBy:dataSize]; // zero filled == silence + + return wav; +} + +} // namespace + +@interface QZBackgroundKeepAlive : NSObject +@property(nonatomic, strong) AVAudioPlayer *player; +@property(nonatomic, strong) NSTimer *watchdog; +@property(nonatomic, assign) BOOL running; ++ (instancetype)shared; +- (void)start; +- (void)stop; +@end + +@implementation QZBackgroundKeepAlive + ++ (instancetype)shared { + static QZBackgroundKeepAlive *instance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[QZBackgroundKeepAlive alloc] init]; + }); + return instance; +} + +- (instancetype)init { + self = [super init]; + if (self) { + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleInterruption:) + name:AVAudioSessionInterruptionNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleMediaServicesReset:) + name:AVAudioSessionMediaServicesWereResetNotification + object:nil]; + } + return self; +} + +- (BOOL)activateSession { + NSError *error = nil; + AVAudioSession *session = [AVAudioSession sharedInstance]; + + // MixWithOthers: we must never stop the music/podcast the user is listening + // to while training. Playback is the only category that keeps running when + // the screen is locked. + if (![session setCategory:AVAudioSessionCategoryPlayback + withOptions:AVAudioSessionCategoryOptionMixWithOthers + error:&error]) { + qDebug() << "QZ iOS keepalive: setCategory failed" + << QString::fromUtf8(error.localizedDescription.UTF8String); + return NO; + } + + if (![session setActive:YES error:&error]) { + qDebug() << "QZ iOS keepalive: setActive failed" + << QString::fromUtf8(error.localizedDescription.UTF8String); + return NO; + } + + return YES; +} + +- (void)start { + if (self.running) { + return; + } + + if (![self activateSession]) { + return; + } + + NSError *error = nil; + self.player = [[AVAudioPlayer alloc] initWithData:QZSilentWavData() error:&error]; + if (self.player == nil) { + qDebug() << "QZ iOS keepalive: player creation failed" + << QString::fromUtf8(error.localizedDescription.UTF8String); + return; + } + + self.player.delegate = self; + self.player.numberOfLoops = -1; // forever + self.player.volume = 1.0; // the samples are silence anyway + [self.player prepareToPlay]; + + if (![self.player play]) { + qDebug() << "QZ iOS keepalive: play failed"; + self.player = nil; + return; + } + + self.running = YES; + + // iOS can stop the playback behind our back (interruptions, route changes, + // media services restart). If that happens while we are in background the + // app gets suspended a few seconds later, so keep an eye on it. + self.watchdog = [NSTimer scheduledTimerWithTimeInterval:10.0 + target:self + selector:@selector(checkPlayback) + userInfo:nil + repeats:YES]; + + qDebug() << "QZ iOS keepalive: started"; +} + +- (void)stop { + if (!self.running) { + return; + } + + self.running = NO; + + [self.watchdog invalidate]; + self.watchdog = nil; + + [self.player stop]; + self.player = nil; + + NSError *error = nil; + [[AVAudioSession sharedInstance] setActive:NO + withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation + error:&error]; + + qDebug() << "QZ iOS keepalive: stopped"; +} + +- (void)checkPlayback { + if (!self.running || self.player.isPlaying) { + return; + } + + qDebug() << "QZ iOS keepalive: playback stopped unexpectedly, restarting"; + if ([self activateSession]) { + [self.player play]; + } +} + +- (void)handleInterruption:(NSNotification *)notification { + if (!self.running) { + return; + } + + NSNumber *type = notification.userInfo[AVAudioSessionInterruptionTypeKey]; + if (type.unsignedIntegerValue == AVAudioSessionInterruptionTypeEnded) { + qDebug() << "QZ iOS keepalive: interruption ended, resuming"; + if ([self activateSession]) { + [self.player play]; + } + } else { + qDebug() << "QZ iOS keepalive: interruption began"; + } +} + +- (void)handleMediaServicesReset:(NSNotification *)notification { + Q_UNUSED(notification) + if (!self.running) { + return; + } + + qDebug() << "QZ iOS keepalive: media services were reset, rebuilding the player"; + self.running = NO; + [self.watchdog invalidate]; + self.watchdog = nil; + self.player = nil; + [self start]; +} + +- (void)audioPlayerDecodeErrorDidOccur:(AVAudioPlayer *)player error:(NSError *)error { + Q_UNUSED(player) + qDebug() << "QZ iOS keepalive: decode error" + << (error ? QString::fromUtf8(error.localizedDescription.UTF8String) : QStringLiteral("(null)")); +} + +@end + +void iosBackgroundKeepAlive::start() { [[QZBackgroundKeepAlive shared] start]; } + +void iosBackgroundKeepAlive::stop() { [[QZBackgroundKeepAlive shared] stop]; } + +bool iosBackgroundKeepAlive::isRunning() { return [QZBackgroundKeepAlive shared].running == YES; } + +#endif diff --git a/src/ios/ios_bluetooth_background.mm b/src/ios/ios_bluetooth_background.mm new file mode 100644 index 0000000000..9aa9d7b18c --- /dev/null +++ b/src/ios/ios_bluetooth_background.mm @@ -0,0 +1,83 @@ +#ifndef IO_UNDER_QT +#import +#import +#import + +// Qt creates its CBCentralManager with -initWithDelegate:queue:, so there is no +// way to ask for CoreBluetooth state preservation/restoration through the Qt +// API. Without a restore identifier iOS drops every central (and every pending +// connection) as soon as the app is terminated in background, and it never +// relaunches us to hand the peripherals back. +// +// Swizzling the designated initializer lets us add the restore identifier +// without rebuilding QtBluetooth. We only do it for the Qt owned central +// manager: any other CBCentralManager in the process (or inside a third party +// framework) keeps the standard behaviour. + +static NSString * const QZCentralRestoreIdentifier = @"org.cagnulein.qdomyoszwift.qtcentral"; + +static BOOL QZShouldAddRestoreIdentifier(id delegate) +{ + if (!delegate) { + return NO; + } + + NSString *className = NSStringFromClass([delegate class]); + return [className containsString:@"OSXBTCentralManager"]; +} + +// CoreBluetooth expects the delegate to implement centralManager:willRestoreState: +// when a restore identifier is used. Qt's central manager does not, so install a +// logging-only implementation at runtime: Qt rediscovers and reconnects the +// peripherals by itself, we just must not leave the selector missing. +static void QZInstallWillRestoreState(id delegate) +{ + Class delegateClass = [delegate class]; + if ([delegateClass instancesRespondToSelector:@selector(centralManager:willRestoreState:)]) { + return; + } + + IMP implementation = imp_implementationWithBlock(^(id _self, CBCentralManager *central, NSDictionary *state) { + NSArray *peripherals = state[CBCentralManagerRestoredStatePeripheralsKey]; + NSLog(@"QZ iOS BLE: CoreBluetooth restored %lu peripheral(s) on %@", + (unsigned long)peripherals.count, central); + }); + + class_addMethod(delegateClass, @selector(centralManager:willRestoreState:), implementation, "v@:@@"); +} + +@interface CBCentralManager (QZBackgroundRestore) +- (instancetype)qz_initWithDelegate:(id)delegate queue:(dispatch_queue_t)queue; +@end + +@implementation CBCentralManager (QZBackgroundRestore) + ++ (void)load +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + Method originalMethod = class_getInstanceMethod(self, @selector(initWithDelegate:queue:)); + Method swizzledMethod = class_getInstanceMethod(self, @selector(qz_initWithDelegate:queue:)); + if (originalMethod && swizzledMethod) { + method_exchangeImplementations(originalMethod, swizzledMethod); + } + }); +} + +- (instancetype)qz_initWithDelegate:(id)delegate queue:(dispatch_queue_t)queue +{ + if (!QZShouldAddRestoreIdentifier(delegate)) { + // after the swizzle this selector points to the original implementation + return [self qz_initWithDelegate:delegate queue:queue]; + } + + QZInstallWillRestoreState(delegate); + + NSDictionary *options = @{ CBCentralManagerOptionRestoreIdentifierKey: QZCentralRestoreIdentifier }; + NSLog(@"QZ iOS BLE: enabling CoreBluetooth restore identifier %@ for %@", + QZCentralRestoreIdentifier, NSStringFromClass([delegate class])); + return [self initWithDelegate:delegate queue:queue options:options]; +} + +@end +#endif diff --git a/src/ios/ios_liveactivity.mm b/src/ios/ios_liveactivity.mm index 2eca4dd044..3dd93be0b7 100644 --- a/src/ios/ios_liveactivity.mm +++ b/src/ios/ios_liveactivity.mm @@ -13,6 +13,10 @@ static LiveActivityBridge* _liveActivityManager = nil; +// minimum delay between two Live Activity updates while the app is in background, +// must stay well below the inactivity timeout of LiveActivityBridge.swift +static const NSTimeInterval QZLiveActivityBackgroundInterval = 5.0; + static bool liveActivitiesSupportedOnCurrentRuntime() { if (!@available(iOS 16.1, *)) { qDebug() << "Live Activities require iOS 16.1 or later"; @@ -57,6 +61,23 @@ static bool liveActivitiesSupportedOnCurrentRuntime() { return; } + // ActivityKit gives the app a limited budget of Live Activity updates while + // it is not in foreground: pushing one update per second burns it in a few + // minutes, after which iOS silently stops applying the new content and the + // Live Activity freezes (and is then rendered as "stale"). Slow down to one + // update every 5 seconds while we are in background / screen off. + static NSTimeInterval lastBackgroundUpdate = 0; + if ([UIApplication sharedApplication].applicationState != UIApplicationStateActive) { + const NSTimeInterval now = [NSDate timeIntervalSinceReferenceDate]; + if (lastBackgroundUpdate != 0 && (now - lastBackgroundUpdate) < QZLiveActivityBackgroundInterval) { + return; + } + lastBackgroundUpdate = now; + } else { + // back in foreground: let the next background update go through at once + lastBackgroundUpdate = 0; + } + if (_liveActivityManager != nil) { NSString *leadingMetric = [NSString stringWithCString:compactLeadingMetric encoding:NSUTF8StringEncoding]; NSString *trailingMetric = [NSString stringWithCString:compactTrailingMetric encoding:NSUTF8StringEncoding]; diff --git a/src/qdomyos-zwift.pri b/src/qdomyos-zwift.pri index 0f0c2f12fe..7631658fdc 100644 --- a/src/qdomyos-zwift.pri +++ b/src/qdomyos-zwift.pri @@ -1027,9 +1027,14 @@ ios { } ios { + # AVFoundation is used by the background keep alive (silent audio session) + LIBS += -framework AVFoundation + OBJECTIVE_SOURCES += ios/lockscreen.mm \ ios/ios_eliteariafan.mm \ ios/ios_app_delegate.mm \ + ios/ios_bluetooth_background.mm \ + ios/ios_background_keepalive.mm \ ios/ios_liveactivity.mm \ fit-sdk/FitDecode.mm \ fit-sdk/FitDeveloperField.mm \ @@ -1043,7 +1048,8 @@ ios { SOURCES += ios/M3iNSQT.cpp OBJECTIVE_HEADERS += ios/M3iNS.h \ - ios/ios_liveactivity.h + ios/ios_liveactivity.h \ + ios/ios_background_keepalive.h QMAKE_INFO_PLIST = ios/Info.plist QMAKE_ASSET_CATALOGS = $$PWD/ios/Images.xcassets diff --git a/src/qzsettings.cpp b/src/qzsettings.cpp index 11317278ad..f808e9c1a2 100644 --- a/src/qzsettings.cpp +++ b/src/qzsettings.cpp @@ -1129,6 +1129,8 @@ const QString QZSettings::zwiftplay_gear_paddle_right = QStringLiteral("zwiftpla const QString QZSettings::zwiftplay_gear_lb = QStringLiteral("zwiftplay_gear_lb"); const QString QZSettings::zwiftplay_gear_rb = QStringLiteral("zwiftplay_gear_rb"); +const QString QZSettings::ios_background_keepalive = QStringLiteral("ios_background_keepalive"); + // MyWhoosh Link settings const QString QZSettings::mywhoosh_link_enabled = QStringLiteral("mywhoosh_link_enabled"); const QString QZSettings::mywhoosh_link_override_gears = QStringLiteral("mywhoosh_link_override_gears"); @@ -1279,7 +1281,7 @@ const QString QZSettings::default_shortcut_start_stop = QStringLiteral(""); const QString QZSettings::shortcut_stop = QStringLiteral("shortcut_stop"); const QString QZSettings::default_shortcut_stop = QStringLiteral(""); -const uint32_t allSettingsCount = 1001; +const uint32_t allSettingsCount = 1002; QVariant allSettings[allSettingsCount][2] = { {QZSettings::cryptoKeySettingsProfiles, QZSettings::default_cryptoKeySettingsProfiles}, @@ -2305,6 +2307,7 @@ QVariant allSettings[allSettingsCount][2] = { {QZSettings::zwiftplay_gear_lb, QZSettings::default_zwiftplay_gear_lb}, {QZSettings::zwiftplay_gear_rb, QZSettings::default_zwiftplay_gear_rb}, {QZSettings::freebeat_serialport, QZSettings::default_freebeat_serialport}, + {QZSettings::ios_background_keepalive, QZSettings::default_ios_background_keepalive}, }; void QZSettings::qDebugAllSettings(bool showDefaults) { diff --git a/src/qzsettings.h b/src/qzsettings.h index ffe0d28d78..5691ffe950 100644 --- a/src/qzsettings.h +++ b/src/qzsettings.h @@ -3308,6 +3308,9 @@ class QZSettings { static const QString zwiftplay_gear_rb; // Ride On (RB) static constexpr int default_zwiftplay_gear_rb = 0; // Disabled + static const QString ios_background_keepalive; + static constexpr bool default_ios_background_keepalive = true; + /** * @brief Write the QSettings values using the constants from this namespace. * @param showDefaults Optionally indicates if the default should be shown with the key. diff --git a/src/settings-catalog.json b/src/settings-catalog.json index 79158e00d0..e08c51fb29 100644 --- a/src/settings-catalog.json +++ b/src/settings-catalog.json @@ -2,7 +2,7 @@ "$schema": "https://qdomyos-zwift.local/settings-catalog.schema.json", "schemaVersion": 1, "format": "qdomyos-zwift-settings-catalog", - "settingCount": 965, + "settingCount": 966, "pages": [ { "key": "page_custom_gear_table", @@ -3722,6 +3722,19 @@ "defaultExpression": "false", "options": null }, + { + "key": "ios_background_keepalive", + "name": "iOS Background Keep Alive", + "description": "iOS only. Keeps QZ running when the screen is off or QZ is in background, so that the Bluetooth connections and the session timers don't freeze. It uses a silent audio track mixed with your music, so nothing you're listening to is interrupted. Turn it off only if you don't need to train with the screen locked. Default is on.", + "parent": "Experimental Features", + "type": "boolean", + "qmlType": "bool", + "control": "switch", + "visible": true, + "defaultValue": true, + "defaultExpression": "true", + "options": null + }, { "key": "applewatch_fakedevice", "name": "Fake Device", @@ -13722,4 +13735,4 @@ "options": null } ] -} \ No newline at end of file +} diff --git a/src/settings.qml b/src/settings.qml index 3b0d23997c..ae83ccbd49 100644 --- a/src/settings.qml +++ b/src/settings.qml @@ -1737,6 +1737,7 @@ import AndroidStatusBar 1.0 property int mywhoosh_link_emote_value: 1 property bool waterrower_usb: false property string freebeat_serialport: "" + property bool ios_background_keepalive: true } @@ -16062,6 +16063,33 @@ import AndroidStatusBar 1.0 color: Material.color(Material.Lime) } + IndicatorOnlySwitch { + text: qsTr("iOS Background Keep Alive") + spacing: 0 + bottomPadding: 0 + topPadding: 0 + rightPadding: 0 + leftPadding: 0 + clip: false + checked: settings.ios_background_keepalive + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + Layout.fillWidth: true + onClicked: { settings.ios_background_keepalive = checked; } + } + + Label { + text: qsTr("iOS only. Keeps QZ running when the screen is off or QZ is in background, so that the Bluetooth connections and the session timers don't freeze. It uses a silent audio track mixed with your music, so nothing you're listening to is interrupted. Turn it off only if you don't need to train with the screen locked. Default is on.") + font.bold: true + font.italic: true + font.pixelSize: Qt.application.font.pixelSize - 2 + textFormat: Text.PlainText + wrapMode: Text.WordWrap + verticalAlignment: Text.AlignVCenter + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + Layout.fillWidth: true + color: Material.color(Material.Lime) + } + IndicatorOnlySwitch { id: appleWatchFakeDeviceDelegate text: qsTr("Fake Device")