diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbc54fd5e..2f33c86bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,7 @@ name: Publish releases -on: push +on: + workflow_dispatch: permissions: contents: read diff --git a/Makefile b/Makefile index 408018a10..eda5cbea3 100644 --- a/Makefile +++ b/Makefile @@ -244,15 +244,7 @@ require-sentry: @if [[ -z "$(SENTRY)" ]]; then echo 'Missing "sentry-cli" command. See sentry.io for installation instructions.'; exit 1; fi release-autoupdate: require-version - @TAG_COMMIT=$$(git rev-list --abbrev-commit -1 $(TAG)) && \ - if [[ -z "$$TAG_COMMIT" ]]; then \ - echo "Could not find given tag $(TAG)."; \ - fi && \ - for URL in s3://lantern/lantern_update_android_arm-$$VERSION.bz2; do \ - NAME=$$(basename $$URL) && \ - STRIPPED_NAME=$$(echo "$$NAME" | cut -d - -f 1 | sed s/lantern_//).bz2 && \ - s3cmd get --force s3://$(S3_BUCKET)/$$NAME $$STRIPPED_NAME; \ - done && \ + @curl https://s3.amazonaws.com/lantern/lantern-installer.apk | bzip2 > update_android_arm.bz2 && \ $(RUBY) ./create_or_update_release.rb getlantern lantern $$VERSION update_android_arm.bz2 release: require-version require-s3cmd require-wget require-lantern-binaries require-release-track release-prod copy-beta-installers-to-mirrors invalidate-getlantern-dot-org upload-aab-to-play diff --git a/README.md b/README.md index f73c3ea4a..1d18973ff 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ To publish a release on Google Play, go to the Lantern App on the [Google Play C ### Enabling Auto-Update for a Sideloaded Release -Just because something's been released to prod doesn't mean clients will auto-update, there's an additional step for that. +Just because something's been released to prod doesn't mean clients will auto-update, there's an additional step for that. The below will release the current production version to autoupdate. Please make sure the VERSION parameter matches the current production version. ``` GH_TOKEN= VERSION=7.2.0 make release-autoupdate diff --git a/android/app/build.gradle b/android/app/build.gradle index fe5f86a0f..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" @@ -446,6 +444,7 @@ dependencies { androidTestImplementation 'com.squareup.okhttp3:okhttp:4.9.2' testImplementation 'junit:junit:4.13.2' + testImplementation "io.mockk:mockk:1.13.5" implementation "com.github.YarikSOffice:lingver:1.3.0" // from https://github.com/getlantern/opus_android diff --git a/android/app/libs/liblantern-all.aar b/android/app/libs/liblantern-all.aar index 949a55313..4961af106 100644 --- a/android/app/libs/liblantern-all.aar +++ b/android/app/libs/liblantern-all.aar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f874195707ea59ddf9477eee0fac6db5f8689bf87b6c563fbdfb763ff3dd96a4 -size 77698058 +oid sha256:9edf75981e16250fc6397ec39f5a7d9bd6487961d0951ad8e016b2f078ddab2c +size 77698340 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 61a1afccf..bba15cdc1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -97,6 +97,16 @@ android:resource="@xml/file_paths" /> + + + + params) { - final String url = String.format("http://localhost/pro%s", uri); - HttpUrl.Builder builder = HttpUrl.parse(url).newBuilder(); - if (params != null) { - for (Map.Entry param : params.entrySet()) { - builder.addQueryParameter(param.getKey(), param.getValue()); - } - } - return builder.build(); - } - - /** - * The HTTP headers expected with Pro requests for a user - */ - private Map userHeaders() { - final Map headers = new HashMap(); - headers.put(DEVICE_ID_HEADER, LanternApp.getSession().getDeviceID()); - headers.put(PRO_TOKEN_HEADER, LanternApp.getSession().getToken()); - headers.put(USER_ID_HEADER, String.valueOf(LanternApp.getSession().getUserID())); - headers.put(PLATFORM_HEADER, "android"); - headers.put(APP_VERSION_HEADER, Utils.appVersion(LanternApp.getAppContext())); - headers.putAll(LanternApp.getSession().getInternalHeaders()); - Logger.d(TAG, "User headers " + headers); - return headers; - } - - public void request(@NonNull final String method, @NonNull final HttpUrl url, - final HttpCallback cb) { - request(method, url, null, null, cb); - } - - public void request(@NonNull final String method, @NonNull final HttpUrl url, - final ProCallback cb) { - proRequest(method, url, null, null, cb); - } - - public void request(@NonNull final String method, @NonNull final HttpUrl url, - final boolean addProHeaders, - RequestBody body, final HttpCallback cb) { - if (addProHeaders) { - request(method, url, userHeaders(), body, cb); - } else { - request(method, url, null, body, cb); - } - } - - public void request(@NonNull final String method, @NonNull final HttpUrl url, - RequestBody body, final ProCallback cb) { - proRequest(method, url, userHeaders(), body, cb); - } - - /** - * GET request. - * - * @param url request URL - * @param cb for notifying the caller of an HTTP response or failure - */ - public void get(@NonNull final HttpUrl url, final ProCallback cb) { - proRequest("GET", url, userHeaders(), null, cb); - } - - /** - * POST request. - * - * @param url request URL - * @param body the data enclosed with the HTTP message - * @param cb the callback responded with an HTTP response or failure - */ - public void post(@NonNull final HttpUrl url, - final RequestBody body, @NonNull final ProCallback cb) { - proRequest("POST", url, userHeaders(), body, cb); - } - - private void processPlans(List fetched, final PlansCallback cb, InAppBilling inAppBilling) { - Map plans = new HashMap(); - Logger.debug(TAG, "Pro plans: " + fetched); - for (ProPlan plan : fetched) { - if (plan != null) { - plan.formatCost(); - Logger.debug(TAG, "New plan is " + plan); - plans.put(plan.getId(), plan); - } - } - if (inAppBilling != null) { - // this means we're in the play store, use the configured plans from there but with the - // renewal bonus from the server side plans - Map regularPlans = new HashMap<>(); - for (Map.Entry entry : plans.entrySet()) { - // Plans from the pro server have a version suffix, like '1y-usd-9' but plans from - // the Play Store don't, like '1y-usd'. So we normalize by dropping the version - // suffix. - regularPlans.put(entry.getKey().substring(0, entry.getKey().lastIndexOf("-")), entry.getValue()); - } - plans = inAppBilling.getPlans(); - for (Map.Entry entry : plans.entrySet()) { - ProPlan regularPlan = regularPlans.get(entry.getKey()); - if (regularPlan != null) { - entry.getValue().updateRenewalBonusExpected(regularPlan.getRenewalBonusExpected()); - } - } - } - cb.onSuccess(plans); - } - - private void processPlansV1(final JsonObject result, final PlansCallback cb, InAppBilling inAppBilling) { - String stripePubKey = result.get("providers").getAsJsonObject().get("stripe").getAsJsonObject().get("pubKey").getAsString(); - LanternApp.getSession().setStripePubKey(stripePubKey); - Type listType = new TypeToken>() { - }.getType(); - Logger.debug(TAG, "Plans: " + result.get("plans")); - final List fetched = Json.gson.fromJson(result.get("plans"), listType); - processPlans(fetched, cb, inAppBilling); - } - - private void processPlansV3(final JsonObject result, final PlansV3Callback cb, InAppBilling inAppBilling) { - Type mapType = new TypeToken>>() { - }.getType(); - Map> response = Json.gson.fromJson(result.get("providers"), mapType); - List providers = response.get("android"); - Type listType = new TypeToken>() { - }.getType(); - final List fetched = Json.gson.fromJson(result.get("plans"), listType); - Logger.debug(TAG, "Payment providers: " + providers); - Map plans = new HashMap(); - for (ProPlan plan : fetched) { - if (plan != null) { - plan.formatCost(); - plans.put(plan.getId(), plan); - } - } - cb.onSuccess(plans, providers); - } - - public void sendLinkRequest(final ProCallback cb) { - final HttpUrl url = createProUrl("/user-link-request"); - final RequestBody formBody = new FormBody.Builder() - .add("email", LanternApp.getSession().email()) - .add("deviceName", LanternApp.getSession().deviceName()) - .build(); - - post(url, formBody, - new LanternHttpClient.ProCallback() { - @Override - public void onFailure(final Throwable throwable, final ProError error) { - if (cb != null) { - cb.onFailure(throwable, error); - } - } - - @Override - public void onSuccess(final Response response, final JsonObject result) { - if (result.get("error") != null) { - onFailure(null, new ProError(result)); - } else if (cb != null) { - cb.onSuccess(response, result); - } - } - }); - } - - /** - * Returns all user data, including payments, referrals, and all available - * fields. - * - * @param cb for notifying the caller of an HTTP response or failure - */ - public void userData(final ProUserCallback cb) { - final Map params = new HashMap(); - params.put("locale", LanternApp.getSession().getLanguage()); - final HttpUrl url = createProUrl("/user-data", params); - get(url, new ProCallback() { - @Override - public void onFailure(final Throwable throwable, final ProError error) { - Logger.error(TAG, "Unable to fetch user data", throwable); - if (cb != null) { - cb.onFailure(throwable, error); - } - } - - @Override - public void onSuccess(final Response response, final JsonObject result) { - try { - Logger.debug(TAG, "JSON response" + result.toString()); - final ProUser user = Json.gson.fromJson(result, ProUser.class); - if (user != null) { - Logger.debug(TAG, "User ID is " + user.getUserId()); - LanternApp.getSession().storeUserData(user); - } - if (cb != null) { - cb.onSuccess(response, user); - } - } catch (Exception e) { - Logger.error(TAG, "Unable to fetch user data: " + e.getMessage(), e); - } - } - }); - } - - public void plans(final PlansCallback cb, InAppBilling inAppBilling) { - final Map params = new HashMap(); - params.put("locale", LanternApp.getSession().getLanguage()); - params.put("countrycode", LanternApp.getSession().getCountryCode()); - final HttpUrl url = createProUrl("/plans", params); - final Map plans = new HashMap(); - get(url, new ProCallback() { - @Override - public void onFailure(final Throwable throwable, final ProError error) { - Logger.error(TAG, "Unable to fetch plans", throwable); - cb.onFailure(throwable, error); - } - - @Override - public void onSuccess(final Response response, final JsonObject result) { - try { - Logger.debug(TAG, "JSON response for " + url + ":" + result.toString()); - processPlansV1(result, cb, inAppBilling); - } catch (Exception e) { - Logger.error(TAG, "Unable to fetch plans: " + e.getMessage(), e); - } - } - }); - } - - public void plansV3(final PlansV3Callback cb, InAppBilling inAppBilling) { - final Map params = new HashMap(); - params.put("locale", LanternApp.getSession().getLanguage()); - params.put("countrycode", LanternApp.getSession().getCountryCode()); - final HttpUrl url = createProUrl("/plans-v3", params); - final Map plans = new HashMap(); - get(url, new ProCallback() { - @Override - public void onFailure(final Throwable throwable, final ProError error) { - Logger.error(TAG, "Unable to fetch plans", throwable); - cb.onFailure(throwable, error); - } - - @Override - public void onSuccess(final Response response, final JsonObject result) { - try { - Logger.debug(TAG, "JSON response for " + url + ":" + result.toString()); - processPlansV3(result, cb, inAppBilling); - } catch (Exception e) { - Logger.error(TAG, "Unable to fetch plans: " + e.getMessage(), e); - } - } - }); - } - - - public void plansV3(final PlansCallback cb, InAppBilling inAppBilling) { - plansV3(cb, inAppBilling); - } - - public void getPlans(final PlansCallback cb, InAppBilling inAppBilling) { - plans(cb, inAppBilling); - } - - /** - * Convert a JsonObject json into a RequestBody that transmits content - * - * @param json the JsonObject to be converted - */ - public static RequestBody createJsonBody(final JsonObject json) { - return RequestBody.create(JSON, json.toString()); - } - - public void request(@NonNull final String method, @NonNull final HttpUrl url, - final Map headers, - RequestBody body, final HttpCallback cb) { - Request.Builder builder = new Request.Builder() - .cacheControl(CacheControl.FORCE_NETWORK); - if (headers != null) { - builder = builder.headers(Headers.of(headers)); - } - builder = builder.url(url); - - if (method != null && method.equals("POST")) { - if (body == null) { - body = RequestBody.create(null, new byte[0]); - } - builder = builder.post(body); - } - final Request request = builder.build(); - httpClient.newCall(request).enqueue(new Callback() { - @Override - public void onFailure(Call call, IOException e) { - if (cb != null) - cb.onFailure(e); - } - - @Override - public void onResponse(Call call, Response response) throws IOException { - if (!response.isSuccessful()) { - Logger.error(TAG, "Request to " + url + " failed"); - Logger.error(TAG, "Response: " + response); - final ResponseBody body = response.body(); - if (body != null) { - Logger.error(TAG, "Body: " + body.string()); - } - cb.onFailure(null); - return; - } - cb.onSuccess(response); - } - }); - } - - /** - * Creates a new HTTP request to be enqueued for later execution - * - * @param method the HTTP method - * @param url the URL target of this request - * @param headers the HTTP header fields to add to the request - * @param body the body of a POST request - * @param cb to notify the caller of an HTTP response or failure - */ - private void proRequest(@NonNull final String method, @NonNull final HttpUrl url, - final Map headers, - RequestBody body, final ProCallback cb) { - Request.Builder builder = new Request.Builder() - .cacheControl(CacheControl.FORCE_NETWORK); - if (headers != null) { - builder = builder.headers(Headers.of(headers)); - } - builder = builder.url(url); - - if (method != null && method.equals("POST")) { - if (body == null) { - body = RequestBody.create(null, new byte[0]); - } - builder = builder.post(body); - } - final Request request = builder.build(); - httpClient.newCall(request).enqueue(new Callback() { - @Override - public void onFailure(Call call, IOException e) { - if (e != null) { - final ProError error = new ProError("", e.getMessage()); - cb.onFailure(e, error); - } - } - - @Override - public void onResponse(Call call, Response response) throws IOException { - if (!response.isSuccessful()) { - Logger.error(TAG, "Request to " + url + " failed"); - Logger.error(TAG, "Response: " + response); - final ResponseBody body = response.body(); - if (body != null) { - Logger.error(TAG, "Body: " + body.string()); - } - final ProError error = new ProError("", "Unexpected response code from server"); - cb.onFailure(null, error); - return; - } - final String responseData = response.body().string(); - JsonObject result; - if (responseData == null) { - Logger.error(TAG, String.format("Invalid response body for %s request", url)); - return; - } - try { - result = (new JsonParser()).parse(responseData).getAsJsonObject(); - } catch (Throwable t) { - Logger.debug(TAG, "Not a JSON response"); - final ResponseBody body = ResponseBody.create(null, responseData); - cb.onSuccess(response.newBuilder().body(body).build(), null); - return; - } - if (result.get("error") != null) { - final String error = result.get("error").getAsString(); - Logger.error(TAG, "Error making request to " + url + ":" + result + " error:" + error); - cb.onFailure(null, new ProError(result)); - } else if (cb != null) { - cb.onSuccess(response, result); - } - } - }); - } - - public interface ProCallback { - public void onFailure(@Nullable Throwable throwable, @Nullable final ProError error); - - public void onSuccess(Response response, JsonObject result); - } - - public interface ProUserCallback { - public void onFailure(@Nullable Throwable throwable, @Nullable final ProError error); - - public void onSuccess(Response response, final ProUser userData); - } - - public interface HttpCallback { - public void onFailure(@Nullable Throwable throwable); - - public void onSuccess(Response response); - } - - public interface PlansCallback { - public void onFailure(@Nullable Throwable throwable, @Nullable final ProError error); - - public void onSuccess(Map plans); - } - - public interface PlansV3Callback { - public void onFailure(@Nullable Throwable throwable, @Nullable final ProError error); - - public void onSuccess(Map plans, List methods); - } - - public interface YuansferCallback { - public void onFailure(@Nullable Throwable throwable, @Nullable final ProError error); - - public void onSuccess(String paymentInfo); - } -} diff --git a/android/app/src/main/java/org/getlantern/lantern/model/ProError.java b/android/app/src/main/java/org/getlantern/lantern/model/ProError.java deleted file mode 100644 index 438ce1a38..000000000 --- a/android/app/src/main/java/org/getlantern/lantern/model/ProError.java +++ /dev/null @@ -1,45 +0,0 @@ -package org.getlantern.lantern.model; - -import androidx.annotation.NonNull; - -import com.google.gson.JsonObject; - -public class ProError { - - private String id; - private String message; - private JsonObject details; - - public ProError(@NonNull final String id, @NonNull final String message) { - this.id = id; - this.message = message; - } - - public ProError(@NonNull final JsonObject result) { - if (result.get("errorId") != null) { - this.id = result.get("errorId").getAsString(); - } - if (result.get("error") != null) { - this.message = result.get("error").getAsString(); - } - if (result.get("details") != null) { - this.details = result.get("details").getAsJsonObject(); - } - } - - public String getMessage() { - return message; - } - - public String getId() { - return id; - } - - public JsonObject getDetails() { - return details; - } - - public String toString() { - return String.format("Error; id=%s message=%s", id, message); - } -} diff --git a/android/app/src/main/java/org/getlantern/lantern/model/ProUser.java b/android/app/src/main/java/org/getlantern/lantern/model/ProUser.java deleted file mode 100644 index 0c6fc74f2..000000000 --- a/android/app/src/main/java/org/getlantern/lantern/model/ProUser.java +++ /dev/null @@ -1,164 +0,0 @@ -package org.getlantern.lantern.model; - -import com.google.gson.annotations.SerializedName; - -import org.joda.time.Days; -import org.joda.time.Months; -import org.joda.time.LocalDateTime; - -import java.util.List; - -import org.getlantern.mobilesdk.Logger; - -public class ProUser { - - private static final String TAG = ProUser.class.getName(); - - public ProUser() { - - } - - @SerializedName("userId") - private Long userId; - - @SerializedName("token") - private String token; - - @SerializedName("referral") - private String referral; - - @SerializedName("email") - private String email; - - @SerializedName("userStatus") - private String userStatus; - - @SerializedName("code") - private String code; - - @SerializedName("subscription") - private String subscription; - - @SerializedName("expiration") - private Long expiration; - - @SerializedName("devices") - private List devices; - - - @SerializedName("userLevel") - public String userLevel; - - public void setUserId(final Long userId) { - this.userId = userId; - } - - public Long getUserId() { - return userId; - } - - public void setToken(final String token) { - this.token = token; - } - - public String getToken() { - return token; - } - - public String getUserLevel() { - return this.userLevel; - } - - public void setUserLevel(final String level) { this.userLevel = level; } - - public void setReferral(final String referral) { - this.referral = referral; - } - - public String getReferral() { - return referral; - } - - public void setEmail(final String email) { - this.email = email; - } - - public String getEmail() { - return email; - } - - public void setUserStatus(final String userStatus) { - this.userStatus = userStatus; - } - - public String getUserStatus() { - return userStatus; - } - - public Boolean isProUser() { - return userStatus != null && userStatus.equals("active"); - } - - public void setCode(final String code) { - this.code = code; - } - - public String getCode() { - return code; - } - - public void setExpiration(final Long expiration) { - this.expiration = expiration; - } - - public Long getExpiration() { - return expiration; - } - - public LocalDateTime getExpirationDate() { - final Long expiration = getExpiration(); - if (expiration == null) { - return null; - } - return new LocalDateTime(expiration * 1000); - } - - public Integer monthsLeft() { - final LocalDateTime expDate = getExpirationDate(); - if (expDate != null) { - final int months = Months.monthsBetween(LocalDateTime.now(), expDate).getMonths(); - return months; - } - return null; - } - - public Integer daysLeft() { - final LocalDateTime expDate = getExpirationDate(); - if (expiration != null) { - final int days = Days.daysBetween(LocalDateTime.now(), expDate).getDays(); - Logger.debug(TAG, "Number of days until Pro account expires " + days); - return days; - } - return null; - } - - public Boolean isActive() { - return userStatus != null && userStatus.equals("active"); - } - - public Boolean isExpired() { - return userStatus != null && userStatus.equals("expired"); - } - - public List getDevices() { - return devices; - } - - public String toString() { - return String.format("User ID %d status %s expiration %d Pro user %b", userId, userStatus, expiration, isProUser()); - } - - public String newUserDetails() { - return String.format("User ID %d referral", userId, referral); - } -} 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 afdfa7c53..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() } @@ -527,9 +532,8 @@ class SessionModel( } } - override fun onSuccess(response: Response, result: JsonObject) { - Logger.debug(TAG, "Response: $result") - if (result["token"] != null && result["userID"] != null) { + override fun onSuccess(response: Response?, result: JsonObject?) { + if (result != null && result["token"] != null && result["userID"] != null) { Logger.debug(TAG, "Successfully validated recovery code") // update token and user ID with those returned by the pro server // update token and user ID with those returned by the pro server @@ -565,7 +569,7 @@ class SessionModel( activity.showErrorDialog(activity.resources.getString(R.string.invalid_verification_code)) } - override fun onSuccess(response: Response, result: JsonObject) { + override fun onSuccess(response: Response?, result: JsonObject?) { lanternClient.userData(object : ProUserCallback { override fun onSuccess(response: Response, userData: ProUser) { Logger.debug(TAG, "Successfully updated userData") @@ -642,7 +646,7 @@ class SessionModel( activity.showErrorDialog(activity.resources.getString(R.string.unable_remove_device)) } - override fun onSuccess(response: Response, result: JsonObject) { + override fun onSuccess(response: Response?, result: JsonObject?) { Logger.debug(TAG, "Successfully removed device") val isLogout = deviceId == LanternApp.getSession().deviceID diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/LanternApp.kt b/android/app/src/main/kotlin/org/getlantern/lantern/LanternApp.kt index 9e7a334ff..9456d565f 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/LanternApp.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/LanternApp.kt @@ -119,7 +119,7 @@ open class LanternApp : Application() { @JvmStatic fun getPlans(cb: LanternHttpClient.PlansCallback) { - lanternHttpClient.getPlans( + lanternHttpClient.plans( cb, if (session.isPlayVersion && !session.isRussianUser) inAppBilling else null ) 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 a8869ddbe..d35247fb1 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt @@ -1,5 +1,7 @@ package org.getlantern.lantern +import android.annotation.SuppressLint +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.VpnService @@ -11,6 +13,7 @@ import android.widget.TextView import androidx.annotation.NonNull import androidx.appcompat.app.AlertDialog import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngineCache @@ -39,6 +42,7 @@ import org.getlantern.lantern.model.Stats import org.getlantern.lantern.model.Utils import org.getlantern.lantern.model.VpnState import org.getlantern.lantern.service.LanternConnection +import org.getlantern.lantern.plausible.Plausible import org.getlantern.lantern.util.PermissionUtil import org.getlantern.lantern.util.PlansUtil import org.getlantern.lantern.util.isServiceRunning @@ -80,6 +84,7 @@ class MainActivity : FlutterActivity(), CoroutineScope by MainScope() { eventManager = object : EventManager("lantern_event_channel", flutterEngine) { override fun onListen(event: Event) { if (LanternApp.getSession().lanternDidStart()) { + Plausible.enable(true) fetchLoConf() Logger.debug( TAG, @@ -115,7 +120,6 @@ class MainActivity : FlutterActivity(), CoroutineScope by MainScope() { 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) @@ -160,6 +164,10 @@ class MainActivity : FlutterActivity(), CoroutineScope by MainScope() { override fun onDestroy() { super.onDestroy() + if (accountInitDialog != null) { + accountInitDialog.dismiss() + } + vpnModel.destroy() sessionModel.destroy() replicaModel.destroy() @@ -256,8 +264,8 @@ class MainActivity : FlutterActivity(), CoroutineScope by MainScope() { Logger.error(TAG, "Unable to fetch user data: $error", throwable) } - override fun onSuccess(response: Response, user: ProUser?) { - val devices = user?.getDevices() + override fun onSuccess(response: Response, user: ProUser) { + val devices = user?.devices val deviceID = LanternApp.getSession().deviceID() // if the payment test mode is enabled // then do nothing To avoid restarting app while debugging @@ -276,7 +284,7 @@ class MainActivity : FlutterActivity(), CoroutineScope by MainScope() { } private fun updatePlans() { - lanternClient.getPlans( + lanternClient.plans( object : PlansCallback { override fun onFailure(throwable: Throwable?, error: ProError?) { Logger.error(TAG, "Unable to fetch user plans: $error", throwable) diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/activity/FreeKassaActivity.kt b/android/app/src/main/kotlin/org/getlantern/lantern/activity/FreeKassaActivity.kt index c94009378..10ebb48e8 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/activity/FreeKassaActivity.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/activity/FreeKassaActivity.kt @@ -196,17 +196,20 @@ open class FreeKassaActivity : BaseFragmentActivity() { } } } + + val plan = LanternApp.getSession().planByID(planID!!)!! + val currency = plan.currency val u = FreeKassa.getPayURI( merchantId, price!!, - LanternApp.getSession().currency(), + currency, planID!!, secretWordOne, LanternApp.getSession().language, userEmail!!, mapOf( "transactionid" to transactionID, - "paymentcurrency" to LanternApp.getSession().currency() + "paymentcurrency" to currency ) ) Logger.d(TAG, "freeKassa Payment URI: $u") diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/model/LanternSessionManager.kt b/android/app/src/main/kotlin/org/getlantern/lantern/model/LanternSessionManager.kt index fd6c278f5..0a28b2d3b 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/model/LanternSessionManager.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/model/LanternSessionManager.kt @@ -293,8 +293,8 @@ class LanternSessionManager(application: Application) : SessionManager(applicati if (user.isProUser) { EventBus.getDefault().post(UserStatus(user.isActive, user.monthsLeft().toLong())) - prefs.edit().putInt(PRO_MONTHS_LEFT, user.monthsLeft()) - .putInt(PRO_DAYS_LEFT, user.daysLeft()) + prefs.edit().putInt(PRO_MONTHS_LEFT, user.monthsLeft() ?: 0) + .putInt(PRO_DAYS_LEFT, user.daysLeft() ?: 0) .apply() } } diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/model/ProError.kt b/android/app/src/main/kotlin/org/getlantern/lantern/model/ProError.kt new file mode 100644 index 000000000..31a63a8e1 --- /dev/null +++ b/android/app/src/main/kotlin/org/getlantern/lantern/model/ProError.kt @@ -0,0 +1,17 @@ +package org.getlantern.lantern.model + +import com.google.gson.JsonObject + +data class ProError( + val id: String, + val message: String, + val details: JsonObject? = null +) { + constructor(result: JsonObject) : this( + result.get("errorId").asString, + result.get("error").asString, + result.get("details").asJsonObject, + ) { + + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/model/ProUser.kt b/android/app/src/main/kotlin/org/getlantern/lantern/model/ProUser.kt new file mode 100644 index 000000000..1c3e72cce --- /dev/null +++ b/android/app/src/main/kotlin/org/getlantern/lantern/model/ProUser.kt @@ -0,0 +1,47 @@ +package org.getlantern.lantern.model + +import org.joda.time.Days +import org.joda.time.LocalDateTime +import org.joda.time.Months + +data class ProUser( + val userId: Long, + val token: String, + val referral: String, + val email: String, + val userStatus: String, + val code: String, + val subscription: String, + val expiration: Long, + val devices: List, + val userLevel: String, +) { + private fun isUserStatus(status: String) = userStatus == status + + private fun expirationDate() = if (expiration == null) null else LocalDateTime(expiration * 1000) + + fun monthsLeft(): Int { + val expDate = expirationDate() + if (expDate == null) return 0 + return Months.monthsBetween(LocalDateTime.now(), expDate).getMonths() + } + + fun daysLeft(): Int { + val expDate = expirationDate() + if (expDate == null) return 0 + return Days.daysBetween(LocalDateTime.now(), expDate).getDays() + } + + fun newUserDetails(): String { + return "User ID $userId referral $referral" + } + + val isProUser: Boolean + get() = isUserStatus("active") + + val isActive: Boolean + get() = isProUser + + val isExpired: Boolean + get() = isUserStatus("expired") +} 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/service/LanternService.kt b/android/app/src/main/kotlin/org/getlantern/lantern/service/LanternService.kt index ad8aced50..e75ed986a 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/service/LanternService.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/service/LanternService.kt @@ -92,7 +92,7 @@ class LanternService : Service(), ServiceManager.Runner { Logger.error(TAG, "Unable to fetch user data: $error", throwable) } - override fun onSuccess(response: Response, result: JsonObject) { + override fun onSuccess(response: Response?, result: JsonObject?) { val user: ProUser? = Json.gson.fromJson(result, ProUser::class.java) if (user == null) { Logger.error(TAG, "Unable to parse user from JSON") @@ -100,10 +100,10 @@ class LanternService : Service(), ServiceManager.Runner { } Logger.debug(TAG, "Created new Lantern user: ${user.newUserDetails()}") LanternApp.getSession().setUserIdAndToken( - user.getUserId(), - user.getToken(), + user.userId, + user.token, ) - val referral = user.getReferral() + val referral = user.referral if (!referral.isEmpty()) { LanternApp.getSession().setCode(referral) } @@ -129,4 +129,5 @@ class LanternService : Service(), ServiceManager.Runner { 1, Notifications.serviceBuilder(this).build(), ) + } diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/util/LanternHttpClient.kt b/android/app/src/main/kotlin/org/getlantern/lantern/util/LanternHttpClient.kt new file mode 100644 index 000000000..5356c8602 --- /dev/null +++ b/android/app/src/main/kotlin/org/getlantern/lantern/util/LanternHttpClient.kt @@ -0,0 +1,339 @@ +package org.getlantern.lantern.model + +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.google.gson.reflect.TypeToken +import okhttp3.CacheControl +import okhttp3.Call +import okhttp3.Callback +import okhttp3.FormBody +import okhttp3.Headers.Companion.toHeaders +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response +import org.getlantern.lantern.LanternApp +import org.getlantern.mobilesdk.Logger +import org.getlantern.mobilesdk.util.HttpClient +import java.io.IOException + +// An OkHttp-Based HTTP client for communicating with the Pro server +open class LanternHttpClient : HttpClient() { + private fun userHeaders(): MutableMap { + val headers = mutableMapOf() + headers.put(DEVICE_ID_HEADER, LanternApp.getSession().deviceID) + headers.put(PRO_TOKEN_HEADER, LanternApp.getSession().token) + headers.put(USER_ID_HEADER, LanternApp.getSession().userID.toString()) + headers.put(PLATFORM_HEADER, "android") + headers.put(APP_VERSION_HEADER, Utils.appVersion(LanternApp.getAppContext())) + headers.putAll(LanternApp.getSession().getInternalHeaders()) + return headers + } + + fun get( + url: HttpUrl, + cb: ProCallback, + ) { + proRequest("GET", url, userHeaders(), null, cb) + } + + fun post( + url: HttpUrl, + body: RequestBody, + cb: ProCallback, + ) { + proRequest("POST", url, userHeaders(), body, cb) + } + + inline fun parseData(row: String): T { + return Gson().fromJson(row, object : TypeToken() {}.type) + } + + fun userData(cb: ProUserCallback) { + val params = mapOf("locale" to LanternApp.getSession().language) + val url = createProUrl("/user-data", params) + get( + url, + object : ProCallback { + override fun onFailure( + throwable: Throwable?, + error: ProError?, + ) { + cb.onFailure(throwable, error) + } + + override fun onSuccess( + response: Response?, + result: JsonObject?, + ) { + Logger.debug(TAG, "JSON response" + result.toString()) + result?.let { + val user = parseData(result.toString()) + Logger.debug(TAG, "User ID is ${user.userId}") + LanternApp.getSession().storeUserData(user) + } + } + }, + ) + } + + fun sendLinkRequest(cb: ProCallback?) { + val url = createProUrl("/user-link-request") + val formBody = + FormBody.Builder() + .add("email", LanternApp.getSession().email()) + .add("deviceName", LanternApp.getSession().deviceName()) + .build() + post( + url, + formBody, + object : ProCallback { + override fun onFailure( + throwable: Throwable?, + error: ProError?, + ) { + if (cb != null) cb.onFailure(throwable, error) + } + + override fun onSuccess( + response: Response?, + result: JsonObject?, + ) { + result?.get("error")?.let { + onFailure(null, ProError(result)) + } + } + }, + ) + } + + private fun plansMap(fetched: List): Map { + val plans = mutableMapOf() + for (plan in fetched) { + plan.formatCost() + plans.put(plan.id, plan) + } + return plans + } + + fun plans( + cb: PlansCallback, + inAppBilling: InAppBilling?, + ) { + val params = + mapOf( + "locale" to LanternApp.getSession().language, + "countrycode" to LanternApp.getSession().getCountryCode(), + ) + val url = createProUrl("/plans", params) + get( + url, + object : ProCallback { + override fun onFailure( + throwable: Throwable?, + error: ProError?, + ) { + cb.onFailure(throwable, error) + } + + override fun onSuccess( + response: Response?, + result: JsonObject?, + ) { + // val mapType = TypeToken>() {}.type + val stripePubKey = + result?.get("providers")?.asJsonObject + ?.get("stripe")?.asJsonObject?.get("pubKey")?.asString + LanternApp.getSession().setStripePubKey(stripePubKey) + val fetched = parseData>(result?.get("plans").toString()) + Logger.debug(TAG, "Pro plans: $fetched") + var plans = plansMap(fetched) + if (inAppBilling != null) { + // this means we're in the play store, use the configured plans from there but + // with the renewal bonus from the server side plans + val regularPlans = mutableMapOf() + plans.forEach { (key, value) -> + regularPlans.put(key.substring(0, key.lastIndexOf("-")), value) + } + plans = inAppBilling.plans + plans.forEach { (key, value) -> + val regularPlan = regularPlans.get(key) + if (regularPlan != null) { + value.updateRenewalBonusExpected(regularPlan.renewalBonusExpected) + } + } + } + cb.onSuccess(plans) + } + }, + ) + } + + fun plansV3( + cb: PlansV3Callback, + inAppBilling: InAppBilling?, + ) { + val params = + mapOf( + "locale" to LanternApp.getSession().language, + "countrycode" to LanternApp.getSession().getCountryCode(), + ) + get( + createProUrl("/plans-v3", params), + object : ProCallback { + override fun onFailure( + throwable: Throwable?, + error: ProError?, + ) { + Logger.error(TAG, "Unable to fetch plans", throwable) + cb.onFailure(throwable, error) + } + + override fun onSuccess( + response: Response?, + result: JsonObject?, + ) { + val methods = + parseData>>( + result?.get("providers").toString(), + ) + val providers = methods.get("android") + val fetched = parseData>(result?.get("plans").toString()) + val plans = plansMap(fetched) + if (providers != null) cb.onSuccess(plans, providers) + } + }, + ) + } + + private fun proRequest( + method: String, + url: HttpUrl, + headers: Map, + body: RequestBody?, + cb: ProCallback, + ) { + var builder = + Request.Builder().cacheControl(CacheControl.FORCE_NETWORK) + .headers(headers.toHeaders()) + .url(url) + if (method == "POST") { + var requestBody = if (body != null) body else RequestBody.create(null, ByteArray(0)) + builder = builder.post(requestBody) + } + + val request = builder.build() + httpClient.newCall(request).enqueue( + object : Callback { + override fun onFailure( + call: Call, + e: IOException, + ) { + cb.onFailure(e, ProError("", e.message ?: "")) + } + + override fun onResponse( + call: Call, + response: Response, + ) { + response.use { + if (!response.isSuccessful) { + val error = ProError("", "Unexpected response code from server $response") + cb.onFailure(null, error) + return + } + val responseData = response.body!!.string() + Logger.d(TAG, "Response body " + responseData) + val result = JsonParser().parse(responseData).asJsonObject + if (result == null) { + return + } else if (result.get("error") != null) { + var error = result.get("error").asString + error = "Error making request to $url: $result error: $error" + Logger.error(TAG, error) + cb.onFailure(null, ProError("", error)) + return + } + cb.onSuccess(response, result) + } + } + }, + ) + } + + interface ProCallback { + fun onFailure( + throwable: Throwable?, + error: ProError?, + ) + + abstract fun onSuccess( + response: Response?, + result: JsonObject?, + ) + } + + interface ProUserCallback { + fun onFailure( + throwable: Throwable?, + error: ProError?, + ) + + fun onSuccess( + response: Response, + userData: ProUser, + ) + } + + interface PlansCallback { + fun onFailure( + throwable: Throwable?, + error: ProError?, + ) + + fun onSuccess(plans: Map) + } + + interface PlansV3Callback { + fun onFailure( + throwable: Throwable?, + error: ProError?, + ) + + fun onSuccess( + plans: Map, + methods: List, + ) + } + + companion object { + private const val DEVICE_ID_HEADER = "X-Lantern-Device-Id" + private const val USER_ID_HEADER = "X-Lantern-User-Id" + private const val PRO_TOKEN_HEADER = "X-Lantern-Pro-Token" + private const val APP_VERSION_HEADER = "X-Lantern-Version" + private const val PLATFORM_HEADER = "X-Lantern-Platform" + private val TAG = LanternHttpClient::class.java.name + + private var JSON: MediaType? = "application/json; charset=utf-8".toMediaTypeOrNull() + + fun createProUrl( + uri: String, + params: Map = mutableMapOf(), + ): HttpUrl { + val url = "http://localhost/pro$uri" + var builder = url.toHttpUrl().newBuilder() + for ((key, value) in params) { + builder.addQueryParameter(key, value) + } + return builder.build() + } + + fun createJsonBody(json: JsonObject): RequestBody { + return RequestBody.create(JSON, json.toString()) + } + } +} diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/util/PaymentsUtil.kt b/android/app/src/main/kotlin/org/getlantern/lantern/util/PaymentsUtil.kt index 02d20db40..cb7fcbff5 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/util/PaymentsUtil.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/util/PaymentsUtil.kt @@ -209,7 +209,7 @@ class PaymentsUtil(private val activity: Activity) { } } - override fun onSuccess(response: Response, result: JsonObject) { + override fun onSuccess(response: Response?, result: JsonObject?) { Logger.debug( TAG, "Successfully redeemed referral code: $refCode", 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/app/src/test/kotlin/org/getlantern/lantern/notification/NotificationHelperTest.kt b/android/app/src/test/kotlin/org/getlantern/lantern/notification/NotificationHelperTest.kt new file mode 100644 index 000000000..3e3e25c0b --- /dev/null +++ b/android/app/src/test/kotlin/org/getlantern/lantern/notification/NotificationHelperTest.kt @@ -0,0 +1,62 @@ +package org.getlantern.lantern.notification + +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test + +class NotificationHelperTest { + + @MockK + lateinit var notificationHelper: NotificationHelper + + var receiverSlot = slot() + + val appContext : Context = mockk(relaxed = true) { + every { + getSystemService(Context.NOTIFICATION_SERVICE) + } returns mockk() + } + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxUnitFun = true) + val receiverIntent = mockk() + every { appContext.registerReceiver(capture(receiverSlot), any()) } returns receiverIntent + notificationHelper = NotificationHelper(appContext, receiverSlot.captured) + } + + @After + fun tearDown() { + verify(exactly = 1) { appContext.unregisterReceiver(receiverSlot.captured) } + } + + @Test + fun `Disconnect broadcast resolves to pending intent`() { + val receiverIntent = mockk() + every { appContext.registerReceiver(capture(receiverSlot), any()) } returns receiverIntent + + notificationHelper = NotificationHelper(appContext, receiverSlot.captured) + val intent = Intent("org.getlantern.lantern.intent.VPN_DISCONNECTED") + val pendingIntent = + PendingIntent.getBroadcast(appContext, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + every { notificationHelper.disconnectBroadcast() } returns pendingIntent + } + + @Test + fun `VPN connected notification`() { + notificationHelper.vpnConnectedNotification() + } +} diff --git a/android/app/src/test/kotlin/org/getlantern/lantern/util/LanternHttpClientTest.kt b/android/app/src/test/kotlin/org/getlantern/lantern/util/LanternHttpClientTest.kt new file mode 100644 index 000000000..3df494da6 --- /dev/null +++ b/android/app/src/test/kotlin/org/getlantern/lantern/util/LanternHttpClientTest.kt @@ -0,0 +1,57 @@ +package org.getlantern.lantern.util + +import android.content.Context +import android.content.Intent +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Before +import org.junit.Test + +class LanternHttpClientTest { + @Test + fun `from raw http - normal`() { + val statusOK = "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json;charset=utf-8\r\n" + + "X-Lantern-Device-Id: abc\r\n" + + "X-Lantern-User-Id: 12345\r\n" + + "\r\n" + + "Some Content" + + statusOK.toVauInnerHttpResponse(mockk()).let { + assertEquals(Protocol.HTTP_1_1, it.protocol) + assertEquals(200, it.code) + assertEquals("abc", it.headers["X-Lantern-Device-Id"]) + assertEquals("12345", it.headers["X-Lantern-User-Id"]) + assertEquals("application/json;charset=utf-8", it.body!!.contentType().toString()) + assertEquals("Some Content", it.body!!.string()) + } + + val notFound = "HTTP/1.1 400 NOT FOUND\r\n" + + "Content-Type: application/json;charset=utf-8\r\n" + + "X-Header: abc\r\n" + + "Y-Header: 12345\r\n" + + "\r\n" + + "Some Content" + + notFound.toVauInnerHttpResponse(mockk()).let { + assertEquals(Protocol.HTTP_1_1, it.protocol) + assertEquals(400, it.code) + assertEquals("abc", it.headers["X-Lantern-Device-Id"]) + assertEquals("12345", it.headers["X-Lantern-User-Id"]) + assertEquals("application/json;charset=utf-8", it.body!!.contentType().toString()) + assertEquals("Some Content", it.body!!.string()) + } + } +} diff --git a/android/app/src/testPlay/kotlin/org/getlantern/lantern/model/InAppBillingTest.kt b/android/app/src/testPlay/kotlin/org/getlantern/lantern/model/InAppBillingTest.kt new file mode 100644 index 000000000..6157b18ae --- /dev/null +++ b/android/app/src/testPlay/kotlin/org/getlantern/lantern/model/InAppBillingTest.kt @@ -0,0 +1,148 @@ +package org.getlantern.lantern.model + +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry +import com.android.billingclient.api.AcknowledgePurchaseParams +import com.android.billingclient.api.AcknowledgePurchaseResponseListener +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ConsumeParams +import com.android.billingclient.api.ConsumeResponseListener +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.PurchaseHistoryRecord +import com.android.billingclient.api.PurchaseHistoryResponseListener +import com.android.billingclient.api.PurchasesResponseListener +import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.SkuDetails +import com.android.billingclient.api.SkuDetailsParams +import com.android.billingclient.api.SkuDetailsResponseListener +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class InAppBillingTest { + + @MockK + lateinit var builder: BillingClient.Builder + + @MockK + lateinit var billingClient: BillingClient + + @MockK + lateinit var availability: GoogleApiAvailability + + private lateinit var inAppBilling: InAppBilling + + @Before + fun setUp() { + val context = mockk(relaxed = true) + MockKAnnotations.init(this, relaxUnitFun = true) + every { builder.setListener(any()) } returns builder + every { builder.build() } returns billingClient + inAppBilling = InAppBilling(context, builder, availability) + } + + private fun playServicesAvailable() = + every { availability.isGooglePlayServicesAvailable(any()) } returns ConnectionResult.SUCCESS + + @Test + fun `initConnection new connection succeeds`() { + every { billingClient.isReady } returns false + val listener = slot() + every { billingClient.startConnection(capture(listener)) } answers { + listener.captured.onBillingSetupFinished( + BillingResult.newBuilder().setResponseCode(BillingClient.BillingResponseCode.OK) + .build() + ) + } + playServicesAvailable() + inAppBilling.initConnection() + } + + @Test + fun `initConnection new connection fails`() { + every { billingClient.isReady } returns false + val listener = slot() + every { billingClient.startConnection(capture(listener)) } answers { + listener.captured.onBillingSetupFinished( + BillingResult.newBuilder().setResponseCode(BillingClient.BillingResponseCode.ERROR) + .build() + ) + } + playServicesAvailable() + + inAppBilling.initConnection() + } + + @Test + fun `endConnection resolves`() { + playServicesAvailable() + + inAppBilling.initConnection() + inAppBilling.endConnection() + + verify { billingClient.endConnection() } + } + + @Test + fun `ensureConnection should attempt to reconnect, if not in ready state`() { + playServicesAvailable() + var callbackCalled = false + every { billingClient.isReady } returns true + val listener = slot() + every { billingClient.startConnection(capture(listener)) } answers { + listener.captured.onBillingSetupFinished( + BillingResult.newBuilder().setResponseCode(BillingClient.BillingResponseCode.OK) + .build() + ) + } + inAppBilling.billingClient = billingClient + inAppBilling.ensureConnected { + callbackCalled = true + } + assertTrue("Callback should be called", callbackCalled) + } + + @Test + fun `handlePendingPurchases successfully handles pending purchases`() { + playServicesAvailable() + every { billingClient.isReady } returns true + val listener = slot() + every { billingClient.queryPurchasesAsync(any(), capture(listener)) } answers { + listener.captured.onQueryPurchasesResponse( + BillingResult.newBuilder().build(), + listOf( + mockk { + every { purchaseState } returns 2 + every { purchaseToken } returns "token" + }, + ) + ) + } + val consumeListener = slot() + every { billingClient.consumeAsync(any(), capture(consumeListener)) } answers { + consumeListener.captured.onConsumeResponse( + BillingResult.newBuilder() + .setResponseCode(BillingClient.BillingResponseCode.ITEM_NOT_OWNED).build(), + "" + ) + } + inAppBilling.billingClient = billingClient + inAppBilling.initConnection() + verify { inAppBilling.handlePendingPurchases() } + } +} 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/account/privacy_disclosure.dart b/lib/account/privacy_disclosure.dart index c7fac560c..4e15b7598 100644 --- a/lib/account/privacy_disclosure.dart +++ b/lib/account/privacy_disclosure.dart @@ -1,38 +1,44 @@ import 'package:lantern/common/common.dart'; class PrivacyDisclosure extends StatelessWidget { + const PrivacyDisclosure({super.key}); + @override Widget build(BuildContext context) { return FullScreenDialog( widget: Padding( - padding: EdgeInsetsDirectional.only( - start: 33, end: 33), + padding: const EdgeInsetsDirectional.only(start: 33, end: 33), + child: SingleChildScrollView( child: Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - margin: const EdgeInsetsDirectional.only(top: 38), + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + margin: const EdgeInsetsDirectional.only(top: 38), child: CText( 'privacy_disclosure_title'.i18n, style: tsSubtitle1.copiedWith(fontSize: 24.0), - )), - Padding( - padding: EdgeInsetsDirectional.only(top: 24, bottom: 24), - child: CText( - 'privacy_disclosure_body'.i18n, - style: tsBody2.copiedWith(fontSize: 14.0, lineHeight: 24.0), - ), ), - const Spacer(), - Container( - margin: const EdgeInsetsDirectional.only(bottom: 38), - child: Button( - width: 200, - text: 'privacy_disclosure_accept'.i18n, - onPressed: () async => await sessionModel.acceptTerms(), - )), - ])), + ), + Padding( + padding: const EdgeInsetsDirectional.only(top: 24, bottom: 24), + child: CText( + 'privacy_disclosure_body'.i18n, + style: tsBody2.copiedWith(fontSize: 14.0, lineHeight: 24.0), + ), + ), + Container( + margin: const EdgeInsetsDirectional.only(bottom: 38), + child: Button( + width: 200, + text: 'privacy_disclosure_accept'.i18n, + onPressed: () async => await sessionModel.acceptTerms(), + ), + ), + ], + ), + ), + ), ); } } diff --git a/lib/account/split_tunneling.dart b/lib/account/split_tunneling.dart index 785cff384..63844c95b 100644 --- a/lib/account/split_tunneling.dart +++ b/lib/account/split_tunneling.dart @@ -25,7 +25,6 @@ class _SplitTunnelingState extends State { void init() async { unawaited(sessionModel.refreshAppsList()); - await sessionModel.trackUserAction('Split tunneling screen shown to user'); var _vpnConnected = await vpnModel.isVpnConnected(); setState(() { vpnConnected = _vpnConnected; 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/common/session_model.dart b/lib/common/session_model.dart index acfe71d05..ae0ca74a1 100644 --- a/lib/common/session_model.dart +++ b/lib/common/session_model.dart @@ -348,14 +348,6 @@ class SessionModel extends Model { ); } - Future trackUserAction( - String message, - ) async { - return methodChannel.invokeMethod('trackUserAction', { - 'message': message, - }); - } - Future redeemResellerCode( String email, String resellerCode, 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