Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added a way to automatically offer the user to rate the app #62

Merged
merged 4 commits into from
Nov 6, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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