From ef229ac35675d59a3069aacd8559d8ccc1d22b8d Mon Sep 17 00:00:00 2001 From: atavism Date: Mon, 23 Oct 2023 13:46:57 -0700 Subject: [PATCH] Updates to LanternHttpClient and integrate MockK (#900) * Add Kotlin-based LanternHttpClient * Kotlin-based LanternHttpClient * Kotlin-based LanternHttpClient * Updates to LanternHttpClient * Add tests * Add tests * Add LanternHttpClientTest * formatting * Add ProUser data class * Formatting --------- Co-authored-by: atavism --- android/app/build.gradle | 1 + .../lantern/model/LanternHttpClient.java | 495 ------------------ .../getlantern/lantern/model/ProError.java | 45 -- .../org/getlantern/lantern/model/ProUser.java | 164 ------ .../kotlin/io/lantern/model/SessionModel.kt | 9 +- .../org/getlantern/lantern/LanternApp.kt | 2 +- .../org/getlantern/lantern/MainActivity.kt | 10 +- .../lantern/model/LanternSessionManager.kt | 4 +- .../org/getlantern/lantern/model/ProError.kt | 17 + .../org/getlantern/lantern/model/ProUser.kt | 47 ++ .../lantern/service/LanternService.kt | 9 +- .../lantern/util/LanternHttpClient.kt | 339 ++++++++++++ .../getlantern/lantern/util/PaymentsUtil.kt | 2 +- .../notification/NotificationHelperTest.kt | 62 +++ .../lantern/util/LanternHttpClientTest.kt | 57 ++ .../lantern/model/InAppBillingTest.kt | 148 ++++++ 16 files changed, 691 insertions(+), 720 deletions(-) delete mode 100644 android/app/src/main/java/org/getlantern/lantern/model/LanternHttpClient.java delete mode 100644 android/app/src/main/java/org/getlantern/lantern/model/ProError.java delete mode 100644 android/app/src/main/java/org/getlantern/lantern/model/ProUser.java create mode 100644 android/app/src/main/kotlin/org/getlantern/lantern/model/ProError.kt create mode 100644 android/app/src/main/kotlin/org/getlantern/lantern/model/ProUser.kt create mode 100644 android/app/src/main/kotlin/org/getlantern/lantern/util/LanternHttpClient.kt create mode 100644 android/app/src/test/kotlin/org/getlantern/lantern/notification/NotificationHelperTest.kt create mode 100644 android/app/src/test/kotlin/org/getlantern/lantern/util/LanternHttpClientTest.kt create mode 100644 android/app/src/testPlay/kotlin/org/getlantern/lantern/model/InAppBillingTest.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index fe5f86a0f..4ea8ece9d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -446,6 +446,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/src/main/java/org/getlantern/lantern/model/LanternHttpClient.java b/android/app/src/main/java/org/getlantern/lantern/model/LanternHttpClient.java deleted file mode 100644 index c6e8cd789..000000000 --- a/android/app/src/main/java/org/getlantern/lantern/model/LanternHttpClient.java +++ /dev/null @@ -1,495 +0,0 @@ -package org.getlantern.lantern.model; - -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.google.gson.reflect.TypeToken; - -import org.getlantern.lantern.LanternApp; -import org.getlantern.lantern.util.Json; -import org.getlantern.mobilesdk.Logger; -import org.getlantern.mobilesdk.util.HttpClient; - -import java.io.IOException; -import java.lang.reflect.Type; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import okhttp3.CacheControl; -import okhttp3.Call; -import okhttp3.Callback; -import okhttp3.FormBody; -import okhttp3.Headers; -import okhttp3.HttpUrl; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; -import okhttp3.ResponseBody; -import okio.Buffer; - -/** - * An OkHttp-based HTTP client. - */ -public class LanternHttpClient extends HttpClient { - private static final String TAG = LanternHttpClient.class.getName(); - - // the standard user headers sent with most Pro requests - private static final String DEVICE_ID_HEADER = "X-Lantern-Device-Id"; - private static final String USER_ID_HEADER = "X-Lantern-User-Id"; - private static final String PRO_TOKEN_HEADER = "X-Lantern-Pro-Token"; - private static final String APP_VERSION_HEADER = "X-Lantern-Version"; - private static final String PLATFORM_HEADER = "X-Lantern-Platform"; - - private static final MediaType JSON - = MediaType.parse("application/json; charset=utf-8"); - - /** - * Creates a new HTTP client - */ - public LanternHttpClient() { - super(); - } - - /** - * Creates a new HTTP client - * - * @param httpClient The HTTP client to use. - */ - public LanternHttpClient(final OkHttpClient httpClient) { - super(httpClient); - } - - public static HttpUrl createProUrl(final String uri) { - return createProUrl(uri, null); - } - - /** - * Constructs a url for a request to the pro server - * - * @param uri the requested resource - * @param params any query params to include with the url - * @return a URL - */ - public static HttpUrl createProUrl(final String uri, final Map 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..5cf700ceb 100644 --- a/android/app/src/main/kotlin/io/lantern/model/SessionModel.kt +++ b/android/app/src/main/kotlin/io/lantern/model/SessionModel.kt @@ -527,9 +527,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 +564,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 +641,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 65864e666..36a8047cc 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/LanternApp.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/LanternApp.kt @@ -96,7 +96,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 5a035e074..44275691b 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt @@ -195,6 +195,10 @@ class MainActivity : override fun onDestroy() { super.onDestroy() + if (accountInitDialog != null) { + accountInitDialog.dismiss() + } + vpnModel.destroy() sessionModel.destroy() replicaModel.destroy() @@ -313,8 +317,8 @@ class MainActivity : 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 @@ -333,7 +337,7 @@ class MainActivity : } private fun updatePlans() { - lanternClient.getPlans(object : PlansCallback { + 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/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/service/LanternService.kt b/android/app/src/main/kotlin/org/getlantern/lantern/service/LanternService.kt index ac39595ae..a55cd84b1 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 @@ -55,7 +55,8 @@ open class LanternService : Service(), Runnable { private val started: AtomicBoolean = AtomicBoolean() private lateinit var autoUpdater: AutoUpdater - override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null) return START_NOT_STICKY autoUpdater = AutoUpdater(this) val autoBooted = intent.getBooleanExtra(AUTO_BOOTED, false) Logger.d(TAG, "Called onStartCommand, autoBooted?: $autoBooted") @@ -155,7 +156,7 @@ open class LanternService : Service(), Runnable { service.createUser(attempts) } - 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") @@ -163,8 +164,8 @@ open class LanternService : Service(), Runnable { } service.createUserHandler.removeCallbacks(service.createUserRunnable) Logger.debug(TAG, "Created new Lantern user: ${user.newUserDetails()}") - LanternApp.getSession().setUserIdAndToken(user.getUserId(), user.getToken()) - val referral = user.getReferral() + LanternApp.getSession().setUserIdAndToken(user.userId, user.token) + val referral = user.referral if (!referral.isEmpty()) { LanternApp.getSession().setCode(referral) } 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/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() } + } +}