Skip to content

Commit

Permalink
Added a way to automatically offer the user to rate the app (#62)
Browse files Browse the repository at this point in the history
* Added a way to offer the user to rate the app.

* adjusted marketing service

* updated Readme

* update french language

Co-authored-by: Etienne Baudoux <[email protected]>
  • Loading branch information
veler and Etienne Baudoux authored Nov 6, 2021
1 parent 8e71c37 commit 442bd96
Show file tree
Hide file tree
Showing 22 changed files with 599 additions and 21 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ DevToys helps in everyday tasks like formatting JSON, comparing text, testing Re
Many tools are available.
- Base 64 Encoder/Decoder
- Hash Generator (MD5, SHA1, SHA256, SHA512)
- Guid Generator
- JWT Decoder
- Json Formatter
- RegExp Tester
Expand Down Expand Up @@ -67,6 +68,7 @@ For example, `start devtoys:?tool=jsonyaml` will open DevToys and start on the `
Here is the list of tool name you can use:
- `base64` - Base64 Encoder/Decoder
- `hash` - Hash Generator
- `guid` - Guid Generator
- `jsonformat` Json Formatter
- `jsonyaml` - Json <> Yaml
- `jwt` - JWT Decoder
Expand Down
27 changes: 27 additions & 0 deletions src/dev/impl/DevToys/Api/Core/IMarketingService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#nullable enable

using System.Threading.Tasks;

namespace DevToys.Api.Core
{
/// <summary>
/// Provides a service that help to generate positive review of the DevToys app.
/// </summary>
/// <remarks>
/// This service should be called when the app started, crashed, successfuly performed a task.
/// By monitoring these events, the service will try to decide of the most ideal moment
/// for proposing to the user to share constructive feedback to the developer.
/// </remarks>
public interface IMarketingService
{
Task NotifyAppEncounteredAProblemAsync();

void NotifyToolSuccessfullyWorked();

void NotifyAppJustUpdated();

void NotifyAppStarted();

void NotifySmartDetectionWorked();
}
}
17 changes: 10 additions & 7 deletions src/dev/impl/DevToys/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ namespace DevToys
sealed partial class App : Application, IDisposable
{
private readonly Task<MefComposer> _mefComposer;
private readonly Lazy<Task<IThemeListener>> _themeListener;
private readonly Lazy<Task<ISettingsProvider>> _settingsProvider;
private readonly AsyncLazy<IThemeListener> _themeListener;
private readonly AsyncLazy<ISettingsProvider> _settingsProvider;
private readonly AsyncLazy<IMarketingService> _marketingService;

private bool _isDisposed;

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

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

InitializeComponent();
Suspending += OnSuspending;
Expand Down Expand Up @@ -179,9 +181,10 @@ private void OnSuspending(object sender, SuspendingEventArgs e)
deferral.Complete();
}

private void OnUnhandledException(object sender, Windows.UI.Xaml.UnhandledExceptionEventArgs e)
private async void OnUnhandledException(object sender, Windows.UI.Xaml.UnhandledExceptionEventArgs e)
{
Logger.LogFault("Unhandled problem", e.Exception);
await (await _marketingService.GetValueAsync()).NotifyAppEncounteredAProblemAsync();
}

private async Task<Frame> EnsureWindowIsInitializedAsync()
Expand Down Expand Up @@ -210,7 +213,7 @@ LanguageDefinition languageDefinition
LanguageManager.Instance.SetCurrentCulture(languageDefinition);

// Apply the app color theme.
(await _themeListener.Value).ApplyDesiredColorTheme();
(await _themeListener.GetValueAsync()).ApplyDesiredColorTheme();

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

ISettingsProvider settingsProvider = await _settingsProvider.Value;
ISettingsProvider settingsProvider = await _settingsProvider.GetValueAsync();
string currentFont = settingsProvider.GetSetting(PredefinedSettings.TextEditorFont);
string[] systemFonts = CanvasTextFormat.GetSystemFontFamilies();

Expand Down
297 changes: 297 additions & 0 deletions src/dev/impl/DevToys/Core/MarketingService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
#nullable enable

using DevToys.Api.Core;
using DevToys.Core.Threading;
using DevToys.Models;
using Newtonsoft.Json;
using System;
using System.Composition;
using System.Threading;
using System.Threading.Tasks;
using Windows.Services.Store;
using Windows.Storage;

namespace DevToys.Core
{
[Export(typeof(IMarketingService))]
[Shared]
internal sealed class MarketingService : IMarketingService, IDisposable
{
private const string StoredFileName = "marketingInfo.json";

private readonly INotificationService _notificationService;
private readonly DisposableSempahore _semaphore = new DisposableSempahore();
private readonly object _lock = new object();

private bool _rateOfferInProgress;
private AsyncLazy<MarketingState> _marketingState;

[ImportingConstructor]
public MarketingService(INotificationService notificationService)
{
_notificationService = notificationService;
_marketingState = new AsyncLazy<MarketingState>(LoadStateAsync);
}

public void Dispose()
{
_semaphore.Dispose();
}

public async Task NotifyAppEncounteredAProblemAsync()
{
await UpdateMarketingStateAsync(state =>
{
state.LastProblemEncounteredDate = DateTime.Now;
state.StartSinceLastProblemEncounteredCount = 0;
});
}

public void NotifyAppJustUpdated()
{
UpdateMarketingStateAsync(state =>
{
state.LastUpdateDate = DateTime.Now;
}).ForgetSafely();
}

public void NotifyAppStarted()
{
UpdateMarketingStateAsync(state =>
{
state.StartSinceLastProblemEncounteredCount++;
}).ForgetSafely();
}

public void NotifySmartDetectionWorked()
{
UpdateMarketingStateAsync(state =>
{
state.SmartDetectionCount++;
}).ContinueWith(_ =>
{
TryOfferUserToRateApp();
}).ForgetSafely();
}

public void NotifyToolSuccessfullyWorked()
{
UpdateMarketingStateAsync(state =>
{
state.ToolSuccessfulyWorkedCount++;
}).ContinueWith(_ =>
{
TryOfferUserToRateApp();
}).ForgetSafely();
}

private void TryOfferUserToRateApp()
{
lock (_lock)
{
if (_rateOfferInProgress)
{
return;
}

if (_marketingState.IsValueCreated
&& DetermineWhetherAppRatingShouldBeOffered(_marketingState.GetValueAsync().Result))
{
_rateOfferInProgress = true;

_notificationService.ShowInAppNotification(
LanguageManager.Instance.MainPage.NotificationRateAppTitle,
LanguageManager.Instance.MainPage.NotificationRateAppActionableActionText,
() =>
{
RateAsync().ForgetSafely();
},
LanguageManager.Instance.MainPage.NotificationRateAppMessage);
}
}
}

private async Task RateAsync()
{
await UpdateMarketingStateAsync(state =>
{
state.AppRatingOfferCount++;
state.LastAppRatingOfferDate = DateTime.Now;
});

StoreContext storeContext = StoreContext.GetDefault();

StoreRateAndReviewResult result = await ThreadHelper.RunOnUIThreadAsync(async () =>
{
return await storeContext.RequestRateAndReviewAppAsync();
}).ConfigureAwait(false);

if (result.Status == StoreRateAndReviewStatus.Succeeded)
{
await UpdateMarketingStateAsync(state =>
{
state.AppGotRated = true;
});
}

lock (_lock)
{
_rateOfferInProgress = false;
}
}

private bool DetermineWhetherAppRatingShouldBeOffered(MarketingState state)
{
// The user already rated the app. Let's not offer him to rate it again.
if (state.AppGotRated)
{
return false;
}

// We already offered the user to rate the app many times.
// It's very unlikely that he will rate it at this point. Let's stop asking.
if (state.AppRatingOfferCount >= 10)
{
return false;
}

// If it's been less than 8 days since the last time the app crashed or that the app
// has been installed on the machine. Let's not ask the user to rate the app.
if (DateTime.Now - state.LastProblemEncounteredDate < TimeSpan.FromDays(8))
{
return false;
}

// If the app have been started less than 4 times since the last crash or since the app
// got installed on the machine, let's not ask the user to rate the app.
if (state.StartSinceLastProblemEncounteredCount < 4)
{
return false;
}

// The app got updated 2 days ago. Potentially, we introduced some instability (not necessarily crash,
// but maybe visual issues, inconsistencies...etc).
// Let's make sure we don't offer the user to rate the app as soon as it got updated, just in case
// if the app is completely broken.
if (DateTime.Now - state.LastUpdateDate < TimeSpan.FromDays(2))
{
return false;
}

// Let's make sure we don't offer to rate the app more than once within 2 days.
if (state.AppRatingOfferCount > 0 && DateTime.Now - state.LastAppRatingOfferDate < TimeSpan.FromDays(2))
{
return false;
}

// If we already offered to rate the app more than 2 times, let's make sure we
// don't offer it again before the next 5 days.
if (state.AppRatingOfferCount > 2 && DateTime.Now - state.LastAppRatingOfferDate < TimeSpan.FromDays(5))
{
return false;
}

// If we already offered to rate the app more than 5 times, let's make sure we
// don't offer it again before the next 10 days.
if (state.AppRatingOfferCount > 5 && DateTime.Now - state.LastAppRatingOfferDate < TimeSpan.FromDays(10))
{
return false;
}

// If we already offered to rate the app more than 7 times, let's make sure we
// don't offer it again before the next 60 days.
if (state.AppRatingOfferCount > 7 && DateTime.Now - state.LastAppRatingOfferDate < TimeSpan.FromDays(60))
{
return false;
}

// Smart Detection has been used at least twice. Let's offer the use to rate the app.
if (state.SmartDetectionCount > 3)
{
return true;
}

// The user used tools at least 10 times already. Let's offer the use to rate the app.
if (state.ToolSuccessfulyWorkedCount > 10)
{
return true;
}

return false;
}

private async Task UpdateMarketingStateAsync(Action<MarketingState> updateAction)
{
await TaskScheduler.Default;

MarketingState state = await _marketingState.GetValueAsync();

using (await _semaphore.WaitAsync(CancellationToken.None))
{
updateAction(state);
}

await SaveStateAsync(state);
}

private async Task SaveStateAsync(MarketingState state)
{
await TaskScheduler.Default;

try
{
using (await _semaphore.WaitAsync(CancellationToken.None))
{
StorageFolder localCacheFolder = ApplicationData.Current.LocalCacheFolder;

StorageFile file = await localCacheFolder.CreateFileAsync(StoredFileName, CreationCollisionOption.ReplaceExisting);

string fileContent
= JsonConvert.SerializeObject(
state,
Formatting.Indented);

await FileIO.WriteTextAsync(file, fileContent);
}
}
catch (Exception)
{
}
}

private async Task<MarketingState> LoadStateAsync()
{
await TaskScheduler.Default;

try
{
using (await _semaphore.WaitAsync(CancellationToken.None))
{
StorageFolder localCacheFolder = ApplicationData.Current.LocalCacheFolder;

IStorageItem? file = await localCacheFolder.TryGetItemAsync(StoredFileName);

if (file is not null && file is StorageFile storageFile)
{
string fileContent = await FileIO.ReadTextAsync(storageFile);
var result = JsonConvert.DeserializeObject<MarketingState>(fileContent);
if (result is not null)
{
return result;
}
}
}
}
catch (Exception)
{
}

return new MarketingState
{
LastAppRatingOfferDate = DateTime.Now,
LastProblemEncounteredDate = DateTime.Now,
LastUpdateDate = DateTime.Now
};
}
}
}
Loading

0 comments on commit 442bd96

Please sign in to comment.