Skip to content

Commit

Permalink
Add iOS in-app rating mechanism
Browse files Browse the repository at this point in the history
- Own dialog, then, if user is happy, open App Store with write-review intent
- Triggers:
-- After using the app for minimum 7 days, we can show the dialog after the user created three events / after three mails
-- When the user did 10 activities within a 28-day period. (sending and email creating event. )
-- After succeeding the paywall
- Use device config to store data needed to trigger the dialog
- Added tests
- Added method to get the native app's installation date (Android & iOS)
- Added comment about `SKStoreReviewController.requestReview()` deprecation

Co-authored-by: arm <[email protected]>
Co-authored-by: jat <[email protected]>
  • Loading branch information
3 people committed Dec 2, 2024
1 parent c12a3c0 commit 3013ccc
Show file tree
Hide file tree
Showing 29 changed files with 717 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.IOException
import de.tutao.tutashared.SystemUtils


class AndroidMobileSystemFacade(
private val fileFacade: AndroidFileFacade,
Expand Down Expand Up @@ -168,4 +170,8 @@ class AndroidMobileSystemFacade(
override suspend fun openMailApp(query: String) {
Log.e(TAG, "Trying to open Tuta Mail from Tuta Mail")
}

override suspend fun getInstallationDate(): String {
return SystemUtils.getInstallationDate(activity.packageManager, activity.packageName)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import androidx.core.content.ContextCompat.startActivity
import androidx.core.content.FileProvider
import androidx.fragment.app.FragmentActivity
import de.tutao.tutashared.CredentialAuthenticationException
import de.tutao.tutashared.SystemUtils
import de.tutao.tutashared.atLeastTiramisu
import de.tutao.tutashared.credentials.AuthenticationPrompt
import de.tutao.tutashared.data.AppDatabase
Expand Down Expand Up @@ -195,4 +196,8 @@ class AndroidMobileSystemFacade(
tryToLaunchStore()
}
}

override suspend fun getInstallationDate(): String {
return SystemUtils.getInstallationDate(activity.packageManager, activity.packageName)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package de.tutao.tutashared

import android.content.pm.PackageManager
import java.io.File

sealed class SystemUtils {
companion object {
/**
* Returns the installation time of a package in UNIX Epoch time.
* Adapted from https://stackoverflow.com/a/2832419
*/
@JvmStatic
fun getInstallationDate(pm: PackageManager, packageName: String): String {
val appInfo = pm.getApplicationInfo(packageName, 0)
val appFile = appInfo.sourceDir
val installedTime = File(appFile).lastModified() //Epoch Time
return installedTime.toString()
}
}
}


Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,14 @@ interface MobileSystemFacade {
suspend fun openMailApp(
query: String,
): Unit
/**
* Returns the date and time the app was installed as a string with milliseconds in UNIX epoch.
*/
suspend fun getInstallationDate(
): String
/**
* Requests the system in-app rating dialog to be displayed
*/
suspend fun requestInAppRating(
): Unit
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ class MobileSystemFacadeReceiveDispatcher(
)
return json.encodeToString(result)
}
"getInstallationDate" -> {
val result: String = this.facade.getInstallationDate(
)
return json.encodeToString(result)
}
"requestInAppRating" -> {
val result: Unit = this.facade.requestInAppRating(
)
return json.encodeToString(result)
}
else -> throw Error("unknown method for MobileSystemFacade: $method")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,14 @@ public protocol MobileSystemFacade {
func openMailApp(
_ query: String
) async throws -> Void
/**
* Returns the date and time the app was installed as a string with milliseconds in UNIX epoch.
*/
func getInstallationDate(
) async throws -> String
/**
* Requests the system in-app rating dialog to be displayed
*/
func requestInAppRating(
) async throws -> Void
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ public class MobileSystemFacadeReceiveDispatcher {
query
)
return "null"
case "getInstallationDate":
let result = try await self.facade.getInstallationDate(
)
return toJson(result)
case "requestInAppRating":
try await self.facade.requestInAppRating(
)
return "null"
default:
fatalError("licc messed up! \(method)")
}
Expand Down
11 changes: 11 additions & 0 deletions app-ios/calendar/Sources/IosMobileSystemFacade.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Contacts
import StoreKit
import Foundation
import TutanotaSharedFramework

Expand Down Expand Up @@ -93,4 +94,14 @@ class IosMobileSystemFacade: MobileSystemFacade {
DispatchQueue.main.async { UIApplication.shared.open(URL(string: "https://itunes.apple.com/us/app/id922429609")!) }
}
}
func getInstallationDate() async throws -> String {
let documentsURL = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
let creationDate = try FileManager.default.attributesOfItem(atPath: documentsURL.path)[FileAttributeKey.creationDate] as! Date
let creationTimeInMilliseconds = Int(creationDate.timeIntervalSince1970 * 1000)
return String(creationTimeInMilliseconds)
}
func requestInAppRating() async throws {
let windowScene = await UIApplication.shared.connectedScenes.first as! UIWindowScene
await SKStoreReviewController.requestReview(in: windowScene)
}
}
14 changes: 14 additions & 0 deletions app-ios/tutanota/Sources/IosMobileSystemFacade.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Contacts
import Foundation
import StoreKit
import TutanotaSharedFramework

private let APP_LOCK_METHOD = "AppLockMethod"
Expand Down Expand Up @@ -78,4 +79,17 @@ class IosMobileSystemFacade: MobileSystemFacade {
}

func openMailApp(_ query: String) async throws { TUTSLog("Tried to open Mail App from Mail App") }
func getInstallationDate() async throws -> String {
let documentsURL = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
let creationDate = try FileManager.default.attributesOfItem(atPath: documentsURL.path)[FileAttributeKey.creationDate] as! Date
let creationTimeInMilliseconds = Int(creationDate.timeIntervalSince1970 * 1000)
return String(creationTimeInMilliseconds)
}
func requestInAppRating() async throws {
// TODO: Replace `SKStoreReviewController.requestReview()` with StoreKit's/SwiftUI's `requestReview()`
// as `SKStoreReviewController.requestReview()` will be removed in iOS 19 (release roughly September 2025)
// This will require migrating from UIKit to Swift UI
let windowScene = await UIApplication.shared.connectedScenes.first as! UIWindowScene
await SKStoreReviewController.requestReview(in: windowScene)
}
}
3 changes: 2 additions & 1 deletion buildSrc/RollupConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const allowedImports = {
date: ["polyfill-helpers", "common-min", "common"],
"date-gui": ["polyfill-helpers", "common-min", "common", "boot", "gui-base", "main", "sharing", "date", "contacts"],
"mail-view": ["polyfill-helpers", "common-min", "common", "boot", "gui-base", "main"],
"mail-editor": ["polyfill-helpers", "common-min", "common", "boot", "gui-base", "main", "mail-view", "sanitizer", "sharing"],
"mail-editor": ["polyfill-helpers", "common-min", "common", "boot", "gui-base", "main", "mail-view", "sanitizer", "sharing", "date-gui"],
search: ["polyfill-helpers", "common-min", "common", "boot", "gui-base", "main", "mail-view", "calendar-view", "contacts", "date", "date-gui", "sharing"],
// ContactMergeView needs HtmlEditor even though ContactEditor doesn't?
contacts: ["polyfill-helpers", "common-min", "common", "boot", "gui-base", "main", "mail-view", "date", "date-gui", "mail-editor"],
Expand Down Expand Up @@ -174,6 +174,7 @@ export function getChunkName(moduleId, { getModuleInfo }) {
isIn("src/calendar-app/calendar/export") ||
isIn("src/common/misc/DateParser") ||
isIn("src/common/misc/CyberMondayUtils") ||
isIn("src/common/ratings") ||
isIn("src/calendar-app/calendar/model") ||
isIn("src/calendar-app/calendar/gui") ||
isIn("src/common/calendar/import")
Expand Down
10 changes: 10 additions & 0 deletions ipc-schema/facades/MobileSystemFacade.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@
}
],
"ret": "void"
},
"getInstallationDate": {
"doc": "Returns the date and time the app was installed as a string with milliseconds in UNIX epoch.",
"arg": [],
"ret": "string"
},
"requestInAppRating": {
"doc": "Requests the system in-app rating dialog to be displayed",
"arg": [],
"ret": "void"
}
}
}
Binary file added resources/images/rating/calendar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added resources/images/rating/mail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import { convertTextToHtml } from "../../../../common/misc/Formatter.js"
import { UserError } from "../../../../common/api/main/UserError.js"
import { showUserError } from "../../../../common/misc/ErrorHandlerImpl.js"

