Skip to content

Commit 442bd96

Browse files
velerEtienne Baudoux
and
Etienne Baudoux
authoredNov 6, 2021
Added a way to automatically offer the user to rate the app (#62)
* Added a way to offer the user to rate the app. * adjusted marketing service * updated Readme * update french language Co-authored-by: Etienne Baudoux <etbaudou@microsoft.com>
1 parent 8e71c37 commit 442bd96

22 files changed

+599
-21
lines changed
 

‎README.md

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ DevToys helps in everyday tasks like formatting JSON, comparing text, testing Re
2626
Many tools are available.
2727
- Base 64 Encoder/Decoder
2828
- Hash Generator (MD5, SHA1, SHA256, SHA512)
29+
- Guid Generator
2930
- JWT Decoder
3031
- Json Formatter
3132
- RegExp Tester
@@ -67,6 +68,7 @@ For example, `start devtoys:?tool=jsonyaml` will open DevToys and start on the `
6768
Here is the list of tool name you can use:
6869
- `base64` - Base64 Encoder/Decoder
6970
- `hash` - Hash Generator
71+
- `guid` - Guid Generator
7072
- `jsonformat` Json Formatter
7173
- `jsonyaml` - Json <> Yaml
7274
- `jwt` - JWT Decoder
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#nullable enable
2+
3+
using System.Threading.Tasks;
4+
5+
namespace DevToys.Api.Core
6+
{
7+
/// <summary>
8+
/// Provides a service that help to generate positive review of the DevToys app.
9+
/// </summary>
10+
/// <remarks>
11+
/// This service should be called when the app started, crashed, successfuly performed a task.
12+
/// By monitoring these events, the service will try to decide of the most ideal moment
13+
/// for proposing to the user to share constructive feedback to the developer.
14+
/// </remarks>
15+
public interface IMarketingService
16+
{
17+
Task NotifyAppEncounteredAProblemAsync();
18+
19+
void NotifyToolSuccessfullyWorked();
20+
21+
void NotifyAppJustUpdated();
22+
23+
void NotifyAppStarted();
24+
25+
void NotifySmartDetectionWorked();
26+
}
27+
}

‎src/dev/impl/DevToys/App.xaml.cs

+10-7
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ namespace DevToys
3030
sealed partial class App : Application, IDisposable
3131
{
3232
private readonly Task<MefComposer> _mefComposer;
33-
private readonly Lazy<Task<IThemeListener>> _themeListener;
34-
private readonly Lazy<Task<ISettingsProvider>> _settingsProvider;
33+
private readonly AsyncLazy<IThemeListener> _themeListener;
34+
private readonly AsyncLazy<ISettingsProvider> _settingsProvider;
35+
private readonly AsyncLazy<IMarketingService> _marketingService;
3536

3637
private bool _isDisposed;
3738

@@ -56,8 +57,9 @@ public App()
5657
UnhandledException += OnUnhandledException;
5758

5859
// Importing it in a Lazy because we can't import it before a Window is created.
59-
_themeListener = new Lazy<Task<IThemeListener>>(async () => (await _mefComposer).ExportProvider.GetExport<IThemeListener>());
60-
_settingsProvider = new Lazy<Task<ISettingsProvider>>(async () => (await _mefComposer).ExportProvider.GetExport<ISettingsProvider>());
60+
_themeListener = new AsyncLazy<IThemeListener>(async () => (await _mefComposer).ExportProvider.GetExport<IThemeListener>());
61+
_settingsProvider = new AsyncLazy<ISettingsProvider>(async () => (await _mefComposer).ExportProvider.GetExport<ISettingsProvider>());
62+
_marketingService = new AsyncLazy<IMarketingService>(async () => (await _mefComposer).ExportProvider.GetExport<IMarketingService>());
6163

6264
InitializeComponent();
6365
Suspending += OnSuspending;
@@ -179,9 +181,10 @@ private void OnSuspending(object sender, SuspendingEventArgs e)
179181
deferral.Complete();
180182
}
181183

182-
private void OnUnhandledException(object sender, Windows.UI.Xaml.UnhandledExceptionEventArgs e)
184+
private async void OnUnhandledException(object sender, Windows.UI.Xaml.UnhandledExceptionEventArgs e)
183185
{
184186
Logger.LogFault("Unhandled problem", e.Exception);
187+
await (await _marketingService.GetValueAsync()).NotifyAppEncounteredAProblemAsync();
185188
}
186189

187190
private async Task<Frame> EnsureWindowIsInitializedAsync()
@@ -210,7 +213,7 @@ LanguageDefinition languageDefinition
210213
LanguageManager.Instance.SetCurrentCulture(languageDefinition);
211214

212215
// Apply the app color theme.
213-
(await _themeListener.Value).ApplyDesiredColorTheme();
216+
(await _themeListener.GetValueAsync()).ApplyDesiredColorTheme();
214217

215218
// Change the text editor font if the current font isn't available on the system.
216219
ValidateDefaultTextEditorFontAsync().Forget();
@@ -240,7 +243,7 @@ private async Task ValidateDefaultTextEditorFontAsync()
240243
{
241244
await TaskScheduler.Default;
242245

243-
ISettingsProvider settingsProvider = await _settingsProvider.Value;
246+
ISettingsProvider settingsProvider = await _settingsProvider.GetValueAsync();
244247
string currentFont = settingsProvider.GetSetting(PredefinedSettings.TextEditorFont);
245248
string[] systemFonts = CanvasTextFormat.GetSystemFontFamilies();
246249

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
#nullable enable
2+
3+
using DevToys.Api.Core;
4+
using DevToys.Core.Threading;
5+
using DevToys.Models;
6+
using Newtonsoft.Json;
7+
using System;
8+
using System.Composition;
9+
using System.Threading;
10+
using System.Threading.Tasks;
11+
using Windows.Services.Store;
12+
using Windows.Storage;
13+
14+
namespace DevToys.Core
15+
{
16+
[Export(typeof(IMarketingService))]
17+
[Shared]
18+
internal sealed class MarketingService : IMarketingService, IDisposable
19+
{
20+
private const string StoredFileName = "marketingInfo.json";
21+
22+
private readonly INotificationService _notificationService;
23+
private readonly DisposableSempahore _semaphore = new DisposableSempahore();
24+
private readonly object _lock = new object();
25+
26+
private bool _rateOfferInProgress;
27+
private AsyncLazy<MarketingState> _marketingState;
28+
29+
[ImportingConstructor]
30+
public MarketingService(INotificationService notificationService)
31+
{
32+
_notificationService = notificationService;
33+
_marketingState = new AsyncLazy<MarketingState>(LoadStateAsync);
34+
}
35+
36+
public void Dispose()
37+
{
38+
_semaphore.Dispose();
39+
}
40+
41+
public async Task NotifyAppEncounteredAProblemAsync()
42+
{
43+
await UpdateMarketingStateAsync(state =>
44+
{
45+
state.LastProblemEncounteredDate = DateTime.Now;
46+
state.StartSinceLastProblemEncounteredCount = 0;
47+
});
48+
}
49+
50+
public void NotifyAppJustUpdated()
51+
{
52+
UpdateMarketingStateAsync(state =>
53+
{
54+
state.LastUpdateDate = DateTime.Now;
55+
}).ForgetSafely();
56+
}
57+
58+
public void NotifyAppStarted()
59+
{
60+
UpdateMarketingStateAsync(state =>
61+
{
62+
state.StartSinceLastProblemEncounteredCount++;
63+
}).ForgetSafely();
64+
}
65+
66+
public void NotifySmartDetectionWorked()
67+
{
68+
UpdateMarketingStateAsync(state =>
69+
{
70+
state.SmartDetectionCount++;
71+
}).ContinueWith(_ =>
72+
{
73+
TryOfferUserToRateApp();
74+
}).ForgetSafely();
75+
}
76+
77+
public void NotifyToolSuccessfullyWorked()
78+
{
79+
UpdateMarketingStateAsync(state =>
80+
{
81+
state.ToolSuccessfulyWorkedCount++;
82+
}).ContinueWith(_ =>
83+
{
84+
TryOfferUserToRateApp();
85+
}).ForgetSafely();
86+
}
87+
88+
private void TryOfferUserToRateApp()
89+
{
90+
lock (_lock)
91+
{
92+
if (_rateOfferInProgress)
93+
{
94+
return;
95+
}
96+
97+
if (_marketingState.IsValueCreated
98+
&& DetermineWhetherAppRatingShouldBeOffered(_marketingState.GetValueAsync().Result))
99+
{
100+
_rateOfferInProgress = true;
101+
102+
_notificationService.ShowInAppNotification(
103+
LanguageManager.Instance.MainPage.NotificationRateAppTitle,
104+
LanguageManager.Instance.MainPage.NotificationRateAppActionableActionText,
105+
() =>
106+
{
107+
RateAsync().ForgetSafely();
108+
},
109+
LanguageManager.Instance.MainPage.NotificationRateAppMessage);
110+
}
111+
}
112+
}
113+
114+
private async Task RateAsync()
115+
{
116+
await UpdateMarketingStateAsync(state =>
117+
{
118+
state.AppRatingOfferCount++;
119+
state.LastAppRatingOfferDate = DateTime.Now;
120+
});
121+
122+
StoreContext storeContext = StoreContext.GetDefault();
123+
124+
StoreRateAndReviewResult result = await ThreadHelper.RunOnUIThreadAsync(async () =>
125+
{
126+
return await storeContext.RequestRateAndReviewAppAsync();
127+
}).ConfigureAwait(false);
128+
129+
if (result.Status == StoreRateAndReviewStatus.Succeeded)
130+
{
131+
await UpdateMarketingStateAsync(state =>
132+
{
133+
state.AppGotRated = true;
134+
});
135+
}
136+
137+
lock (_lock)
138+
{
139+
_rateOfferInProgress = false;
140+
}
141+
}
142+
143+
private bool DetermineWhetherAppRatingShouldBeOffered(MarketingState state)
144+
{
145+
// The user already rated the app. Let's not offer him to rate it again.
146+
if (state.AppGotRated)
147+
{
148+
return false;
149+
}
150+
151+
// We already offered the user to rate the app many times.
152+
// It's very unlikely that he will rate it at this point. Let's stop asking.
153+
if (state.AppRatingOfferCount >= 10)
154+
{
155+
return false;
156+
}
157+
158+
// If it's been less than 8 days since the last time the app crashed or that the app
159+
// has been installed on the machine. Let's not ask the user to rate the app.
160+
if (DateTime.Now - state.LastProblemEncounteredDate < TimeSpan.FromDays(8))
161+
{
162+
return false;
163+
}
164+
165+
// If the app have been started less than 4 times since the last crash or since the app
166+
// got installed on the machine, let's not ask the user to rate the app.
167+
if (state.StartSinceLastProblemEncounteredCount < 4)
168+
{
169+
return false;
170+
}
171+
172+
// The app got updated 2 days ago. Potentially, we introduced some instability (not necessarily crash,
173+
// but maybe visual issues, inconsistencies...etc).
174+
// Let's make sure we don't offer the user to rate the app as soon as it got updated, just in case
175+
// if the app is completely broken.
176+
if (DateTime.Now - state.LastUpdateDate < TimeSpan.FromDays(2))
177+
{
178+
return false;
179+
}
180+
181+
// Let's make sure we don't offer to rate the app more than once within 2 days.
182+
if (state.AppRatingOfferCount > 0 && DateTime.Now - state.LastAppRatingOfferDate < TimeSpan.FromDays(2))
183+
{
184+
return false;
185+
}
186+
187+
// If we already offered to rate the app more than 2 times, let's make sure we
188+
// don't offer it again before the next 5 days.
189+
if (state.AppRatingOfferCount > 2 && DateTime.Now - state.LastAppRatingOfferDate < TimeSpan.FromDays(5))
190+
{
191+
return false;
192+
}
193+
194+
// If we already offered to rate the app more than 5 times, let's make sure we
195+
// don't offer it again before the next 10 days.
196+
if (state.AppRatingOfferCount > 5 && DateTime.Now - state.LastAppRatingOfferDate < TimeSpan.FromDays(10))
197+
{
198+
return false;
199+
}
200+
201+
// If we already offered to rate the app more than 7 times, let's make sure we
202+
// don't offer it again before the next 60 days.
203+
if (state.AppRatingOfferCount > 7 && DateTime.Now - state.LastAppRatingOfferDate < TimeSpan.FromDays(60))
204+
{
205+
return false;
206+
}
207+
208+
// Smart Detection has been used at least twice. Let's offer the use to rate the app.
209+
if (state.SmartDetectionCount > 3)
210+
{
211+
return true;
212+
}
213+
214+
// The user used tools at least 10 times already. Let's offer the use to rate the app.
215+
if (state.ToolSuccessfulyWorkedCount > 10)
216+
{
217+
return true;
218+
}
219+
220+
return false;
221+
}
222+
223+
private async Task UpdateMarketingStateAsync(Action<MarketingState> updateAction)
224+
{
225+
await TaskScheduler.Default;
226+
227+
MarketingState state = await _marketingState.GetValueAsync();
228+
229+
using (await _semaphore.WaitAsync(CancellationToken.None))
230+
{
231+
updateAction(state);
232+
}
233+
234+
await SaveStateAsync(state);
235+
}
236+
237+
private async Task SaveStateAsync(MarketingState state)
238+
{
239+
await TaskScheduler.Default;
240+
241+
try
242+
{
243+
using (await _semaphore.WaitAsync(CancellationToken.None))
244+
{
245+
StorageFolder localCacheFolder = ApplicationData.Current.LocalCacheFolder;
246+
247+
StorageFile file = await localCacheFolder.CreateFileAsync(StoredFileName, CreationCollisionOption.ReplaceExisting);
248+
249+
string fileContent
250+
= JsonConvert.SerializeObject(
251+
state,
252+
Formatting.Indented);
253+
254+
await FileIO.WriteTextAsync(file, fileContent);
255+
}
256+
}
257+
catch (Exception)
258+
{
259+
}
260+
}
261+
262+
private async Task<MarketingState> LoadStateAsync()
263+
{
264+
await TaskScheduler.Default;
265+
266+
try
267+
{
268+
using (await _semaphore.WaitAsync(CancellationToken.None))
269+
{
270+
StorageFolder localCacheFolder = ApplicationData.Current.LocalCacheFolder;
271+
272+
IStorageItem? file = await localCacheFolder.TryGetItemAsync(StoredFileName);
273+
274+
if (file is not null && file is StorageFile storageFile)
275+
{
276+
string fileContent = await FileIO.ReadTextAsync(storageFile);
277+
var result = JsonConvert.DeserializeObject<MarketingState>(fileContent);
278+
if (result is not null)
279+
{
280+
return result;
281+
}
282+
}
283+
}
284+
}
285+
catch (Exception)
286+
{
287+
}
288+
289+
return new MarketingState
290+
{
291+
LastAppRatingOfferDate = DateTime.Now,
292+
LastProblemEncounteredDate = DateTime.Now,
293+
LastUpdateDate = DateTime.Now
294+
};
295+
}
296+
}
297+
}

0 commit comments

Comments
 (0)
Please sign in to comment.