Skip to content

Commit 571fa7b

Browse files
authored
Merge branch 'master' into codex/ios-background-central-homeform
2 parents 6a77506 + c9ad7a3 commit 571fa7b

43 files changed

Lines changed: 2776 additions & 491 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ tst/Devices/
168168
- Platform-specific configuration paths
169169
- Profile system for multiple users/devices
170170
- Extensive customization options for device behavior
171-
- `src/settings-catalog.json` is the generated cross-platform catalog of every persistent setting declared in `src/settings.qml` and its settings child QML files. When adding, removing, or renaming any settings QML property, run `python tools/generate_settings_catalog.py` and then `python tools/generate_settings_catalog.py --check`. The check must report exact coverage before the change is complete.
171+
- `src/settings-catalog.json` is the manually maintained cross-platform catalog of every persistent setting declared in `src/settings.qml` and its settings child QML files. When adding, removing, or renaming any settings QML property, update `src/settings-catalog.json` by hand in the same change. Keep `settingCount` synchronized and add new settings at the end of the catalog.
172172

173173
## External Dependencies
174174

docs/qz_faq_public.md

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# QZ (qdomyos-zwift) - Frequently Asked Questions
2+
3+
## Getting Started / Builds
4+
5+
### 1. Joining the beta program (Android)
6+
**Q: How can I join the QZ beta program / get the latest beta build?**
7+
8+
For Android, join the beta here:
9+
https://play.google.com/apps/testing/org.cagnulen.qdomyoszwift
10+
Then update the app from the Play Store as usual.
11+
12+
### 2. Trying a nightly/test build (APK)
13+
**Q: Can I try a fix or new feature before it's officially released?**
14+
15+
Nightly debug builds are published here (replace the date with a recent one):
16+
https://github.com/cagnulein/qdomyos-zwift/releases/download/nightly-YYYY-MM-DD/android-debug.apk
17+
You can install this over your existing app without losing your settings.
18+
19+
---
20+
21+
## Integrations (Garmin, Google Fit, Zwift)
22+
23+
### 3. Garmin integration
24+
**Q: How do I use QZ with my Garmin watch (e.g. to control treadmill speed/incline from a Garmin workout)?**
25+
26+
Set your Garmin credentials in QZ. QZ will then download Garmin workouts
27+
directly and you can also push completed workouts back to Garmin
28+
automatically. You can also create workouts directly inside QZ.
29+
30+
### 18. Apple Health - reading age/weight/heart rate zones automatically
31+
**Q: Can QZ read my age, weight, resting heart rate and HR zones automatically from Apple Health, so I don't have to update them manually?**
32+
33+
Not currently - these values are entered manually under Settings > User and
34+
won't auto-update from Apple Health. If yours are out of date, update them
35+
there. Automatic syncing of this profile data from Apple Health is a
36+
possible future improvement.
37+
38+
---
39+
40+
## Device Compatibility
41+
42+
### 7. Compatibility questions (new bike/treadmill/rower brand)
43+
**Q: Is my [bike/treadmill/rower] compatible with QZ / Zwift via QZ?**
44+
45+
Most FTMS-compatible and many proprietary BLE trainers/treadmills/rowers are
46+
supported. You can try the trial version first. If your device isn't
47+
recognized, send a debug log so support for it can be added or fixed.
48+
49+
### 19. Kinomap-compatible bikes (Kettler, Merach, and others)
50+
**Q: Does QZ work with bikes that connect to Kinomap (e.g. Kettler Racer S, Merach S28)?**
51+
52+
Yes - any indoor bike that supports the Kinomap BLE protocol can connect
53+
to QZ using that protocol, with working auto resistance. This includes
54+
Kettler bikes and other brands such as Merach that use the same Kinomap
55+
BLE stack. Once QZ is connected, apps like Zwift, MyWhoosh and Rouvy can
56+
control resistance automatically through QZ.
57+
58+
Try the trial first; if your bike isn't detected or resistance doesn't
59+
behave correctly, send a debug log.
60+
61+
### 21. Speed shown in training app differs from bike console
62+
**Q: My bike console shows (e.g.) 25 km/h but the training app (Kinomap, Zwift, etc.) shows only 8 km/h - why?**
63+
64+
These are two different things. The console speed is the actual flywheel/wheel
65+
speed. The training app's speed is a virtual estimate calculated from power
66+
output (watts) — it represents how fast you'd go on a flat road at that effort.
67+
QZ passes through whatever the bike reports over BLE; the discrepancy isn't
68+
something QZ can correct because it comes from how each app converts power to
69+
virtual speed.
70+
71+
### 22. HR-linked speed control on treadmill
72+
**Q: Can QZ automatically adjust treadmill speed based on heart rate (e.g. reduce speed when HR is too high, restore it when HR drops)?**
73+
74+
Yes - QZ has a heart rate linked speed feature in the treadmill settings. You
75+
can configure an HR threshold and speed adjustment so that when your HR goes
76+
above the target, QZ automatically reduces speed, and when it drops back down,
77+
QZ restores the original pace. This achieves the same effect as a simple
78+
HR-controlled interval loop.
79+
80+
Sound alerts triggered by HR zone are not currently supported.
81+
82+
---
83+
84+
## Troubleshooting & Support
85+
86+
### 9. Getting a debug log
87+
**Q: How do I get a debug log to send for troubleshooting?**
88+
89+
Follow this guide:
90+
https://github.com/cagnulein/qdomyos-zwift/wiki/How-do-i-get-the-debug-log-in-case-something-doesn't-work%3F
91+
### 14. Calorie burn estimate looks way too high
92+
**Q: QZ shows an unrealistically high calorie burn (e.g. 30+ kcal/minute) - what's wrong?**
93+
94+
Without a heart-rate monitor connected, QZ estimates calories from your
95+
speed/incline and the weight set in your profile. A very high value usually
96+
means the weight setting is wrong (e.g. entered in the wrong unit, kg vs
97+
lb) or the speed/incline readings themselves are unrealistic. Check
98+
Settings > User for your weight, and check the live speed/incline tiles. If
99+
those look correct and calories are still off, send a debug log.
100+
101+
### 24. BLE advertising error on Android ("Cannot start advertising in ClosingState")
102+
**Q: QZ shows an "AdvertisingError / Cannot start advertising in ClosingState" in the debug log and another device (e.g. a second phone/tablet running a training app) can't find QZ via Bluetooth.**
103+
104+
This is an Android BLE limitation triggered by long Bluetooth device names. Workaround:
105+
1. Go to Android Settings > About phone (or About tablet) > Device name.
106+
2. Rename the device to a **short name of 4 characters or fewer** (e.g. "tab1").
107+
3. Reboot the device.
108+
109+
After the reboot, QZ should advertise correctly and be visible to other apps/devices.
110+
111+
---
112+
113+
### 25. Running QZ and Zwift on the same Mac (Apple Silicon / macOS)
114+
**Q: Can I run QZ and Zwift on the same MacBook (Apple Silicon, macOS Sequoia) to bridge my trainer?**
115+
116+
Yes, QZ has a macOS build that runs on Apple Silicon. If you run into issues with your trainer not being detected or metrics not updating when both QZ and Zwift are on the same Mac, please share a debug log from a session that reproduces the problem so it can be diagnosed:
117+
https://github.com/cagnulein/qdomyos-zwift/wiki/How-do-i-get-the-debug-log-in-case-something-doesn't-work%3F

