diff --git a/android/app/build.gradle b/android/app/build.gradle index 4ea8ece9d..004dfbfd0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -428,8 +428,6 @@ dependencies { implementation 'com.stripe:stripe-android:20.17.0' - implementation 'com.datadoghq:dd-sdk-android:1.19.3' - annotationProcessor "org.androidannotations:androidannotations:$androidAnnotationsVersion" implementation("org.androidannotations:androidannotations-api:$androidAnnotationsVersion") kapt "org.androidannotations:androidannotations:$androidAnnotationsVersion" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f95e8b487..d986dd656 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -105,6 +105,16 @@ android:resource="@xml/file_paths" /> + + + + diff --git a/android/app/src/main/kotlin/io/lantern/model/SessionModel.kt b/android/app/src/main/kotlin/io/lantern/model/SessionModel.kt index 5cf700ceb..361665b8a 100644 --- a/android/app/src/main/kotlin/io/lantern/model/SessionModel.kt +++ b/android/app/src/main/kotlin/io/lantern/model/SessionModel.kt @@ -29,6 +29,7 @@ import org.getlantern.lantern.model.LanternHttpClient.ProUserCallback import org.getlantern.lantern.model.ProError import org.getlantern.lantern.model.ProUser import org.getlantern.lantern.model.Utils +import org.getlantern.lantern.plausible.Plausible import org.getlantern.lantern.util.AutoUpdater import org.getlantern.lantern.util.PaymentsUtil import org.getlantern.lantern.util.PermissionUtil @@ -181,6 +182,10 @@ class SessionModel( } } + "trackUserAction" -> { + Plausible.event(call.argument("message")!!) + } + "acceptTerms" -> { LanternApp.getSession().acceptTerms() } diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt b/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt index 44275691b..62fd3d72f 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt @@ -44,6 +44,7 @@ import org.getlantern.lantern.model.Utils import org.getlantern.lantern.model.VpnState import org.getlantern.lantern.notification.NotificationHelper import org.getlantern.lantern.notification.NotificationReceiver +import org.getlantern.lantern.plausible.Plausible import org.getlantern.lantern.service.LanternService_ import org.getlantern.lantern.util.PermissionUtil import org.getlantern.lantern.util.PlansUtil @@ -90,6 +91,7 @@ class MainActivity : eventManager = object : EventManager("lantern_event_channel", flutterEngine) { override fun onListen(event: Event) { if (LanternApp.getSession().lanternDidStart()) { + Plausible.enable(true) fetchLoConf() Logger.debug( TAG, @@ -129,7 +131,6 @@ class MainActivity : override fun onCreate(savedInstanceState: Bundle?) { val start = System.currentTimeMillis() super.onCreate(savedInstanceState) - Logger.debug(TAG, "Default Locale is %1\$s", Locale.getDefault()) if (!EventBus.getDefault().isRegistered(this)) { EventBus.getDefault().register(this) diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/plausible/Event.kt b/android/app/src/main/kotlin/org/getlantern/lantern/plausible/Event.kt new file mode 100644 index 000000000..074086112 --- /dev/null +++ b/android/app/src/main/kotlin/org/getlantern/lantern/plausible/Event.kt @@ -0,0 +1,22 @@ +package org.getlantern.lantern.plausible + +import org.getlantern.lantern.util.Json + +internal data class Event( + val domain: String, + val name: String, + val url: String, + val referrer: String, + val screenWidth: Int, + val props: Map? +) { + companion object { + fun fromJson(json: String): Event? = try { + Json.gson.fromJson(json, Event::class.java) + } catch (ignored: Exception) { + null + } + } +} + +internal fun Event.toJson(): String = Json.gson.toJson(this) diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/plausible/Plausible.kt b/android/app/src/main/kotlin/org/getlantern/lantern/plausible/Plausible.kt new file mode 100644 index 000000000..c6e9a5302 --- /dev/null +++ b/android/app/src/main/kotlin/org/getlantern/lantern/plausible/Plausible.kt @@ -0,0 +1,107 @@ +package org.getlantern.lantern.plausible + +import android.content.Context +import java.util.concurrent.atomic.AtomicReference +import org.getlantern.mobilesdk.Logger + +// Singleton for sending events to Plausible. +object Plausible { + private val client: AtomicReference = AtomicReference(null) + private val config: AtomicReference = AtomicReference(null) + + fun init(context: Context) { + val config = AndroidResourcePlausibleConfig(context) + val client = NetworkFirstPlausibleClient(config) + init(client, config) + } + + internal fun init(client: PlausibleClient, config: PlausibleConfig) { + this.client.set(client) + this.config.set(config) + } + + // Enable or disable event sending + @Suppress("unused") + fun enable(enable: Boolean) { + config.get() + ?.let { + it.enable = enable + } + ?: Logger.d("Plausible", "Ignoring call to enable(). Did you forget to call Plausible.init()?") + } + + /** + * The raw value of User-Agent is used to calculate the user_id which identifies a unique + * visitor in Plausible. + * User-Agent is also used to populate the Devices report in your + * Plausible dashboard. The device data is derived from the open source database + * device-detector. If your User-Agent is not showing up in your dashboard, it's probably + * because it is not recognized as one in the device-detector database. + */ + @Suppress("unused") + fun setUserAgent(userAgent: String) { + config.get() + ?.let { + it.userAgent = userAgent + } + ?: Logger.d("Plausible", "Ignoring call to setUserAgent(). Did you forget to call Plausible.init()?") + } + + /** + * Send a `pageview` event. + * + * @param url URL of the page where the event was triggered. If the URL contains UTM parameters, + * they will be extracted and stored. + * The URL parameter will feel strange in a mobile app but you can manufacture something that looks + * like a web URL. If you name your mobile app screens like page URLs, Plausible will know how to + * handle it. So for example, on your login screen you could send something like + * `app://localhost/login`. The pathname (/login) is what will be shown as the page value in the + * Plausible dashboard. + * @param referrer Referrer for this event. + * Plausible uses the open source referer-parser database to parse referrers and assign these + */ + fun pageView( + url: String, + referrer: String = "", + props: Map? = null + ) = event( + name = "pageview", + url = url, + referrer = referrer, + props = props + ) + + /** + * Send a custom event. To send a `pageview` event, consider using [pageView] instead. + * + * @param name Name of the event. Can specify `pageview` which is a special type of event in + * Plausible. All other names will be treated as custom events. + * @param url URL of the page where the event was triggered. If the URL contains UTM parameters, + * they will be extracted and stored. + * The URL parameter will feel strange in a mobile app but you can manufacture something that looks + * like a web URL. If you name your mobile app screens like page URLs, Plausible will know how to + * handle it. So for example, on your login screen you could send something like + * `app://localhost/login`. The pathname (/login) is what will be shown as the page value in the + * Plausible dashboard. + * @param referrer Referrer for this event. + * Plausible uses the open source referer-parser database to parse referrers and assign these + * source categories. + */ + @Suppress("MemberVisibilityCanBePrivate") + fun event( + name: String, + url: String = "", + referrer: String = "", + props: Map? = null + ) { + client.get() + ?.let { client -> + config.get() + ?.let { config -> + client.event(config.domain, name, url, referrer, config.screenWidth, props) + } + ?: Logger.d("Plausible", "Ignoring call to event(). Did you forget to call Plausible.init()?") + } + ?: Logger.d("Plausible", "Ignoring call to event(). Did you forget to call Plausible.init()?") + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/plausible/PlausibleClient.kt b/android/app/src/main/kotlin/org/getlantern/lantern/plausible/PlausibleClient.kt new file mode 100644 index 000000000..a97f56b8a --- /dev/null +++ b/android/app/src/main/kotlin/org/getlantern/lantern/plausible/PlausibleClient.kt @@ -0,0 +1,179 @@ +package org.getlantern.lantern.plausible + +import android.net.Uri +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.io.File +import java.io.IOException +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.URI +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import org.getlantern.lantern.LanternApp +import org.getlantern.mobilesdk.Logger + +internal interface PlausibleClient { + + // See [Plausible.event] for details on parameters. + // @return true if the event was successfully processed and false if not + fun event( + domain: String, + name: String, + url: String, + referrer: String, + screenWidth: Int, + props: Map? = null + ) { + var correctedUrl = Uri.parse(url) + if (correctedUrl.scheme.isNullOrBlank()) { + correctedUrl = correctedUrl.buildUpon().scheme("app").build() + } + if (correctedUrl.authority.isNullOrBlank()) { + correctedUrl = correctedUrl.buildUpon().authority("localhost").build() + } + return event(Event( + domain, + name, + correctedUrl.toString(), + referrer, + screenWidth, + props?.mapValues { (_, v) -> v.toString() } + )) + } + + fun event(event: Event) +} + +// The primary client for sending events to Plausible. It will attempt to send events immediately, +// caching them to disk to send later upon failure. +internal class NetworkFirstPlausibleClient( + private val config: PlausibleConfig, + coroutineContext: CoroutineContext = Dispatchers.IO +) : PlausibleClient { + private val coroutineScope = CoroutineScope(coroutineContext) + + init { + coroutineScope.launch { + config.eventDir.mkdirs() + config.eventDir.listFiles()?.forEach { + val event = Event.fromJson(it.readText()) + if (event == null) { + Logger.e("Plausible", "Failed to decode event JSON, discarding") + it.delete() + return@forEach + } + try { + postEvent(event) + } catch (e: IOException) { + return@forEach + } + it.delete() + } + } + } + + override fun event(event: Event) { + coroutineScope.launch { + suspendEvent(event) + } + } + + @VisibleForTesting + internal suspend fun suspendEvent(event: Event) { + try { + postEvent(event) + } catch (e: IOException) { + if (!config.retryOnFailure) return + val file = File(config.eventDir, "event_${System.currentTimeMillis()}.json") + file.writeText(event.toJson()) + var retryAttempts = 0 + var retryDelay = 1000L + while (retryAttempts < 5) { + delay(retryDelay) + retryDelay = when (retryDelay) { + 1000L -> 60_000L + 60_000L -> 360_000L + 360_000L -> 600_000L + else -> break + } + try { + postEvent(event) + file.delete() + break + } catch (e: IOException) { + retryAttempts++ + } + } + } + } + + private suspend fun postEvent(event: Event) { + if (!config.enable) { + Logger.e("Plausible", "Plausible disabled, not sending event: $event") + return + } + val body = event.toJson().toRequestBody("application/json".toMediaType()) + val url = config.host + .toHttpUrl() + .newBuilder() + .addPathSegments("api/event") + .build() + val request = Request.Builder() + .url(url) + .addHeader("User-Agent", config.userAgent) + .post(body) + .build() + suspendCancellableCoroutine { continuation -> + val call = okHttpClient.newCall(request) + continuation.invokeOnCancellation { + call.cancel() + } + + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Logger.e("Plausible", "Failed to send event to backend") + continuation.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + response.use { res -> + if (res.isSuccessful) { + continuation.resume(Unit) + } else { + val e = IOException( + "Received unexpected response: ${res.code} ${res.body?.string()}" + ) + onFailure(call, e) + } + } + } + }) + } + } + + val okHttpClient: OkHttpClient by lazy { + val session = LanternApp.getSession() + val hTTPAddr = session.hTTPAddr + val uri = URI("http://" + hTTPAddr) + val proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress( + "127.0.0.1", + uri.getPort(), + ), + ) + OkHttpClient.Builder().proxy(proxy).build() + } +} diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/plausible/PlausibleConfig.kt b/android/app/src/main/kotlin/org/getlantern/lantern/plausible/PlausibleConfig.kt new file mode 100644 index 000000000..8b66312e6 --- /dev/null +++ b/android/app/src/main/kotlin/org/getlantern/lantern/plausible/PlausibleConfig.kt @@ -0,0 +1,90 @@ +package org.getlantern.lantern.plausible + +import android.content.Context +import android.content.res.Resources +import android.os.Build +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlin.math.roundToInt +import org.getlantern.lantern.BuildConfig +import org.getlantern.lantern.R + +private val DEFAULT_USER_AGENT = + "Android ${Build.VERSION.RELEASE} ${Build.MANUFACTURER} ${Build.PRODUCT} ${Build.FINGERPRINT.hashCode()}" +private const val DEFAULT_PLAUSIBLE_HOST = "https://plausible.io" + +// Configuration options for the Plausible SDK. See the [Events API reference](https://plausible.io/docs/events-api) for more details +interface PlausibleConfig { + + // Domain name of the site in Plausible + var domain: String + + // Whether or not events should be sent. Use this to allow users to opt-in or opt-out for + // example. + var enable: Boolean + + // Directory to persist events upon upload failure. + val eventDir: File + + // The host for the Plausible backend server. Defaults to `https://plausible.io` + var host: String + + // Whether or not to attempt to resend events upon failure. If true, events will be serialized + // to disk in [eventDir] and the upload will be retried later. + var retryOnFailure: Boolean + + // Width of the screen in dp. + val screenWidth: Int + + // The raw value of User-Agent is used to calculate the user_id which identifies a unique + // visitor in Plausible. + var userAgent: String +} + +open class ThreadSafePlausibleConfig( + override val eventDir: File, + override val screenWidth: Int +) : PlausibleConfig { + + private val enableRef = AtomicBoolean(true) + override var enable: Boolean + get() = enableRef.get() + set(value) = enableRef.set(value) + + private val domainRef = AtomicReference("") + override var domain: String + get() = domainRef.get() + set(value) = domainRef.set(value) + + private val hostRef = AtomicReference(DEFAULT_PLAUSIBLE_HOST) + override var host: String + get() = hostRef.get() ?: "" + set(value) = hostRef.set(value.ifBlank { DEFAULT_PLAUSIBLE_HOST }) + + private val retryRef = AtomicBoolean(true) + override var retryOnFailure: Boolean + get() = retryRef.get() + set(value) = retryRef.set(value) + + private val userAgentRef = AtomicReference(DEFAULT_USER_AGENT) + override var userAgent: String + get() = userAgentRef.get() + set(value) = userAgentRef.set(value.ifBlank { DEFAULT_USER_AGENT }) +} + +class AndroidResourcePlausibleConfig(context: Context) : ThreadSafePlausibleConfig( + eventDir = File(context.applicationContext.filesDir, "events"), + screenWidth = with(Resources.getSystem().displayMetrics) { + widthPixels / density + }.roundToInt() +) { + init { + domain = context.resources.getString(R.string.plausible_domain) + host = context.resources.getString(R.string.plausible_host) + context.resources.getString(R.string.plausible_enable_startup).toBooleanStrictOrNull() + ?.let { + enable = it + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/plausible/PlausibleInitializer.kt b/android/app/src/main/kotlin/org/getlantern/lantern/plausible/PlausibleInitializer.kt new file mode 100644 index 000000000..02dc89943 --- /dev/null +++ b/android/app/src/main/kotlin/org/getlantern/lantern/plausible/PlausibleInitializer.kt @@ -0,0 +1,16 @@ +package org.getlantern.lantern.plausible + +import android.content.Context +import androidx.startup.Initializer + +// Automatically initializes the Plausible SDK for sending events. +class PlausibleInitializer : Initializer { + override fun create(context: Context): Plausible { + Plausible.init(context.applicationContext) + return Plausible + } + + override fun dependencies(): List>> { + return emptyList() + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/vpn/LanternVpnService.kt b/android/app/src/main/kotlin/org/getlantern/lantern/vpn/LanternVpnService.kt index 70f551a93..c703c75d2 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/vpn/LanternVpnService.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/vpn/LanternVpnService.kt @@ -7,6 +7,7 @@ import android.content.ServiceConnection import android.net.VpnService import android.os.IBinder import org.getlantern.lantern.LanternApp +import org.getlantern.lantern.plausible.Plausible import org.getlantern.lantern.service.LanternService_ import org.getlantern.mobilesdk.Logger @@ -58,6 +59,7 @@ class LanternVpnService : VpnService(), Runnable { return START_STICKY } return if (intent.action == ACTION_DISCONNECT) { + Plausible.event("switchVPN", "", "", mapOf("status" to "disconnect")) stop() START_NOT_STICKY } else { @@ -69,6 +71,7 @@ class LanternVpnService : VpnService(), Runnable { private fun connect() { Logger.d(TAG, "connect") + Plausible.event("switchVPN", "", "", mapOf("status" to "connect")) Thread(this, "VpnService").start() } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 4ebb652bd..e121c1dcb 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -499,4 +499,10 @@ Calls Lantern Service Replica Download + + true + + https://plausible.io + + android.lantern.io diff --git a/android/settings.gradle b/android/settings.gradle index 31a9ce0ec..32344b562 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,3 +1,11 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + include ':app' def localPropertiesFile = new File(rootProject.projectDir, "local.properties") diff --git a/lib/ad_helper.dart b/lib/ad_helper.dart index c9b6f12e4..4ac77acaa 100644 --- a/lib/ad_helper.dart +++ b/lib/ad_helper.dart @@ -11,6 +11,7 @@ import 'package:clever_ads_solutions/public/MediationManager.dart'; import 'package:flutter/foundation.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:logger/logger.dart'; +import 'package:lantern/common/common.dart'; import 'package:lantern/replica/common.dart'; enum AdType { Google, CAS } @@ -109,6 +110,8 @@ class AdHelper { }, onAdShowedFullScreenContent: (ad) { logger.i('[Ads Manager] Showing Ads'); + PlausibleUtils.trackUserAction( + 'User shown interstitial ad', googleAttributes); }, onAdFailedToShowFullScreenContent: (ad, error) { logger.i( @@ -123,10 +126,12 @@ class AdHelper { ); _interstitialAd = ad; logger.i('[Ads Manager] to loaded $ad'); + PlausibleUtils.trackUserAction('Interstitial ad loaded', googleAttributes); }, onAdFailedToLoad: (err) { _failedLoadAttempts++; // increment the count on failure logger.i('[Ads Manager] failed to load $err'); + PlausibleUtils.trackUserAction('Interstitial ad failed to load', googleAttributes); _postShowingAds(); }, ), diff --git a/lib/common/common.dart b/lib/common/common.dart index 64a1b3506..f304fc8b9 100644 --- a/lib/common/common.dart +++ b/lib/common/common.dart @@ -37,6 +37,7 @@ export 'lru_cache.dart'; export 'model.dart'; export 'model_event_channel.dart'; export 'once.dart'; +export 'plausible.dart'; export 'session_model.dart'; export 'single_value_subscriber.dart'; export 'ui/audio.dart'; diff --git a/lib/common/plausible.dart b/lib/common/plausible.dart new file mode 100644 index 000000000..22c55b044 --- /dev/null +++ b/lib/common/plausible.dart @@ -0,0 +1,10 @@ +import 'package:plausible_analytics/plausible_analytics.dart'; + +class PlausibleUtils { + static trackUserAction(String name, [Map props = const {}]) { + Plausible plausible = + Plausible("https://plausible.io", "android.lantern.io"); + // Send goal + plausible.event(name: name, props: props); + } +} diff --git a/lib/replica/logic/api.dart b/lib/replica/logic/api.dart index aa7759eb9..004609f59 100644 --- a/lib/replica/logic/api.dart +++ b/lib/replica/logic/api.dart @@ -61,11 +61,13 @@ class ReplicaApi { break; } logger.v('_search(): uri: ${Uri.parse(s)}'); - final resp = await dio.get(s); if (resp.statusCode == 200) { logger .v('Statuscode: ${resp.statusCode} || body: ${resp.data.toString()}'); + PlausibleUtils.trackUserAction('User searched for Replica content', { + s: s, + }); return ReplicaSearchItem.fromJson(category, resp.data); } else { logger.e( diff --git a/lib/replica/logic/uploader.dart b/lib/replica/logic/uploader.dart index a0013621a..ef2c5ae0d 100644 --- a/lib/replica/logic/uploader.dart +++ b/lib/replica/logic/uploader.dart @@ -60,6 +60,9 @@ class ReplicaUploader { method: UploadMethod.POST, ), ); + PlausibleUtils.trackUserAction('User uploaded Replica content', { + fileTitle: fileTitle, + }); } // TODO <08-10-22, kalli> Figure out how to query endpoint with infohash (for rendering preview after uploading a file) diff --git a/lib/replica/ui/viewers/layout.dart b/lib/replica/ui/viewers/layout.dart index 1fd54a841..97c573441 100644 --- a/lib/replica/ui/viewers/layout.dart +++ b/lib/replica/ui/viewers/layout.dart @@ -32,6 +32,9 @@ abstract class ReplicaViewerLayoutState extends State { // For the Viewers in Replica, we are sending another request to fetch the below params. // That request goes to `/object_info` endpoint (as opposed it coming bundled in our ReplicaSearchItem) doFetchObjectInfo(); + PlausibleUtils.trackUserAction('User viewed Replica content', { + 'title': infoTitle, + }); } void doFetchObjectInfo() async { diff --git a/pubspec.lock b/pubspec.lock index 16831dac9..440b81fd2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1085,6 +1085,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + plausible_analytics: + dependency: "direct main" + description: + name: plausible_analytics + sha256: be9f0b467d23cd94861737f10101431ad8b7d280dc0c14f7251e0e24655b07fa + url: "https://pub.dev" + source: hosted + version: "0.3.0" plugin_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2192591fa..2fb5be89f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -102,6 +102,9 @@ dependencies: flutter_mailer: ^2.0.0 fluttertoast: ^8.2.2 + # Analytics + plausible_analytics: ^0.3.0 + # Package information package_info_plus: ^4.1.0