import { handleRatingByEvent } from "../../../../common/ratings/InAppRatingDialog.js"

const enum ConfirmationResult {
Cancel,
Continue,
Expand Down Expand Up @@ -151,6 +153,8 @@ export async function showNewCalendarEventEditDialog(model: CalendarEventModel):
if (result === EventSaveResult.Saved) {
finished = true
finish()

await handleRatingByEvent()
}
} catch (e) {
if (e instanceof UserError) {
Expand Down
3 changes: 1 addition & 2 deletions src/common/gui/base/Dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ import { DropDownSelector } from "./DropDownSelector.js"
import { DEFAULT_ERROR, Keys, TabIndex } from "../../api/common/TutanotaConstants"
import { AriaWindow } from "../AriaUtils"
import { styles } from "../styles"
import { lazy, MaybeLazy, Thunk } from "@tutao/tutanota-utils"
import { $Promisable, assertNotNull, getAsLazy, identity, mapLazily, noOp } from "@tutao/tutanota-utils"
import { $Promisable, assertNotNull, getAsLazy, identity, lazy, mapLazily, MaybeLazy, noOp, Thunk } from "@tutao/tutanota-utils"
import type { DialogInjectionRightAttrs } from "./DialogInjectionRight"
import { DialogInjectionRight } from "./DialogInjectionRight"
import { assertMainOrNode } from "../../api/common/Env"
Expand Down
82 changes: 82 additions & 0 deletions src/common/misc/DeviceConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ interface ConfigObject {
isCredentialsMigratedToNative: boolean
lastExternalCalendarSync: Record<Id, LastExternalCalendarSyncEntry>
clientOnlyCalendars: Map<Id, ClientOnlyCalendarsInfo>

/**
* A list of dates on which a user has sent an e-mail or created a calendar event. Each date is represented as the date's timestamp.
*/
events: Array<number>

/**
* The last date on which the user was prompted to rate the app as a timestamp.
*/
lastRatingPromptedDate?: number

/**
* The date of the earliest possible next date from which another rating can be requested from the user.
* This is only for the case the user does not want to rate right now or completely opts out of the in-app ratings.
*/
retryRatingPromptAfter?: number
}

/**
Expand Down Expand Up @@ -125,6 +141,9 @@ export class DeviceConfig implements UsageTestStorage, NewsItemStorage {
isCredentialsMigratedToNative: loadedConfig.isCredentialsMigratedToNative ?? false,
lastExternalCalendarSync: loadedConfig.lastExternalCalendarSync ?? {},
clientOnlyCalendars: loadedConfig.clientOnlyCalendars ? new Map(typedEntries(loadedConfig.clientOnlyCalendars)) : new Map(),
events: loadedConfig.events ?? [],
lastRatingPromptedDate: loadedConfig.lastRatingPromptedDate ?? null,
retryRatingPromptAfter: loadedConfig.retryRatingPromptAfter ?? null,
}

this.lastSyncStream(new Map(Object.entries(this.config.lastExternalCalendarSync)))
Expand Down Expand Up @@ -414,6 +433,69 @@ export class DeviceConfig implements UsageTestStorage, NewsItemStorage {
this.config.clientOnlyCalendars.set(calendarId, clientOnlyCalendarConfig)
this.writeToStorage()
}

public writeEvents(events: Date[]): void {
this.config.events = events.map((date) => date.getTime())
this.writeToStorage()
}

/**
* Gets a list of dates on which a certain event has occurred. Could be email sent, replied, contact created etc.
*
* Only present on iOS.
*/
public getEvents(): Date[] {
return (this.config.events ?? []).flatMap((timestamp) => {
try {
return new Date(timestamp)
} catch (e) {
return []
}
})
}

public setLastRatingPromptedDate(date: Date): void {
this.config.lastRatingPromptedDate = date.getTime()
this.writeToStorage()
}

/**
* Gets the last date on which the user was prompted to rate the app.
*/
public getLastRatingPromptedDate(): Date | null {
if (this.config.lastRatingPromptedDate == null) {
return null
}

try {
return new Date(this.config.lastRatingPromptedDate)
} catch (e) {
return null
}
}

/**
* Sets the date of the earliest possible next date from which another rating can be requested from the user.
*/
public setRetryRatingPromptAfter(date: Date): void {
this.config.retryRatingPromptAfter = date.getTime()
this.writeToStorage()
}

/**
* Gets the date of the earliest possible next date from which another rating can be requested from the user.
*/
public getRetryRatingPromptAfter(): Date | null {
if (this.config.retryRatingPromptAfter == null) {
return null
}

try {
return new Date(this.config.retryRatingPromptAfter)
} catch (e) {
return null
}
}
}

export function migrateConfig(loadedConfig: any) {
Expand Down
5 changes: 5 additions & 0 deletions src/common/misc/TranslationKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1800,3 +1800,8 @@ export type TranslationKeyType =
| "yourMessage_label"
| "you_label"
| "emptyString_msg"
| "ratingHowAreWeDoing_title"
| "ratingExplanation_msg"
| "ratingLoveIt_label"
| "ratingNeedsWork_label"
| "notNow_label"
10 changes: 10 additions & 0 deletions src/common/native/common/generatedipc/MobileSystemFacade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,14 @@ export interface MobileSystemFacade {
getSupportedAppLockMethods(): Promise<ReadonlyArray<AppLockMethod>>

openMailApp(query: string): Promise<void>

/**
* Returns the date and time the app was installed as a string with milliseconds in UNIX epoch.
*/
getInstallationDate(): Promise<string>

/**
* Requests the system in-app rating dialog to be displayed
*/
requestInAppRating(): Promise<void>
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,10 @@ export class MobileSystemFacadeSendDispatcher implements MobileSystemFacade {
async openMailApp(...args: Parameters<MobileSystemFacade["openMailApp"]>) {
return this.transport.invokeNative("ipc", ["MobileSystemFacade", "openMailApp", ...args])
}
async getInstallationDate(...args: Parameters<MobileSystemFacade["getInstallationDate"]>) {
return this.transport.invokeNative("ipc", ["MobileSystemFacade", "getInstallationDate", ...args])
}
async requestInAppRating(...args: Parameters<MobileSystemFacade["requestInAppRating"]>) {
return this.transport.invokeNative("ipc", ["MobileSystemFacade", "requestInAppRating", ...args])
}
}
5 changes: 0 additions & 5 deletions src/common/native/main/wizard/SetupWizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,8 @@ import { createWizardDialog, WizardPageWrapper, wizardPageWrapper } from "../../
import { defer } from "@tutao/tutanota-utils"
import { SetupCongratulationsPage, SetupCongratulationsPageAttrs } from "./setupwizardpages/SetupCongraulationsPage.js"
import { DeviceConfig } from "../../../misc/DeviceConfig.js"
import m from "mithril"
import { SetupNotificationsPage, SetupNotificationsPageAttrs } from "./setupwizardpages/SetupNotificationsPage.js"
import { BannerButton } from "../../../gui/base/buttons/BannerButton.js"
import { theme } from "../../../gui/theme.js"
import { ClickHandler } from "../../../gui/base/GuiUtils.js"
import { DialogType } from "../../../gui/base/Dialog.js"
import { TranslationKey } from "../../../misc/LanguageViewModel.js"
import { SetupThemePage, SetupThemePageAttrs } from "./setupwizardpages/SetupThemePage.js"
import { SetupContactsPage, SetupContactsPageAttrs } from "./setupwizardpages/SetupContactsPage.js"
import { SetupLockPage, SetupLockPageAttrs } from "./setupwizardpages/SetupLockPage.js"
Expand Down
Loading

0 comments on commit 3013ccc

Please sign in to comment.