docs/settings-catalog.md

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Settings Catalog
22

3-
`src/settings-catalog.json` is a generated catalog of every persistent setting declared in the settings QML files:
3+
`src/settings-catalog.json` is a manually maintained catalog of every persistent setting declared in the settings QML files:
44

55
- `src/settings.qml`
66
- `src/settings-tiles.qml`
@@ -48,16 +48,14 @@ Each virtual setting contains:
4848

4949
## Updating
5050

51-
When adding, removing, or renaming a setting in any settings QML file, regenerate the catalog:
51+
When adding, removing, or renaming a setting in any settings QML file, update the catalog by hand in the same change.
5252

53-
```bash
54-
python tools/generate_settings_catalog.py
55-
```
53+
For a new persistent setting:
5654

57-
Then validate that the catalog still exactly covers the QML declarations:
55+
- Add the QML property at the end of the property list.
56+
- Add one matching entry to `src/settings-catalog.json`.
57+
- Increment top-level `settingCount`.
58+
- Keep `key`, `qmlType`, `defaultValue`, and `defaultExpression` aligned with the QML declaration.
59+
- Set `parent`, `name`, `description`, `control`, and `options` from the visible UI control.
5860

59-
```bash
60-
python tools/generate_settings_catalog.py --check
61-
```
62-
63-
The check fails if any QML setting is missing from the catalog, if the catalog contains an undeclared setting, if a setting is duplicated, or if declaration metadata no longer matches the QML sources.
61+
For a removed setting, delete its catalog entry and decrement `settingCount`. For a rename, update the catalog key in the same commit as the QML and `QZSettings` changes.

