diff --git a/CMakeLists.txt b/CMakeLists.txt index 0b595322..96895518 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,6 +37,7 @@ option(ENABLE_MQTT "MQTT broker support" ON) option(ENABLE_OPENSSL "OpenSSL for various encryption needs" ON) option(ENABLE_MBEDTLS "mbedTLS for various encryption needs" ON) option(ENABLE_SECURE_STORAGE "Store MQTT/MySQL passwords in the OS secure store via QtKeychain" ON) +option(ENABLE_TESTS "Build the Entitlement unit tests (developer-only)" OFF) ################################################################################ @@ -116,6 +117,12 @@ set(SOURCES src/rc4/rc4.cpp src/rc4/rc4.h src/blufi/BluFiFrame.cpp src/blufi/BluFiFrame.h src/blufi/BluFiUtils.h + src/Entitlement/Entitlement.cpp src/Entitlement/Entitlement.h + src/Entitlement/EntitlementManager.cpp src/Entitlement/EntitlementManager.h + src/Entitlement/EntitlementEras.cpp src/Entitlement/EntitlementEras.h + src/Entitlement/Provenance.cpp src/Entitlement/Provenance.h + src/Entitlement/ProvenanceStore.cpp src/Entitlement/ProvenanceStore.h + thirdparty/TheengsDecoder/src/decoder.cpp thirdparty/TheengsDecoder/src/decoder.h thirdparty/IconLibrary/IconLibrary_material.qrc @@ -584,3 +591,12 @@ set_target_properties(${CMAKE_PROJECT_NAME} PROPERTIES #) ################################################################################ + +# Developer-only unit tests for the Entitlement module. Off by default so the +# shipped app build is unaffected. Enable with: cmake -B build/ -DENABLE_TESTS=ON +if(ENABLE_TESTS) + enable_testing() + add_subdirectory(tests) +endif() + +################################################################################ diff --git a/assets/android/AndroidManifest.xml b/assets/android/AndroidManifest.xml index a103feca..d5a0a3a2 100644 --- a/assets/android/AndroidManifest.xml +++ b/assets/android/AndroidManifest.xml @@ -56,7 +56,10 @@ + android:label="Theengs BLE" + android:allowBackup="true" + android:fullBackupContent="@xml/backup_rules" + android:dataExtractionRules="@xml/data_extraction_rules"> + + + + + diff --git a/assets/android/res/xml/data_extraction_rules.xml b/assets/android/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..f9e4148e --- /dev/null +++ b/assets/android/res/xml/data_extraction_rules.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/qml/About.qml b/qml/About.qml index b141604d..c29c1e83 100644 --- a/qml/About.qml +++ b/qml/About.qml @@ -157,6 +157,23 @@ Item { sourceSize: 24 } + ListItem { // entitlement / Pro status + width: parent.width + visible: entitlement.stamped + + text: entitlement.isPro ? qsTr("Theengs Pro — Active") : qsTr("Theengs Free") + source: "qrc:/IconLibrary/material-symbols/stars.svg" + sourceSize: 24 + + // HIL + a11y: the About screen is otherwise opaque Qt + // SurfaceView content; objectName + Accessible surface the + // entitlement state as a uiautomator-readable node + // (resource-id "hil-entitlement-status" + content-desc). + objectName: "hil-entitlement-status" + Accessible.role: Accessible.StaticText + Accessible.name: text + } + Item { height: 4; width: 4; } // spacer //////// diff --git a/src/Entitlement/Entitlement.cpp b/src/Entitlement/Entitlement.cpp new file mode 100644 index 00000000..e9aecd14 --- /dev/null +++ b/src/Entitlement/Entitlement.cpp @@ -0,0 +1,90 @@ +/* + Theengs - Decode things and devices + Copyright: (c) Florian ROBERT + + This file is part of Theengs. + + Theengs is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Theengs is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include "Entitlement.h" +#include "EntitlementEras.h" +#include "ProvenanceStore.h" + +#include +#include +#include +#include +#include + +#if defined(Q_OS_ANDROID) +#include +#include +#endif + +namespace { + +// Best-effort per-platform build integer. Used as a provenance breadcrumb +// only; not load-bearing for the current grant decision (the grant is +// unconditional — this binary is the paid build). +// +// Android: BuildConfig.VERSION_CODE — a primitive `int` static field +// generated by Gradle into the app's package namespace (com.theengs.app). +// The build.gradle in this repo sets versionCode = seconds-since-epoch at +// build time, so this integer is monotonically increasing per build and +// can later be compared against the boundaries in EntitlementEras. +// +// iOS/desktop: recorded as 0. On iOS, the Apple-held CFBundleVersion is +// available later via AppTransaction.originalAppVersion at decision time, +// so pre-recording it here would only be a breadcrumb. +int currentBuildNumber() +{ +#if defined(Q_OS_ANDROID) + const jint code = QJniObject::getStaticField( + "com/theengs/app/BuildConfig", "VERSION_CODE"); + QJniEnvironment env; + if (env.checkAndClearExceptions()) + { + qWarning() << "Entitlement: failed to read BuildConfig.VERSION_CODE; recording 0"; + return 0; + } + return static_cast(code); +#else + return 0; +#endif +} + +} // namespace + +namespace Entitlement +{ + +void stampOnceAtStartup() +{ + // Same QSettings keying as SettingsManager so the file is shared (and + // therefore in the same Android Auto-Backup set). + QSettings settings(QCoreApplication::organizationName(), + QCoreApplication::applicationName()); + + const auto &era = EntitlementEras::currentEraForStampRelease(); + const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); + const int build = currentBuildNumber(); + + ProvenanceStore::stampIfFirstLaunch( + settings, + nowMs, + build, + QString::fromLatin1(era.id), + QStringLiteral("GF_ANDROID_STAMP")); +} + +} // namespace Entitlement diff --git a/src/Entitlement/Entitlement.h b/src/Entitlement/Entitlement.h new file mode 100644 index 00000000..9a609f5a --- /dev/null +++ b/src/Entitlement/Entitlement.h @@ -0,0 +1,36 @@ +/* + Theengs - Decode things and devices + Copyright: (c) Florian ROBERT + + This file is part of Theengs. + + Theengs is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Theengs is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#ifndef ENTITLEMENT_H +#define ENTITLEMENT_H +/* ************************************************************************** */ + +namespace Entitlement +{ + //! Called once during app startup, immediately after SettingsManager is + //! initialized. Idempotent: writes the provenance stamp exactly once, + //! never overwrites an existing one. No-op on subsequent launches. + //! + //! Invariant: this binary is the PAID build. Every install executing + //! this code is, by definition, a legitimate paid_era buyer, so the + //! stamp grants Pro with source=GF_ANDROID_STAMP unconditionally. + void stampOnceAtStartup(); +} + +/* ************************************************************************** */ +#endif // ENTITLEMENT_H diff --git a/src/Entitlement/EntitlementEras.cpp b/src/Entitlement/EntitlementEras.cpp new file mode 100644 index 00000000..8058c971 --- /dev/null +++ b/src/Entitlement/EntitlementEras.cpp @@ -0,0 +1,51 @@ +/* + Theengs - Decode things and devices + Copyright: (c) Florian ROBERT + + This file is part of Theengs. + + Theengs is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Theengs is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include "EntitlementEras.h" + +namespace EntitlementEras +{ + +const Era kEras[] = { + // paid_era: every install up to and including the last paid build / + // pre-boundary. + { "paid_era", + /*iosBuildMax=*/ kIosLastPaidBuild, + /*iosBuildMin=*/ 0, + /*androidBeforeEpochMs=*/ kAndroidFreeFlipEpochMs, + /*androidAfterEpochMs=*/ 0 }, + + // next_era: placeholder for the next cohort so the ledger ships with the + // forward boundary already shaped. Entitlements for this row are decided + // by the release that activates it, not by the current stamp release. + { "next_era", + /*iosBuildMax=*/ 0, + /*iosBuildMin=*/ kIosFirstFreeBuild, + /*androidBeforeEpochMs=*/ 0, + /*androidAfterEpochMs=*/ kAndroidFreeFlipEpochMs }, +}; + +const int kErasCount = static_cast(sizeof(kEras) / sizeof(kEras[0])); + +const Era ¤tEraForStampRelease() +{ + // kEras[0] is paid_era and is asserted by the unit test. + return kEras[0]; +} + +} // namespace EntitlementEras diff --git a/src/Entitlement/EntitlementEras.h b/src/Entitlement/EntitlementEras.h new file mode 100644 index 00000000..b967eb83 --- /dev/null +++ b/src/Entitlement/EntitlementEras.h @@ -0,0 +1,70 @@ +/* + Theengs - Decode things and devices + Copyright: (c) Florian ROBERT + + This file is part of Theengs. + + Theengs is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Theengs is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#ifndef ENTITLEMENT_ERAS_H +#define ENTITLEMENT_ERAS_H +/* ************************************************************************** */ + +#include + +// Boundary constants ledger. Append-only — never edit past rows; only append +// a new row at each future cohort boundary, freezing that boundary's build +// number / epoch in place. This file is the single source of truth that any +// future cohort decision reads. +// +// TODO(florian): Fill the four constants below from the store consoles +// before release. They are intentional placeholders — the engineer who +// authored this code cannot read the store consoles. The PR description +// must list these constants explicitly. +namespace EntitlementEras +{ + +// iOS: last paid CFBundleVersion. Read from App Store Connect. +constexpr int kIosLastPaidBuild = 0; // TODO(florian): fill from store console + +// iOS: first CFBundleVersion of the next cohort. = kIosLastPaidBuild + 1. +constexpr int kIosFirstFreeBuild = 0; // TODO(florian): fill from store console + +// Android: UTC epoch (ms) at the relevant cohort boundary. Set when the +// boundary becomes effective, not before. +constexpr qint64 kAndroidFreeFlipEpochMs = 0; // TODO(florian): fill at boundary + +// Android: last paid Play versionCode. Informational; not used by the stamp +// logic, recorded here so future cohort math has it on hand. +constexpr int kAndroidLastPaidVersionCode = 0; // TODO(florian): fill from store console + +struct Era +{ + const char *id; //!< canonical era slug, written into provenance + int iosBuildMax; //!< <= boundary, 0 = N/A + int iosBuildMin; //!< >= boundary, 0 = N/A + qint64 androidBeforeEpochMs; //!< install < boundary, 0 = N/A + qint64 androidAfterEpochMs; //!< install >= boundary, 0 = N/A +}; + +extern const Era kEras[]; +extern const int kErasCount; + +//! Returns the era that the current stamp release must assign. Every install +//! running this binary is, by definition, in paid_era. +const Era ¤tEraForStampRelease(); + +} // namespace EntitlementEras + +/* ************************************************************************** */ +#endif // ENTITLEMENT_ERAS_H diff --git a/src/Entitlement/EntitlementManager.cpp b/src/Entitlement/EntitlementManager.cpp new file mode 100644 index 00000000..19894b94 --- /dev/null +++ b/src/Entitlement/EntitlementManager.cpp @@ -0,0 +1,51 @@ +/* + Theengs - Decode things and devices + Copyright: (c) Florian ROBERT + + This file is part of Theengs. + + Theengs is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Theengs is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include "EntitlementManager.h" + +#include "Provenance.h" +#include "ProvenanceStore.h" + +#include +#include + +EntitlementManager::EntitlementManager(QObject *parent) : QObject(parent) +{ + refresh(); +} + +void EntitlementManager::refresh() +{ + // Same QSettings keying as Entitlement::stampOnceAtStartup() so we read the + // exact record the startup stamp wrote (org/app both "Theengs"). + QSettings settings(QCoreApplication::organizationName(), + QCoreApplication::applicationName()); + const Provenance p = ProvenanceStore::load(settings); + + const bool pro = p.pro.granted; + const bool stamped = p.isStamped(); + if (pro == m_isPro && stamped == m_stamped + && p.pro.source == m_proSource && p.acquisitionEra == m_acquisitionEra) + return; + + m_isPro = pro; + m_stamped = stamped; + m_proSource = p.pro.source; + m_acquisitionEra = p.acquisitionEra; + emit changed(); +} diff --git a/src/Entitlement/EntitlementManager.h b/src/Entitlement/EntitlementManager.h new file mode 100644 index 00000000..a58e1c0a --- /dev/null +++ b/src/Entitlement/EntitlementManager.h @@ -0,0 +1,67 @@ +/* + Theengs - Decode things and devices + Copyright: (c) Florian ROBERT + + This file is part of Theengs. + + Theengs is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Theengs is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#ifndef ENTITLEMENT_MANAGER_H +#define ENTITLEMENT_MANAGER_H +/* ************************************************************************** */ + +#include +#include + +//! Read-only, QML-facing view of the persisted entitlement provenance (the +//! Release-A buyer stamp; see Entitlement/Provenance.h). This is the single +//! gate the UI binds to — ``entitlement.isPro`` — per the grandfathering spec +//! P3 ("no feature queries the store directly"). +//! +//! On this PAID build the grant is unconditional, so ``isPro`` is true for +//! every stamped install; the freemium build will back the same property with +//! the full resolution engine without changing this QML contract. Read-only: +//! the stamp is written by Entitlement::stampOnceAtStartup(), never here. +class EntitlementManager : public QObject +{ + Q_OBJECT + + Q_PROPERTY(bool isPro READ isPro NOTIFY changed) + Q_PROPERTY(bool stamped READ isStamped NOTIFY changed) + Q_PROPERTY(QString proSource READ proSource NOTIFY changed) + Q_PROPERTY(QString acquisitionEra READ acquisitionEra NOTIFY changed) + +public: + explicit EntitlementManager(QObject *parent = nullptr); + + bool isPro() const { return m_isPro; } + bool isStamped() const { return m_stamped; } + QString proSource() const { return m_proSource; } + QString acquisitionEra() const { return m_acquisitionEra; } + + //! Re-read the persisted provenance. Call after the startup stamp so the + //! freshly-written record is reflected; cheap and idempotent. + Q_INVOKABLE void refresh(); + +signals: + void changed(); + +private: + bool m_isPro = false; + bool m_stamped = false; + QString m_proSource; + QString m_acquisitionEra; +}; + +/* ************************************************************************** */ +#endif // ENTITLEMENT_MANAGER_H diff --git a/src/Entitlement/Provenance.cpp b/src/Entitlement/Provenance.cpp new file mode 100644 index 00000000..006d7968 --- /dev/null +++ b/src/Entitlement/Provenance.cpp @@ -0,0 +1,119 @@ +/* + Theengs - Decode things and devices + Copyright: (c) Florian ROBERT + + This file is part of Theengs. + + Theengs is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Theengs is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include "Provenance.h" + +#include +#include +#include + +namespace { +// Top-level keys this binary understands. Any other top-level key encountered +// during fromJson() is stashed into Provenance::extras to be re-emitted on the +// next save — that keeps older binaries non-destructive against newer schemas. +const QStringList kKnownTopLevel = { + QStringLiteral("schema"), + QStringLiteral("firstSeenEpoch"), + QStringLiteral("firstSeenBuild"), + QStringLiteral("acquisitionEra"), + QStringLiteral("entitlements"), + QStringLiteral("transitionLog"), +}; +} + +QJsonObject Provenance::toJson() const +{ + QJsonObject obj = extras; // start with forward-compat extras, then overwrite known keys + obj.insert("schema", schema); + obj.insert("firstSeenEpoch", static_cast(firstSeenEpoch)); + obj.insert("firstSeenBuild", firstSeenBuild); + obj.insert("acquisitionEra", acquisitionEra); + + QJsonObject entitlements; + QJsonObject proObj; + proObj.insert("granted", pro.granted); + proObj.insert("source", pro.source); + proObj.insert("grantedAt", static_cast(pro.grantedAt)); + entitlements.insert("pro", proObj); + obj.insert("entitlements", entitlements); + + QJsonArray log; + for (const auto &t : transitionLog) + { + QJsonObject e; + e.insert("era", t.era); + e.insert("event", t.event); + e.insert("at", static_cast(t.at)); + log.append(e); + } + obj.insert("transitionLog", log); + return obj; +} + +QString Provenance::toJsonString() const +{ + return QString::fromUtf8(QJsonDocument(toJson()).toJson(QJsonDocument::Compact)); +} + +Provenance Provenance::fromJson(const QJsonObject &obj, bool *ok) +{ + Provenance p; + p.schema = obj.value("schema").toInt(2); + p.firstSeenEpoch = static_cast(obj.value("firstSeenEpoch").toDouble(0)); + p.firstSeenBuild = obj.value("firstSeenBuild").toInt(0); + p.acquisitionEra = obj.value("acquisitionEra").toString(); + + const QJsonObject entitlements = obj.value("entitlements").toObject(); + const QJsonObject proObj = entitlements.value("pro").toObject(); + p.pro.granted = proObj.value("granted").toBool(false); + p.pro.source = proObj.value("source").toString(); + p.pro.grantedAt = static_cast(proObj.value("grantedAt").toDouble(0)); + + const QJsonArray log = obj.value("transitionLog").toArray(); + for (const auto &v : log) + { + const QJsonObject e = v.toObject(); + TransitionEntry t; + t.era = e.value("era").toString(); + t.event = e.value("event").toString(); + t.at = static_cast(e.value("at").toDouble(0)); + p.transitionLog.append(t); + } + + // Stash any unknown top-level fields into extras so a re-save preserves them. + for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) + { + if (!kKnownTopLevel.contains(it.key())) + p.extras.insert(it.key(), it.value()); + } + + if (ok) *ok = true; + return p; +} + +Provenance Provenance::fromJsonString(const QString &s, bool *ok) +{ + QJsonParseError err; + const auto doc = QJsonDocument::fromJson(s.toUtf8(), &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) + { + if (ok) *ok = false; + return Provenance{}; + } + return fromJson(doc.object(), ok); +} diff --git a/src/Entitlement/Provenance.h b/src/Entitlement/Provenance.h new file mode 100644 index 00000000..0da20ac3 --- /dev/null +++ b/src/Entitlement/Provenance.h @@ -0,0 +1,72 @@ +/* + Theengs - Decode things and devices + Copyright: (c) Florian ROBERT + + This file is part of Theengs. + + Theengs is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Theengs is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#ifndef PROVENANCE_H +#define PROVENANCE_H +/* ************************************************************************** */ + +#include +#include +#include + +// Provenance record. Written exactly once at first launch, immutable +// thereafter. JSON-serialized into QSettings under the key +// "provenance/json_v2" so the entire object is one atomic value and +// forward-compatible via the explicit `schema` field. Unknown JSON fields +// are round-tripped through `extras` so a future schema bump does not +// silently truncate an older client's write. +struct Provenance +{ + int schema = 2; + qint64 firstSeenEpoch = 0; //!< ms since epoch, immutable once non-zero + int firstSeenBuild = 0; //!< platform-specific build int at first launch + QString acquisitionEra; //!< e.g. "paid_era" + + struct ProGrant + { + bool granted = false; + QString source; //!< e.g. "GF_ANDROID_STAMP" + qint64 grantedAt = 0; + }; + ProGrant pro; + + struct TransitionEntry + { + QString era; + QString event; + qint64 at = 0; + }; + QList transitionLog; + + //! Forward-compat holding bay for fields a future schema may add. Any + //! top-level keys we don't recognize get stashed here and re-emitted on + //! the next save, so an older binary doesn't silently truncate a newer + //! object that was written by a future client. + QJsonObject extras; + + bool isStamped() const { return firstSeenEpoch != 0; } + + QJsonObject toJson() const; + QString toJsonString() const; + + static Provenance fromJson(const QJsonObject &obj, bool *ok = nullptr); + static Provenance fromJsonString(const QString &s, bool *ok = nullptr); +}; + +/* ************************************************************************** */ +#endif // PROVENANCE_H diff --git a/src/Entitlement/ProvenanceStore.cpp b/src/Entitlement/ProvenanceStore.cpp new file mode 100644 index 00000000..a4de70b6 --- /dev/null +++ b/src/Entitlement/ProvenanceStore.cpp @@ -0,0 +1,71 @@ +/* + Theengs - Decode things and devices + Copyright: (c) Florian ROBERT + + This file is part of Theengs. + + Theengs is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Theengs is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include "ProvenanceStore.h" + +#include + +namespace ProvenanceStore +{ + +const char *const kSettingsKey = "provenance/json_v2"; + +Provenance load(QSettings &settings) +{ + if (!settings.contains(kSettingsKey)) return Provenance{}; + const QString s = settings.value(kSettingsKey).toString(); + if (s.isEmpty()) return Provenance{}; + bool ok = false; + Provenance p = Provenance::fromJsonString(s, &ok); + if (!ok) return Provenance{}; + return p; +} + +void save(QSettings &settings, const Provenance &p) +{ + settings.setValue(kSettingsKey, p.toJsonString()); +} + +Provenance stampIfFirstLaunch(QSettings &settings, + qint64 nowMs, + int currentBuild, + const QString &eraId, + const QString &source) +{ + Provenance existing = load(settings); + if (existing.isStamped()) return existing; + + Provenance p; + p.firstSeenEpoch = nowMs; + p.firstSeenBuild = currentBuild; + p.acquisitionEra = eraId; + p.pro.granted = true; + p.pro.source = source; + p.pro.grantedAt = nowMs; + Provenance::TransitionEntry entry; + entry.era = eraId; + entry.event = QStringLiteral("stamped"); + entry.at = nowMs; + p.transitionLog.append(entry); + + save(settings, p); + settings.sync(); + return p; +} + +} // namespace ProvenanceStore diff --git a/src/Entitlement/ProvenanceStore.h b/src/Entitlement/ProvenanceStore.h new file mode 100644 index 00000000..9f55c0b4 --- /dev/null +++ b/src/Entitlement/ProvenanceStore.h @@ -0,0 +1,56 @@ +/* + Theengs - Decode things and devices + Copyright: (c) Florian ROBERT + + This file is part of Theengs. + + Theengs is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Theengs is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#ifndef PROVENANCE_STORE_H +#define PROVENANCE_STORE_H +/* ************************************************************************** */ + +#include "Provenance.h" + +class QSettings; + +// Pure functions over an externally-owned QSettings instance. No global state, +// no clock access — unit tests drive these with a temp QSettings file and an +// injected `nowMs`. The Release A startup glue (Entitlement::stampOnceAtStartup) +// wires the real clock + the real QSettings into stampIfFirstLaunch(). +namespace ProvenanceStore +{ + //! QSettings key under which the JSON-serialized Provenance lives. + extern const char *const kSettingsKey; // "provenance/json_v2" + + //! Reads and parses the provenance object. Returns an unstamped Provenance + //! when the key is absent or the JSON is malformed (never throws). + Provenance load(QSettings &settings); + + //! Overwrites the persisted JSON unconditionally. Callers must NOT use this + //! to mutate an already-stamped object; use stampIfFirstLaunch instead. + void save(QSettings &settings, const Provenance &p); + + //! Idempotent stamp. If `load(settings)` returns an unstamped Provenance, + //! a fully populated stamped Provenance is written and returned. If it + //! returns an already-stamped Provenance, the existing record is returned + //! unchanged. Used by Entitlement::stampOnceAtStartup at app launch. + Provenance stampIfFirstLaunch(QSettings &settings, + qint64 nowMs, + int currentBuild, + const QString &eraId, + const QString &source); +} + +/* ************************************************************************** */ +#endif // PROVENANCE_STORE_H diff --git a/src/main.cpp b/src/main.cpp index c6b91ea9..3c06bc09 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -24,6 +24,8 @@ #include "MenubarManager.h" #include "MqttManager.h" +#include "Entitlement/Entitlement.h" +#include "Entitlement/EntitlementManager.h" #include "BatteryPresetManager.h" #include "BatteryPreset.h" #include "TempPresetManager.h" @@ -162,6 +164,12 @@ int main(int argc, char *argv[]) // Init components PermissionManager *pm = PermissionManager::getInstance(); SettingsManager *sm = SettingsManager::getInstance(); + // Durable buyer/entitlement stamp. Idempotent — writes once on first + // launch and never overwrites afterward. + Entitlement::stampOnceAtStartup(); + // Read-only QML-facing view of that stamp (the single `entitlement.isPro` + // gate). Constructed after the stamp so it reflects the fresh record. + EntitlementManager *em = new EntitlementManager(); MqttManager *mq = MqttManager::getInstance(); BatteryPresetManager *bpm = BatteryPresetManager::getInstance(); TempPresetManager *tpm = TempPresetManager::getInstance(); @@ -246,6 +254,7 @@ int main(int argc, char *argv[]) engine_context->setContextProperty("notificationManager", nm); engine_context->setContextProperty("batteryPresetsManager", bpm); engine_context->setContextProperty("tempPresetsManager", tpm); + engine_context->setContextProperty("entitlement", em); engine_context->setContextProperty("utilsApp", utilsApp); engine_context->setContextProperty("utilsWifi", utilsWifi); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 00000000..724c9716 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,27 @@ +# Developer-only unit tests for the Entitlement module. Not part of the shipped +# app target. Build with: cmake -B build-tests -DENABLE_TESTS=ON +# Run with: ctest --test-dir build-tests -V + +find_package(Qt6 REQUIRED COMPONENTS Core Test) + +set(_entitlement_sources + ${CMAKE_SOURCE_DIR}/src/Entitlement/EntitlementEras.cpp + ${CMAKE_SOURCE_DIR}/src/Entitlement/Provenance.cpp + ${CMAKE_SOURCE_DIR}/src/Entitlement/ProvenanceStore.cpp +) + +function(theengs_add_unit_test name) + qt_add_executable(${name} ${name}.cpp ${_entitlement_sources}) + target_include_directories(${name} PRIVATE ${CMAKE_SOURCE_DIR}/src) + target_link_libraries(${name} PRIVATE Qt6::Core Qt6::Test) + set_target_properties(${name} PROPERTIES + AUTOMOC ON + WIN32_EXECUTABLE OFF + MACOSX_BUNDLE OFF + ) + add_test(NAME ${name} COMMAND ${name}) +endfunction() + +theengs_add_unit_test(test_entitlement_eras) +theengs_add_unit_test(test_provenance) +theengs_add_unit_test(test_provenance_store) diff --git a/tests/test_entitlement_eras.cpp b/tests/test_entitlement_eras.cpp new file mode 100644 index 00000000..3e644a19 --- /dev/null +++ b/tests/test_entitlement_eras.cpp @@ -0,0 +1,52 @@ +/* + Theengs - Decode things and devices + Copyright: (c) Florian ROBERT + + This file is part of Theengs. + + Theengs is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Theengs is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include + +#include "Entitlement/EntitlementEras.h" + +class TestEntitlementEras : public QObject +{ + Q_OBJECT +private slots: + void paidEraRowExists(); + void paidEraIsCurrentForStampRelease(); +}; + +void TestEntitlementEras::paidEraRowExists() +{ + bool found = false; + for (int i = 0; i < EntitlementEras::kErasCount; ++i) + { + if (QString::fromLatin1(EntitlementEras::kEras[i].id) == "paid_era") + { + found = true; + break; + } + } + QVERIFY(found); +} + +void TestEntitlementEras::paidEraIsCurrentForStampRelease() +{ + const auto &era = EntitlementEras::currentEraForStampRelease(); + QCOMPARE(QString::fromLatin1(era.id), QStringLiteral("paid_era")); +} + +QTEST_APPLESS_MAIN(TestEntitlementEras) +#include "test_entitlement_eras.moc" diff --git a/tests/test_provenance.cpp b/tests/test_provenance.cpp new file mode 100644 index 00000000..82ce1caa --- /dev/null +++ b/tests/test_provenance.cpp @@ -0,0 +1,107 @@ +/* + Theengs - Decode things and devices + Copyright: (c) Florian ROBERT + + This file is part of Theengs. + + Theengs is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Theengs is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include +#include + +#include "Entitlement/Provenance.h" + +class TestProvenance : public QObject +{ + Q_OBJECT +private slots: + void emptyProvenanceIsNotStamped(); + void stampedProvenanceRoundTripsJson(); + void schemaFieldIsTwo(); + void unknownExtraFieldsArePreservedOnRoundTrip(); + void malformedJsonStringFailsCleanly(); +}; + +void TestProvenance::emptyProvenanceIsNotStamped() +{ + Provenance p; + QVERIFY(!p.isStamped()); + QCOMPARE(p.schema, 2); +} + +void TestProvenance::stampedProvenanceRoundTripsJson() +{ + Provenance p; + p.firstSeenEpoch = 1719500000000LL; + p.firstSeenBuild = 142; + p.acquisitionEra = "paid_era"; + p.pro.granted = true; + p.pro.source = "GF_ANDROID_STAMP"; + p.pro.grantedAt = 1719500000000LL; + Provenance::TransitionEntry entry; + entry.era = "paid_era"; + entry.event = "stamped"; + entry.at = 1719500000000LL; + p.transitionLog.append(entry); + + const QString s = p.toJsonString(); + bool ok = false; + Provenance round = Provenance::fromJsonString(s, &ok); + QVERIFY(ok); + QCOMPARE(round.schema, 2); + QCOMPARE(round.firstSeenEpoch, 1719500000000LL); + QCOMPARE(round.firstSeenBuild, 142); + QCOMPARE(round.acquisitionEra, QStringLiteral("paid_era")); + QVERIFY(round.pro.granted); + QCOMPARE(round.pro.source, QStringLiteral("GF_ANDROID_STAMP")); + QCOMPARE(round.pro.grantedAt, 1719500000000LL); + QCOMPARE(round.transitionLog.size(), 1); + QCOMPARE(round.transitionLog.first().era, QStringLiteral("paid_era")); + QCOMPARE(round.transitionLog.first().event, QStringLiteral("stamped")); + QCOMPARE(round.transitionLog.first().at, 1719500000000LL); +} + +void TestProvenance::schemaFieldIsTwo() +{ + Provenance p; + const QJsonObject obj = p.toJson(); + QCOMPARE(obj.value("schema").toInt(), 2); +} + +void TestProvenance::unknownExtraFieldsArePreservedOnRoundTrip() +{ + // Forward-compat: a future schema may add fields. Loading and re-saving + // must not silently drop them. + QJsonObject extra; + extra.insert("schema", 2); + extra.insert("firstSeenEpoch", 1.0); + extra.insert("futureField", QStringLiteral("payload")); + + bool ok = false; + Provenance p = Provenance::fromJson(extra, &ok); + QVERIFY(ok); + const QJsonObject out = p.toJson(); + QVERIFY(out.contains("futureField")); + QCOMPARE(out.value("futureField").toString(), QStringLiteral("payload")); +} + +void TestProvenance::malformedJsonStringFailsCleanly() +{ + bool ok = true; + Provenance p = Provenance::fromJsonString(QStringLiteral("not-json"), &ok); + QVERIFY(!ok); + QVERIFY(!p.isStamped()); +} + +QTEST_APPLESS_MAIN(TestProvenance) +#include "test_provenance.moc" diff --git a/tests/test_provenance_store.cpp b/tests/test_provenance_store.cpp new file mode 100644 index 00000000..6f82b74d --- /dev/null +++ b/tests/test_provenance_store.cpp @@ -0,0 +1,133 @@ +/* + Theengs - Decode things and devices + Copyright: (c) Florian ROBERT + + This file is part of Theengs. + + Theengs is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Theengs is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include +#include +#include +#include +#include + +#include "Entitlement/ProvenanceStore.h" +#include "Entitlement/Provenance.h" + +class TestProvenanceStore : public QObject +{ + Q_OBJECT +private slots: + void initTestCase(); + void init(); + void firstLaunchWritesStamp(); + void secondLaunchIsIdempotent(); + void simulatedUpgradePreservesFirstSeen(); + void loadOfMissingKeyReturnsEmpty(); + void loadOfMalformedJsonReturnsEmpty(); + +private: + QTemporaryDir m_dir; + QString settingsPath() const { return m_dir.path() + "/test.conf"; } +}; + +void TestProvenanceStore::initTestCase() +{ + QStandardPaths::setTestModeEnabled(true); + QVERIFY(m_dir.isValid()); +} + +void TestProvenanceStore::init() +{ + // Start each test with a clean settings file. + QFile::remove(settingsPath()); +} + +void TestProvenanceStore::firstLaunchWritesStamp() +{ + QSettings s(settingsPath(), QSettings::IniFormat); + const qint64 t = 1719500000000LL; + Provenance p = ProvenanceStore::stampIfFirstLaunch(s, t, 142, "paid_era", "GF_ANDROID_STAMP"); + + QVERIFY(p.isStamped()); + QCOMPARE(p.firstSeenEpoch, t); + QCOMPARE(p.firstSeenBuild, 142); + QCOMPARE(p.acquisitionEra, QStringLiteral("paid_era")); + QVERIFY(p.pro.granted); + QCOMPARE(p.pro.source, QStringLiteral("GF_ANDROID_STAMP")); + QCOMPARE(p.pro.grantedAt, t); + QCOMPARE(p.transitionLog.size(), 1); + QCOMPARE(p.transitionLog.first().event, QStringLiteral("stamped")); + + s.sync(); + Provenance reload = ProvenanceStore::load(s); + QCOMPARE(reload.firstSeenEpoch, t); + QCOMPARE(reload.firstSeenBuild, 142); + QCOMPARE(reload.acquisitionEra, QStringLiteral("paid_era")); +} + +void TestProvenanceStore::secondLaunchIsIdempotent() +{ + QSettings s(settingsPath(), QSettings::IniFormat); + const qint64 t1 = 1719500000000LL; + ProvenanceStore::stampIfFirstLaunch(s, t1, 142, "paid_era", "GF_ANDROID_STAMP"); + s.sync(); + + const qint64 t2 = 1719600000000LL; + Provenance p2 = ProvenanceStore::stampIfFirstLaunch(s, t2, 999, "different_era", "GF_ANDROID_STAMP"); + + // All originally-stamped fields must be preserved exactly. + QCOMPARE(p2.firstSeenEpoch, t1); + QCOMPARE(p2.firstSeenBuild, 142); + QCOMPARE(p2.acquisitionEra, QStringLiteral("paid_era")); + QCOMPARE(p2.pro.source, QStringLiteral("GF_ANDROID_STAMP")); + QCOMPARE(p2.pro.grantedAt, t1); + QCOMPARE(p2.transitionLog.size(), 1); // no duplicate log entry +} + +void TestProvenanceStore::simulatedUpgradePreservesFirstSeen() +{ + // Pre-populate the same way an old install would. + { + QSettings s(settingsPath(), QSettings::IniFormat); + ProvenanceStore::stampIfFirstLaunch(s, 1719500000000LL, 100, "paid_era", "GF_ANDROID_STAMP"); + s.sync(); + } + // Now "upgrade" — re-open and re-call stamp with a higher build number. + QSettings s2(settingsPath(), QSettings::IniFormat); + Provenance p = ProvenanceStore::stampIfFirstLaunch(s2, 1720000000000LL, 200, "paid_era", "GF_ANDROID_STAMP"); + + QCOMPARE(p.firstSeenBuild, 100); + QCOMPARE(p.firstSeenEpoch, 1719500000000LL); + QCOMPARE(p.pro.grantedAt, 1719500000000LL); +} + +void TestProvenanceStore::loadOfMissingKeyReturnsEmpty() +{ + QSettings s(settingsPath(), QSettings::IniFormat); + Provenance p = ProvenanceStore::load(s); + QVERIFY(!p.isStamped()); +} + +void TestProvenanceStore::loadOfMalformedJsonReturnsEmpty() +{ + QSettings s(settingsPath(), QSettings::IniFormat); + s.setValue(ProvenanceStore::kSettingsKey, QStringLiteral("{not-json")); + s.sync(); + Provenance p = ProvenanceStore::load(s); + QVERIFY(!p.isStamped()); +} + +QTEST_APPLESS_MAIN(TestProvenanceStore) +#include "test_provenance_store.moc"