Skip to content

Commit

Permalink
VPN-3362: Add plan upgrade message for monthly users (#8570)
Browse files Browse the repository at this point in the history
* annual upgrade plan message

* update documentation

* separate into 2 messages

* re-evaluate message conditions on a timer

* mistype

* linter

* Unify 14 and 87 day upgrade messages into one

* code review updates

* linter

* use single qtimer object for callbacks

* disconnect timeout signal before creating new one

* rename message

* initial test

* functional test

* fix inspector command

* unit test

* linter

* use production addon for testing

* fix timer unit test

* linter

* move comment
  • Loading branch information
MattLichtenstein authored Nov 21, 2023
1 parent d24c9e1 commit 6facaf0
Show file tree
Hide file tree
Showing 23 changed files with 388 additions and 10 deletions.
48 changes: 48 additions & 0 deletions addons/message_upgrade_to_annual_plan/conditions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
(function(api, condition) {
//Show this message after 14 days, and resurface after 87 days

//This will run when TaskaGetSubscriptionDetails returns something different than what is loaded from setting
//(often from signing in/out but can be any change in the subscription)
api.connectSignal(api.subscriptionData, 'changed', () => computeCondition());

//This will run on app launch for signed in users when there subscription data is loaded into memory from settings
api.connectSignal(api.subscriptionData, 'initialized', () => computeCondition());

//This is for already running clients that receive this addon while the client is launched and signed in
//(Because these users may not see a change in subscriptionData for a long time)
computeCondition()

function isMonthlyWebPlan() {
return api.subscriptionData.type === api.subscriptionData.SubscriptionWeb &&
api.subscriptionData.planBillingInterval === api.subscriptionData.BillingIntervalMonthly
}

function computeCondition() {
//subscriptionData not initalized - return
if (api.subscriptionData.createdAt <= 0) return

let now = Date.now()
let fourteenDaysAfterSubscriptionStarted = api.subscriptionData.createdAt + 1000 * 60 * 60 * 24 * 14
let eightySevenDaysAfterSubscriptionStarted = api.subscriptionData.createdAt + 1000 * 60 * 60 * 24 * 87

if (isMonthlyWebPlan) {
//Less than 14 days into the subscription, don't show message
if (now < fourteenDaysAfterSubscriptionStarted) {
api.setTimedCallback(fourteenDaysAfterSubscriptionStarted - now, () => computeCondition());
condition.disable()
}
//Between 14 and 87 days into the subscription, show message
else if (now >= fourteenDaysAfterSubscriptionStarted && now < eightySevenDaysAfterSubscriptionStarted) {
api.setTimedCallback(eightySevenDaysAfterSubscriptionStarted - now, () => computeCondition())
api.addon.date = fourteenDaysAfterSubscriptionStarted / 1000
condition.enable()
}
// After 87 days into the subscription, re-surface the message (undimiss, unread, updating timestamp)
else if (now >= eightySevenDaysAfterSubscriptionStarted) {
api.addon.date = eightySevenDaysAfterSubscriptionStarted / 1000
api.addon.resetMessage()
condition.enable()
}
}
}
});
25 changes: 25 additions & 0 deletions addons/message_upgrade_to_annual_plan/getHelp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
(function(api) {
function versionCompare(a, b) {
for (let i = 0; i < 3; ++i) {
if (a[i] != b[i]) {
return a[i] > b[i] ? -1 : 1;
}
}
return 0;
}

const parts = api.env.versionString.split('.');

const version = parts.map(a => parseInt(a, 10));

//Post 2.16 API
if (versionCompare([2, 16, 0], version) >= 0) {
api.navigator.requestScreen(api.vpn.ScreenGetHelp);
return;
}
//Pre 2.16 API
else {
api.navigator.requestScreen(api.navigator.ScreenGetHelp);
return;
}
})
56 changes: 56 additions & 0 deletions addons/message_upgrade_to_annual_plan/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"api_version": "0.1",
"id": "message_upgrade_to_annual_plan",
"name": "Upgrade to annual plan",
"type": "message",
"conditions": {
"javascript": "conditions.js"
},
"message": {
"id": "message_upgrade_to_annual_plan",
"title": "Save 50% when you switch to an annual plan",
"subtitle": "Get the same protection, for half the price.",
"badge": "subscription",
"notify": false,
"blocks": [
{
"id": "c_1",
"type": "text",
"content": "Switch to an annual plan, and get all the same benefits at 50% less than the monthly plan:"
},
{ "id": "c_2",
"type": "ulist",
"content": [
{ "id": "l_1",
"content": "Protection for up to 5 devices"
},
{ "id": "l_2",
"content": "500+ servers in 30+ countries"
},
{ "id": "l_3",
"content": "Device-level encryption"
},
{ "id": "l_4",
"content": "No bandwidth restrictions"
},
{ "id": "l_5",
"content": "No logging of your network activity"
}
]
},
{ "id": "c_3",
"type": "button",
"style": "primary",
"content": "Switch to an annual plan",
"javascript": "openLink.js"
},
{
"id": "c_4",
"type": "button",
"style": "link",
"content": "Get help",
"javascript": "getHelp.js"
}
]
}
}
4 changes: 4 additions & 0 deletions addons/message_upgrade_to_annual_plan/openLink.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
((api) => {
return api.urlOpener.openUrl(
`https://www.mozilla.org/products/vpn/?utm_medium=mozillavpn&utm_source=messages#pricing`);
});
3 changes: 2 additions & 1 deletion docs/messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ property object with the following properties:
| title_comment | An optional comment to describe the meaning of the title | String | No |
| subtitle_comment | An optional comment to describe the meaning of the subtitle | String | No |
| date | The date the message was received (using seconds since epoch time) | Number | No |
| badge | A label used to tag a message (options: `warning`, `critical`, `new_update`, `whats_new`, `survey` ) | String | No |
| badge | A label used to tag a message (options: `warning`, `critical`, `new_update`, `whats_new`, `survey`, `subscription` ) | String | No |
| notify | Should we notify the user about this message via system notifications (Default: true) | Boolean | No |
| blocks | An array of graphical blocks that compose the user interface of the message's contents (see more info [here](https://github.com/mozilla-mobile/mozilla-vpn-client/wiki/guides#block-object)) | Array of Block objects | Yes |
4 changes: 4 additions & 0 deletions scripts/ci/jsonSchemas/message.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
"type": "string",
"description": "The type of this message"
},
"notify": {
"type": "boolean",
"description": "Determines whether we trigger a system notification. Default: true"
},
"blocks": {
"type": "array",
"description": "The list of text blocks",
Expand Down
22 changes: 21 additions & 1 deletion src/addons/addonapi.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ void AddonApi::initialize() {
QQmlEngine::setObjectOwnership(m_addon, QQmlEngine::CppOwnership);

QJSValue value = engine->newQObject(m_addon);
value.setPrototype(engine->newQMetaObject(&Addon::staticMetaObject));
value.setPrototype(engine->newQMetaObject(m_addon->metaObject()));

insert("addon", QVariant::fromValue(value));
}
Expand Down Expand Up @@ -90,6 +90,26 @@ void AddonApi::initialize() {
if (s_constructorCallback) {
s_constructorCallback(this);
}

m_timer.setSingleShot(true);
}

void AddonApi::setTimedCallback(int interval, const QJSValue& callback) {
if (!callback.isCallable()) {
logger.debug() << "No callback received";
return;
}

// disconnect a potential previous timer
if (m_timer.isActive()) {
logger.warning() << "Disconnecting timer for addon: " << m_addon->id();

QObject::disconnect(&m_timer, nullptr, nullptr, nullptr);
}

connect(&m_timer, &QTimer::timeout, this, [callback]() { callback.call(); });

m_timer.start(interval);
}

void AddonApi::log(const QString& message) { logger.debug() << message; }
Expand Down
4 changes: 4 additions & 0 deletions src/addons/addonapi.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

#include <QJSValue>
#include <QQmlPropertyMap>
#include <QTimer>

class Addon;

Expand All @@ -27,6 +28,8 @@ class AddonApi final : public QQmlPropertyMap {
const QJSValue& callback);
Q_INVOKABLE void log(const QString& message);

Q_INVOKABLE void setTimedCallback(int interval, const QJSValue& callback);

/**
* @brief callback executed when a new AddonApi is created. Use it to add
* your custom APIs.
Expand All @@ -39,6 +42,7 @@ class AddonApi final : public QQmlPropertyMap {

private:
Addon* m_addon = nullptr;
QTimer m_timer;
};

class AddonApiCallbackWrapper final : public QObject {
Expand Down
18 changes: 18 additions & 0 deletions src/addons/addonmessage.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ Addon* AddonMessage::create(QObject* parent, const QString& manifestFileName,
message->m_date = messageObj["date"].toInteger();
message->planDateRetranslation();

// if "notify" is not specified in the manifest, default to true
message->m_shouldNotify = messageObj["notify"].toBool(true);

message->setBadge(messageObj["badge"].toString());

guard.dismiss();
Expand Down Expand Up @@ -104,6 +107,12 @@ void AddonMessage::updateMessageStatus(MessageStatus newStatus) {

QMetaEnum statusMetaEnum = QMetaEnum::fromType<MessageStatus>();
QString newStatusSetting = statusMetaEnum.valueToKey(newStatus);

// We are going from dismissed, to some other status, so re-enable the message
if (m_status == MessageStatus::Dismissed) {
enable();
}

m_status = newStatus;
emit statusChanged(m_status);

Expand All @@ -126,6 +135,11 @@ void AddonMessage::dismiss() {

void AddonMessage::markAsRead() { updateMessageStatus(MessageStatus::Read); }

// Marks messaged as un-read and un-dimissed
void AddonMessage::resetMessage() {
updateMessageStatus(MessageStatus::Received);
}

bool AddonMessage::containsSearchString(const QString& query) const {
if (query.isEmpty()) {
return true;
Expand Down Expand Up @@ -220,6 +234,8 @@ void AddonMessage::setBadge(const QString& badge) {
m_badge = WhatsNew;
} else if (badge == "survey") {
m_badge = Survey;
} else if (badge == "subscription") {
m_badge = Subscription;
} else {
logger.error() << "Unsupported badge type" << badge;
}
Expand All @@ -233,4 +249,6 @@ void AddonMessage::setBadge(Badge badge) {
void AddonMessage::setDate(qint64 date) {
m_date = date;
emit dateChanged();
// Notifies formattedDate that the date has been changed
emit retranslationCompleted();
}
14 changes: 13 additions & 1 deletion src/addons/addonmessage.h
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,15 @@ class AddonMessage final : public Addon {
QString formattedDate READ formattedDate NOTIFY retranslationCompleted)
Q_PROPERTY(Badge badge MEMBER m_badge WRITE setBadge NOTIFY badgeChanged)

enum Badge { None, Warning, Critical, NewUpdate, WhatsNew, Survey };
enum Badge {
None,
Warning,
Critical,
NewUpdate,
WhatsNew,
Survey,
Subscription
};
Q_ENUM(Badge)

static Addon* create(QObject* parent, const QString& manifestFileName,
Expand All @@ -61,6 +69,7 @@ class AddonMessage final : public Addon {

Q_INVOKABLE void dismiss();
Q_INVOKABLE void markAsRead();
Q_INVOKABLE void resetMessage();
Q_INVOKABLE bool containsSearchString(const QString& query) const;

void setBadge(Badge badge);
Expand All @@ -70,6 +79,8 @@ class AddonMessage final : public Addon {

bool isReceived() const { return m_status == MessageStatus::Received; }

bool shouldNotify() const { return m_shouldNotify; }

QString formattedDate() const;

bool enabled() const override;
Expand Down Expand Up @@ -109,6 +120,7 @@ class AddonMessage final : public Addon {
Composer* m_composer = nullptr;

qint64 m_date = 0;
bool m_shouldNotify = true;

MessageStatus m_status = MessageStatus::Received;

Expand Down
4 changes: 2 additions & 2 deletions src/constants.h
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,8 @@ constexpr uint32_t BENCHMARK_THRESHOLD_SPEED_MEDIUM = 10000000; // 10 Megabit
CONSTEXPR(uint32_t, releaseMonitorMsec, 21600000, 4000, 0)

// in milliseconds, how often we should fetch the server list, the account and
// so on.
CONSTEXPR(uint32_t, schedulePeriodicTaskTimerMsec, 3600000, 30000, 0)
// so on. 60 minuts on prod, 5 minutes on stage
CONSTEXPR(uint32_t, schedulePeriodicTaskTimerMsec, 3600000, 300000, 0)

// how often we check the captive portal when the VPN is on.
CONSTEXPR(uint32_t, captivePortalRequestTimeoutMsec, 10000, 4000, 0)
Expand Down
28 changes: 27 additions & 1 deletion src/inspector/inspectorhandler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
#include "logger.h"
#include "loghandler.h"
#include "models/featuremodel.h"
#include "models/subscriptiondata.h"
#include "mozillavpn.h"
#include "mzglean.h"
#include "networkmanager.h"
#include "qmlengineholder.h"
Expand Down Expand Up @@ -636,7 +638,31 @@ static QList<InspectorCommand> s_commands{
[](InspectorHandler*, const QList<QByteArray>&) {
MZGlean::initialize();
return QJsonObject();
}}};
}},

InspectorCommand{
"set_subscription_start_date",
"Changes the start date of the subscription", 1,
[](InspectorHandler*, const QList<QByteArray>& arguments) {
qint64 newCreatedAtTimestamp = arguments[1].toLongLong();

// get sub data json from settings
QByteArray subscriptionData =
SettingsHolder::instance()->subscriptionData();
QJsonDocument doc = QJsonDocument::fromJson(subscriptionData);
QJsonObject obj = doc.object();
QJsonObject subObj = obj["subscription"].toObject();

// modify createdAt date
qlonglong createdAt = newCreatedAtTimestamp;
subObj["created"] = createdAt;
obj["subscription"] = subObj;
doc.setObject(obj);
subscriptionData = doc.toJson();
SettingsHolder::instance()->setSubscriptionData(subscriptionData);

return QJsonObject();
}}};

// static
void InspectorHandler::initialize() {
Expand Down
10 changes: 8 additions & 2 deletions src/models/subscriptiondata.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ namespace {
Logger logger("SubscriptionData");
} // namespace

SubscriptionData::SubscriptionData() { MZ_COUNT_CTOR(SubscriptionData); }
SubscriptionData::SubscriptionData() {
MZ_COUNT_CTOR(SubscriptionData);

connect(SettingsHolder::instance(), &SettingsHolder::subscriptionDataChanged,
this, &SubscriptionData::fromSettings);
}

SubscriptionData::~SubscriptionData() { MZ_COUNT_DTOR(SubscriptionData); }

Expand All @@ -50,12 +55,13 @@ bool SubscriptionData::fromSettings() {

logger.debug() << "Reading the subscription data from settings";

const QByteArray& json = settingsHolder->devices();
const QByteArray& json = settingsHolder->subscriptionData();
if (json.isEmpty() || !fromJsonInternal(json)) {
return false;
}

m_rawJson = json;
emit initialized();
return true;
}

Expand Down
Loading

0 comments on commit 6facaf0

Please sign in to comment.