src/Home.qml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ HomeForm {
3838
property string theme_tile_shadow_color: "#9C27B0"
3939
property int theme_tile_secondline_textsize: 12
4040
property bool skipLocationServicesDialog: false
41+
property bool trainprogram_sound_on_segment: false
42+
}
43+
44+
SoundEffect {
45+
id: trainingProgramSegmentSound
46+
source: "qrc:/sounds/training-program-segment.wav"
47+
volume: 0.9
48+
}
49+
50+
Connections {
51+
target: rootItem
52+
onTrainingProgramIntervalSoundRequested: trainingProgramSegmentSound.play()
4153
}
4254

4355
MessageDialog {

src/android/src/CustomQtActivity.java

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@
99
import android.view.WindowInsets;
1010
import android.view.WindowManager;
1111
import android.view.DisplayCutout;
12+
import android.graphics.Insets;
1213
import org.qtproject.qt5.android.bindings.QtActivity;
1314

1415
public class CustomQtActivity extends QtActivity {
1516
private static final String TAG = "CustomQtActivity";
1617

1718
// Declare the native method that will be implemented in C++
18-
private static native void onInsetsChanged(int top, int bottom, int left, int right);
19+
private static native void onInsetsChanged(int top, int bottom, int left, int right,
20+
int waterfallTop, int waterfallBottom,
21+
int waterfallLeft, int waterfallRight);
1922
private static native void nativeOnOAuthCallback(String callbackUrl);
2023

2124
private void dispatchOAuthCallback(Intent intent) {
@@ -63,6 +66,10 @@ public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
6366
int bottom = 0;
6467
int left = 0;
6568
int right = 0;
69+
int waterfallTop = 0;
70+
int waterfallBottom = 0;
71+
int waterfallLeft = 0;
72+
int waterfallRight = 0;
6673

6774
if (density > 0) {
6875
// Use system window insets as primary source
@@ -80,6 +87,15 @@ public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
8087
right = Math.max(right, Math.round(cutout.getSafeInsetRight() / density));
8188
top = Math.max(top, Math.round(cutout.getSafeInsetTop() / density));
8289
bottom = Math.max(bottom, Math.round(cutout.getSafeInsetBottom() / density));
90+
91+
// Android 11+ exposes curved waterfall display areas separately from cutouts.
92+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
93+
Insets waterfallInsets = cutout.getWaterfallInsets();
94+
waterfallLeft = Math.round(waterfallInsets.left / density);
95+
waterfallRight = Math.round(waterfallInsets.right / density);
96+
waterfallTop = Math.round(waterfallInsets.top / density);
97+
waterfallBottom = Math.round(waterfallInsets.bottom / density);
98+
}
8399
}
84100
}
85101
}
@@ -100,6 +116,13 @@ public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
100116
" Bottom:" + cutout.getSafeInsetBottom() +
101117
" Left:" + cutout.getSafeInsetLeft() +
102118
" Right:" + cutout.getSafeInsetRight());
119+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
120+
Insets waterfallInsets = cutout.getWaterfallInsets();
121+
Log.d(TAG, "Waterfall insets - Top:" + waterfallInsets.top +
122+
" Bottom:" + waterfallInsets.bottom +
123+
" Left:" + waterfallInsets.left +
124+
" Right:" + waterfallInsets.right);
125+
}
103126
}
104127
}
105128

@@ -109,7 +132,7 @@ public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
109132
// forces edge-to-edge, triggering this before QtActivity finishes
110133
// loading libqdomyos-zwift in its background thread).
111134
try {
112-
onInsetsChanged(top, bottom, left, right);
135+
onInsetsChanged(top, bottom, left, right, waterfallTop, waterfallBottom, waterfallLeft, waterfallRight);
113136
} catch (UnsatisfiedLinkError ignored) {
114137
// Qt not ready yet; insets will be re-applied once Qt initializes.
115138
}

src/android/src/HealthConnectHelper.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class HealthConnectHelper {
3737
private const val HEALTH_CONNECT_PROVIDER = "com.google.android.apps.healthdata"
3838
private const val PREFS_NAME = "qz_health_connect"
3939
private const val PREF_PERMISSION_PROMPT_PENDING = "permission_prompt_pending_v2"
40+
private const val MIN_HRV_RMSSD_MILLIS = 1.0
4041

4142
private var initialized = false
4243

@@ -310,7 +311,7 @@ class HealthConnectHelper {
310311
stepsCadenceSamples += StepsCadenceRecord.Sample(time = time, rate = cadence)
311312
}
312313
}
313-
if (hasHrv && hrv > 0.0) {
314+
if (hasHrv && hrv >= MIN_HRV_RMSSD_MILLIS) {
314315
hrvRecords += HeartRateVariabilityRmssdRecord(
315316
time = time,
316317
zoneOffset = zoneOffset,

src/android/tests/org/cagnulen/qdomyoszwift/HealthConnectHelperTest.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,18 @@ class HealthConnectHelperTest {
327327
assertEquals(42.5, hrvRecords[0].heartRateVariabilityMillis, 0.001)
328328
}
329329

330+
@Test
331+
fun `buildRecords omits HeartRateVariabilityRmssdRecord when hrv is below health connect minimum`() {
332+
val samples = JSONArray().apply {
333+
put(makeSample(1_000L, hrv = 0.9067155184426361))
334+
put(makeSample(2_000L, hrv = 1.0))
335+
}
336+
val records = HealthConnectHelper.buildRecords(samples, "t", BIKE, null)
337+
val hrvRecords = records.filterIsInstance<HeartRateVariabilityRmssdRecord>()
338+
assertEquals(1, hrvRecords.size)
339+
assertEquals(1.0, hrvRecords[0].heartRateVariabilityMillis, 0.001)
340+
}
341+
330342
@Test
331343
fun `buildRecords omits HeartRateVariabilityRmssdRecord when hrv is 0`() {
332344
val samples = JSONArray().apply { put(makeSample(1_000L)) }

src/androidstatusbar.cpp

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,27 +38,41 @@ int AndroidStatusBar::apiLevel() const
3838
#endif
3939
}
4040

41-
void AndroidStatusBar::onInsetsChanged(int top, int bottom, int left, int right)
41+
void AndroidStatusBar::onInsetsChanged(int top, int bottom, int left, int right, int waterfallTop,
42+
int waterfallBottom, int waterfallLeft, int waterfallRight)
4243
{
43-
if (m_top != top || m_bottom != bottom || m_left != left || m_right != right) {
44+
if (m_top != top || m_bottom != bottom || m_left != left || m_right != right ||
45+
m_waterfallTop != waterfallTop || m_waterfallBottom != waterfallBottom ||
46+
m_waterfallLeft != waterfallLeft || m_waterfallRight != waterfallRight) {
4447
m_top = top;
4548
m_bottom = bottom;
4649
m_left = left;
4750
m_right = right;
48-
qDebug() << "Insets changed - Top:" << m_top << "Bottom:" << m_bottom << "Left:" << m_left << "Right:" << m_right;
51+
m_waterfallTop = waterfallTop;
52+
m_waterfallBottom = waterfallBottom;
53+
m_waterfallLeft = waterfallLeft;
54+
m_waterfallRight = waterfallRight;
55+
qDebug() << "Insets changed - Top:" << m_top << "Bottom:" << m_bottom << "Left:" << m_left
56+
<< "Right:" << m_right << "WaterfallTop:" << m_waterfallTop
57+
<< "WaterfallBottom:" << m_waterfallBottom << "WaterfallLeft:" << m_waterfallLeft
58+
<< "WaterfallRight:" << m_waterfallRight;
4959
emit insetsChanged();
5060
}
5161
}
5262

5363
#ifdef Q_OS_ANDROID
5464
// JNI method with standard naming convention
5565
extern "C" JNIEXPORT void JNICALL
56-
Java_org_cagnulen_qdomyoszwift_CustomQtActivity_onInsetsChanged(JNIEnv *env, jobject thiz, jint top, jint bottom, jint left, jint right)
66+
Java_org_cagnulen_qdomyoszwift_CustomQtActivity_onInsetsChanged(JNIEnv *env, jobject thiz, jint top,
67+
jint bottom, jint left, jint right,
68+
jint waterfallTop, jint waterfallBottom,
69+
jint waterfallLeft, jint waterfallRight)
5770
{
5871
Q_UNUSED(env);
5972
Q_UNUSED(thiz);
6073
if (AndroidStatusBar::instance()) {
61-
AndroidStatusBar::instance()->onInsetsChanged(top, bottom, left, right);
74+
AndroidStatusBar::instance()->onInsetsChanged(top, bottom, left, right, waterfallTop, waterfallBottom,
75+
waterfallLeft, waterfallRight);
6276
}
6377
}
6478
#endif

src/androidstatusbar.h

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ class AndroidStatusBar : public QObject
1111
Q_PROPERTY(int navigationBarHeight READ navigationBarHeight NOTIFY insetsChanged)
1212
Q_PROPERTY(int leftInset READ leftInset NOTIFY insetsChanged)
1313
Q_PROPERTY(int rightInset READ rightInset NOTIFY insetsChanged)
14+
Q_PROPERTY(int waterfallTopInset READ waterfallTopInset NOTIFY insetsChanged)
15+
Q_PROPERTY(int waterfallBottomInset READ waterfallBottomInset NOTIFY insetsChanged)
16+
Q_PROPERTY(int waterfallLeftInset READ waterfallLeftInset NOTIFY insetsChanged)
17+
Q_PROPERTY(int waterfallRightInset READ waterfallRightInset NOTIFY insetsChanged)
18+
Q_PROPERTY(bool hasWaterfallDisplay READ hasWaterfallDisplay NOTIFY insetsChanged)
1419
Q_PROPERTY(int apiLevel READ apiLevel CONSTANT)
1520

1621
public:
@@ -23,10 +28,16 @@ class AndroidStatusBar : public QObject
2328
int navigationBarHeight() const { return m_bottom; }
2429
int leftInset() const { return m_left; }
2530
int rightInset() const { return m_right; }
31+
int waterfallTopInset() const { return m_waterfallTop; }
32+
int waterfallBottomInset() const { return m_waterfallBottom; }
33+
int waterfallLeftInset() const { return m_waterfallLeft; }
34+
int waterfallRightInset() const { return m_waterfallRight; }
35+
bool hasWaterfallDisplay() const { return m_waterfallTop > 0 || m_waterfallBottom > 0 || m_waterfallLeft > 0 || m_waterfallRight > 0; }
2636
int apiLevel() const;
2737

2838
public slots:
29-
void onInsetsChanged(int top, int bottom, int left, int right);
39+
void onInsetsChanged(int top, int bottom, int left, int right, int waterfallTop, int waterfallBottom,
40+
int waterfallLeft, int waterfallRight);
3041

3142
signals:
3243
void insetsChanged();
@@ -36,6 +47,10 @@ public slots:
3647
int m_bottom = 0;
3748
int m_left = 0;
3849
int m_right = 0;
50+
int m_waterfallTop = 0;
51+
int m_waterfallBottom = 0;
52+
int m_waterfallLeft = 0;
53+
int m_waterfallRight = 0;
3954

4055
static AndroidStatusBar* m_instance;
4156
};

0 commit comments

Comments
 (0)